Corteksa

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)

  1. Graph confirm gateagent.graph.ts confirm node. Before running a batch that contains a confirmable write, the graph pauses at an interrupt, surfacing the pending writes to the user (each as a backend-derived, ready-to-render WriteIntent: label + 3-value action). 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.

  2. Policy-guard backstopguarded-tool-executor.ts. Underneath the graph, a write that needs confirmation executes only when its callId is in approvedWriteCallIds (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 tierConfirmed?
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

  1. Classify every write tool in tool-policy.registry.ts (WRITE, or DANGER for destructive). Unclassified ⇒ denied.
  2. Build the run's ToolRunContext with writeGate: '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).
  3. Route tool calls through GuardedToolExecutor (never call a tool directly), and wire the graph's confirmGate for a 'per-write' agent so the pause is surfaced to the transport.
  4. On resume, thread the user's approvedCallIds into approvedWriteCallIds — 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.

On this page