AI v2 — Write Confirmation Law
AI v2 — Write Confirmation Law
The law: every AI v2 chat agent that can create, update, or delete a workspace's data or schema must have the user confirm before it writes. No agent writes an un-approved create/update/delete. This is a platform invariant, enforced fail-closed — not a per-agent option a new agent can forget.
This doc is the law. If code and this doc disagree, fix whichever is wrong — but the invariant above never bends.
1. Why (the failure this prevents)
An LLM agent decides its own tool calls. Without a hard gate, a mis-prompt, a prompt
injection in a record's text, or a routing bug could make the model issue a delete_record
or update_field that executes silently. The confirmation gate makes that structurally
impossible: a write is proposed to the user and runs only after they approve that exact
call.
2. The two layers (they share one predicate, so they can't drift)
-
Graph confirm gate —
agent.graph.tsconfirmnode. Before running a batch that contains a confirmable write, the graph pauses at aninterrupt, surfacing the pending writes to the user (each as a backend-derived, ready-to-renderWriteIntent:label+ 3-valueaction). On approve it runs them; on decline it closes the calls and the model acknowledges. An over-cap destructive batch (rejectBatch, e.g. "no mass delete") is refused before the user is even asked. -
Policy-guard backstop —
guarded-tool-executor.ts. Underneath the graph, a write that needs confirmation executes only when itscallIdis inapprovedWriteCallIds(threaded in on the resume after the user approves). Fail-closed: even a routing/graph bug cannot write on a plain model-issued call.
Both call the one predicate tierNeedsConfirmation(tier, writeGate),
so "does this need confirmation?" can never mean two different things:
| Risk tier | Confirmed? |
|---|---|
DANGER (delete) | always — a hard floor, independent of the gate |
WRITE (create/update) | when writeGate === 'per-write' |
SAFE (read) | never |
Tiers live in the exhaustive tool-policy.registry.ts — a
tool with no entry is DENIED, so a new tool cannot ship an unclassified write path.
3. The writeGate contract (how the law is made un-forgettable)
writeGate is a required field on ToolRunContext
(WriteGate). Because it is required, an agent cannot compile
without consciously choosing one — there is no default to forget:
'per-write'— pause and ask the user to approve each write batch. The default every write agent uses. (The units agent.)'plan-reviewed'— the writes were already approved up front as one reviewed plan, so per-write confirmation is redundant. The only sanctioned alternative, allowed for the workspace builder (the user approves the whole plan before any build runs). DANGER still always confirms, even here.
A guardrail test fails CI if 'plan-reviewed'
appears anywhere outside a workspace-builder file — so the opt-out cannot spread by copy-paste.
4. Checklist — adding a new write-capable agent
- Classify every write tool in
tool-policy.registry.ts(WRITE, orDANGERfor destructive). Unclassified ⇒ denied. - Build the run's
ToolRunContextwithwriteGate: 'per-write'. Use'plan-reviewed'only if the agent gets an up-front, whole-batch human approval (and you accept the guardrail exception — builder only). - Route tool calls through
GuardedToolExecutor(never call a tool directly), and wire the graph'sconfirmGatefor a'per-write'agent so the pause is surfaced to the transport. - On resume, thread the user's
approvedCallIdsintoapprovedWriteCallIds— nothing else lets a gated write run.
5. Exactly-once on resume (the double-confirm guard)
Approving a gate must run its writes exactly once, even if the confirm is delivered
twice — two open tabs, a socket reconnect re-emitting assistant:confirm, a cross-socket
double-click. Without a guard both deliveries read the checkpoint interrupt as still-pending
(the LangGraph Postgres checkpointer is last-writer-wins with no compare-and-swap) and
both run the batch: duplicate records, or a workspace published to the shared catalog twice.
The approvedCallIds backstop (§2.2) authorizes a call but does not consume it, and the
tool idempotency store keys on a per-run id, so it never matches across two separate resumes.
The front door serializes the resume per conversation with
ResumeConfirmationLock — a token-based Redis
lock (RedisService.acquireLockWithToken), scoped tenantDatabase:workspaceId:conversationSlug
because RLS does not cover Redis. AiGraphService.resumeConfirmation / resumeConfirmationRun
probe the pending gate first (outside the lock), then acquire it around the
resumeStream / resume call: the winner runs the batch, the loser stands down with a benign
"already being processed" turn and never re-executes. The probe is left outside the lock so a
same-socket confirm that supersedes an aborted one still re-resumes cleanly — the lock only
stops a genuine concurrent race. This mirrors BuilderBuildLock,
which guards the narrower deterministic build nested inside a plan resume.
Client consequence: confirm/reject is safe to retry on reconnect — a re-sent confirmation cannot double-write. A client outbox that replays the last confirm after the socket recovers is therefore correct by construction.
6. See also
- AI v2 — Integration Guide §6 — the client-side confirmation flow.
- The Units Agent doc — the reference
'per-write'implementation. - The Workspace Builder doc — the reference
'plan-reviewed'implementation.