AI coding

Read-Only by Default: Agent Tasks Without Writes

Antonio Evans10 min read

A split scene on a plain white background.
Key points

Key points

  1. 1Scope an agent to a SELECT-only database user when its job is to answer a question, and add a write path only when its job is to change state.
  2. 2A task fits read-only scope if its success can be verified by reading data back, which covers lookups, reporting, triage, reconciliation and cache warmers.
  3. 3Three signals rule out pure read-only scope: the task must persist state across runs, retry safely after a crash, or leave a durable audit record.
  4. 4When a write is unavoidable, split it into a propose step (the agent inserts an intent row under a SELECT-plus-INSERT grant) and an apply step (a separate service with write grants validates and executes it).

Start with one question: decision or change?

Most teams reach for a permission dialog when they should reach for a database grant. The dialog is a weak control: Anthropic's own write-up on auto mode reports that users approved 93 percent of permission prompts, which suggests the prompt is often treated as ceremony rather than a boundary (Penligent's review of coding agent sandboxes). A role that literally cannot issue an UPDATE has no ceremony to click through.

So the first design question is not "what tools does the agent need?" It is "is the deliverable a decision or a change?" If the output is a number, a list, a label, a diff of discrepancies or a ranked shortlist, the agent is answering a question and a SELECT-only account is enough. If the output is a new row, a status flip, a payment, a sent message or a schema alteration, the agent is changing state and needs a write path, ideally not its own.

The verification test follows directly. Write down the sentence that proves the task succeeded. If that sentence can be checked by running a query against the database, you are still in read-only territory. "The agent identified 41 orders whose shipping address does not match the carrier record" is verifiable by reading. "The agent fixed the 41 addresses" is verifiable only by reading back rows the agent itself wrote, which means the agent needed a write.

The decision rule, stated plainly

TestIf yesIf no
Can success be proven by a SELECT?Read-only scopeNeeds a write path
Must state survive a crash or a second run?Needs a write pathRead-only scope
Must the task retry safely after timeout?Needs idempotent writeRead-only scope
Must someone reconstruct who did what later?Needs a durable recordRead-only scope
Is the output consumed by a human or another agent as data?Read-only scopeNeeds a write path
Does the task need DDL (migrations, indexes, new tables)?Propose-then-apply, DDL never by agentRead-only scope

Notice that the table never asks whether the agent is "trusted." Trust is not a property you can measure per run, and agents drift: the same model that behaves well on Tuesday can be talked into something else by a malicious document it reads. OpenAI's safety guidance argues the reliable strategy is to constrain the impact of manipulation rather than to detect every attempt (Penligent's summary of OpenAI agent safety guidance). A read-only role is that constraint expressed as a grant.

The grant itself is small. In PostgreSQL, CONNECT and USAGE are separate from table privileges, so a read-only role typically needs three grants: connect to the database, usage on the schema, and select on the tables or views, plus default privileges so tables created later are covered (Stack Overflow's read-only user recipe).

-- Run as the object owner, not as a superuser shared with the agent
CREATE ROLE agent_reader LOGIN PASSWORD :'pw';
GRANT CONNECT ON DATABASE appdb TO agent_reader;
GRANT USAGE ON SCHEMA public TO agent_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO agent_reader;

The ALTER DEFAULT PRIVILEGES line is the one people forget. AWS's guidance on PostgreSQL roles recommends exactly this shape: named roles per use case (readonly, readwrite), least privilege, and never letting the application use the master user (AWS Database Blog). If your migrations run as a different role than the one that issued the default-privilege statement, new tables will not be readable, and the agent will fail on a permission error that looks like a bug.

Task types that fit a SELECT-only user

Lookups and enrichment. "What tier is account A-1029 on, and have they opened a support ticket in the last 30 days?" The agent reads, joins, and returns an answer. Nothing needs to persist because the answer goes back into the conversation, and the transcript or the agent's log is the record, not a database row.

Reporting and anomaly detection. Daily revenue reconciliations, backlog aging, cohort counts, duplicate-record hunts. These are naturally read-only, and the output (a report, a chart, a list of exceptions) is a deliverable a human consumes. If the report must be stored, store it outside the operational database, in an object store or a reporting warehouse fed by a separate job.

Triage and classification. Route an inbound ticket to a queue, label an alert as noise or signal, score a lead, pick the right runbook. The decision is the output. The executing system (the ticket tracker, the alerting tool) applies it under its own rules and usually keeps its own audit trail. The agent never touches the operational tables.

Reconciliation. Compare two sources: the payment processor's settlement file against your ledger, the CRM against the billing system, the carrier manifest against the order table. The agent produces a list of mismatches with evidence. Fixing the mismatches is a second task with a different scope, and often a different owner.

Cache warmers and retrievers. Anything that builds a read model (an index, an embedding set, a materialized view) can be run by a builder job with a write grant to its own schema, and the query-facing agent connects to that read model with SELECT only. Keep the two roles distinct even when the same person owns both.

A worked example with numbers makes the boundary concrete. Suppose reconciliation runs nightly against 1.2 million orders and finds 340 mismatches, about 0.03 percent. A read-only agent flags all 340 in a report. A write-enabled agent that "corrects" them in place will touch 340 rows, produce 340 audit events you did not design, and give you no clean way to tell a good correction from a hallucinated one. The read-only version gives a human or a separate apply service the list and the evidence, and the correction becomes a reviewed operation with an idempotency key and a log line.

The signals that read-only will not work

Three requirements break pure read-only scope, and they are the ones that matter.

The task must persist state across runs. Agents are stateless between invocations unless something remembers. If a job polls an inbox every ten minutes and must know which messages it already handled, that memory has to live somewhere durable. The good news is that the memory does not need to live in your operational database: a sandbox with a persistent volume synced to object storage can carry agent state between executions, with read-only or read-write access assigned per user or group (Windmill's AI sandbox documentation). Keep the agent's scratch memory in its own volume, and keep the operational database read-only.

The task must retry safely after a crash. This is where naive write-enabled agents go wrong: the tool call times out but succeeds server-side, the agent retries, and you get two invoices (Duckweave on tool calling and retries). Idempotency is the fix, and idempotency is a server-side property: the API must accept a key and return the original outcome when the same key arrives again (WSO2 on idempotent tool design). A read-only role sidesteps the problem entirely because a repeated SELECT is harmless.

The task must leave an audit trail. If an auditor later asks who changed a record and why, the change must have been made by something that records it. PostgreSQL's pgAudit extension exists to capture exactly that level of detail, including the user who made each change, and it is designed for regulatory audit needs (AWS documentation for pgAudit). A SELECT leaves no such trail, which is fine when the deliverable is a report and not fine when the deliverable is a change in the system of record.

There is a fourth signal worth naming even though it is not strictly a write: schema changes. Migrations should never be run by the same identity that reasons about the task. Database branching gives each task its own copy-on-write fork of schema and data, so a botched ALTER TABLE affects only the branch and the parent is structurally unchanged (PingCAP on TINE and database branching). Specstory's walkthrough shows the operational discipline: create the branch when the work starts, point the task's connection string at it, delete it when the pull request closes (Specstory on database branching). That is a write path, but it is a write path to a disposable copy, which is a different risk class from a write to production.

Propose-then-apply when a write is unavoidable

Once a task genuinely needs to change state, do not hand the agent an UPDATE grant. Split the task in two.

The propose step runs under a role with SELECT plus INSERT on exactly one table: an intent table. The agent reads whatever it needs, decides what should change, and writes a row describing the intended change: the target row(s), the before-value, the after-value, the reason, a correlation id and a hash of the evidence it used. This role can insert into one table and read the rest. It cannot update, delete, or touch anything in the operational schema.

The apply step is a separate service with the actual write grants. It validates each intent against current data (does the target row still have the before-value?), enforces business rules, and executes the change inside a transaction. It records the outcome, accepts an idempotency key so a retry does not double-apply, and writes an audit row. If validation fails, the intent is marked rejected with a reason and nothing changes.

The shape of the intent table matters more than the schema details:

CREATE TABLE agent_intent (
  id            bigserial PRIMARY KEY,
  correlation_id text NOT NULL,
  task          text NOT NULL,
  target_table  text NOT NULL,
  target_key    text NOT NULL,
  before_value  jsonb,
  after_value   jsonb,
  reason        text NOT NULL,
  evidence_hash text NOT NULL,
  status        text NOT NULL DEFAULT 'proposed',
  created_at    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (correlation_id, target_table, target_key)
);

That UNIQUE constraint is doing real work. It makes the propose step idempotent at the database level: a retried proposal with the same correlation id and target becomes a no-op instead of a duplicate. The apply service then only has to be careful about its own execution, and that is a much smaller problem than making a language model careful.

This pattern also gives you the audit trail for free. Every change is preceded by a proposed row that names the agent task, the evidence hash and the reason, and every change is followed by a status transition. When an auditor asks why the shipping address for order 4417 changed, the answer is a row, not a reconstruction from logs.

A practical note on transaction semantics: if you ever prototype the apply step inside a test harness, remember that test-managed transactions and preemptive timeouts do not mix well, because work performed on a different thread escapes the transaction and commits even though the harness rolls back (Spring Framework transaction management guide). The same class of bug applies to any async apply service: if the write happens outside the transaction you think wraps it, your rollback is a lie.

Guardrails around the database that are not the database

A SELECT-only grant is the strongest single control, but it is one layer. Agents run with more access than the grant suggests, because the process also has environment variables, credentials, network reach and a filesystem. Sandboxing is what bounds all of that at once: the filesystem boundary limits the agent to its mounted working tree, and process isolation limits what it can reach outside it (Penligent on coding agent sandboxes). A proper sandbox is a bundle of boundaries, not just a separate container, and hardware-level isolation (microVMs) is a different risk class from software isolation (Solo.io's agent sandbox guide).

For database access specifically, the checks that pay for themselves are:

  • The agent's connection string points at a role with SELECT, or SELECT plus INSERT on the intent table. Nothing else.
  • The role is not a member of any role with write grants, and default privileges do not accidentally grant more.
  • The database user the agent uses is distinct per environment, so a staging credential cannot reach production.
  • The apply service's credentials live in a different secret store and are never injected into the agent's process.

If you run agents that generate or test code against a database, database branching is the complementary control: branch per task, point the task at the branch, and drop the branch when the task ends (cole-medin knowledge base on database branching). The agent can then run migrations and reset fixtures freely, because the blast radius stops at its own copy. That is read-write scope against a disposable target, which is safe in a way that read-write scope against production never is.

Readers building agent platforms with model routing across text, image and embedding endpoints often find that the database scope decision is the easy half; the harder half is running the same agent task against several model providers without rewriting the tool layer. That is the case Quanta Inference is built for: one OpenAI-compatible API key across model types, priced in US dollars with prepaid billing (https://quantaintelligence.ai/inference). For teams that want an expert to own the propose-then-apply build rather than assemble it, Quanta Experts hands back finished deliverables (https://quantaintelligence.ai/agents).

A checklist you can apply to any task

Before you grant the agent anything, answer these in writing:

  1. Write the success sentence. Can it be verified with a SELECT? If yes, stop at read-only.
  2. Does the task need to remember anything between runs? If yes, put that memory in a sandbox volume, not the operational database.
  3. Can the task be retried without side effects? If not, you need an idempotency key at the tool boundary.
  4. Does anyone need to reconstruct the change later? If yes, you need an intent row or an audit extension, not just a log line.
  5. Does the task touch schema? If yes, run it against a database branch and never against production.
  6. Is the agent's credential separate from every human and service credential? If not, fix that before anything else.

The decision rule survives contact with real tasks because it maps to something a reviewer can check. Read-only scope is not a limitation you tolerate; it is the default you earn the right to leave. Every task that fits it gets a cheaper review, a smaller blast radius and a shorter incident report.

FAQ

FAQ

Can an agent that only reads still cause damage?

Yes, through volume and through exfiltration. A read-only role can still run expensive queries that saturate the database, and it can still read data it should not see. Restrict search_path, give the role access only to the schemas and columns it needs, set statement timeouts, and combine the grant with a sandbox that limits network egress, since filesystem and network boundaries are what stop data leaving the environment (Solo.io, Penligent).

Does a read-only user need INSERT to do propose-then-apply?

Yes, on exactly one table: the intent table. Grant SELECT on the operational schema and INSERT on the intent table, and nothing else. The apply service, which holds the real write grants, validates and executes the intent, and its credentials never reach the agent's process. That split keeps a compromised agent limited to writing proposals that a validator will reject.

How do I stop a retried tool call from writing twice?

Put an idempotency key at the API boundary and store it server-side with the outcome, so a repeat of the same key returns the original result instead of creating a second record (Duckweave, WSO2). In the propose step, a UNIQUE constraint on (correlation_id, target_table, target_key) achieves the same thing at the database level.

Should the agent run migrations against a shared development database?

No. Use a branch per task so a failed ALTER TABLE leaves the parent unchanged, and delete the branch when the task ends (PingCAP, Specstory). A shared development database turns one agent's reset script into everyone else's outage.

Where does the audit trail come from if the agent never writes?

From the systems that do write. The agent's proposals land in the intent table, the apply service records the outcome, and the database can log changes with an extension like pgAudit (AWS). If the task's only output is a report, the report artifact and the agent's run log are the record, and no database write is needed.

Sources

Sources

  1. Sandboxes for Coding Agents - Penligent
  2. How do you create a read-only user in PostgreSQL?
  3. Managing PostgreSQL users and roles | AWS Database Blog
  4. AI sandboxes & volumes: isolated environments for coding agents
  5. Tool Calling Has a Tax You're Not Seeing | by Duckweave
  6. Idempotent Tool Design: Building Agent Tools Safe to Retry
  7. Using pgAudit to log database activity - AWS Documentation
  8. Database Branching for AI Agents: TINE Solves Schema ...
  9. What is database branching? | AI coding agents
  10. Transaction Management :: Spring Framework
  11. What Is an Agent Sandbox? A Guide to Isolated Execution for AI Agents
  12. cole-medin-knowledge-base/concepts/database-branching ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles