--- title: "ADR 0016 — An assistant writes directly by default, and one act undoes a whole session" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:40:19.103Z" --- # ADR 0016 — An assistant writes directly by default, and one act undoes a whole session **Status** Accepted, default reversed by [ADR 0017](./0017-conflict-only-review.md); control 3 (the retrieval quarantine) amended by [ADR 0018](./0018-verification-is-an-org-choice.md), which makes it an org setting; the default amended again by [ADR 0022](./0022-agent-writes.md), where an org's "Agent writes" setting starts off; control 3 amended by [ADR 0027](./0027-verification-is-tracking.md), which removes the retrieval quarantine in every org · **Date** 2026-08-27 · **Supersedes** nothing · **Amends** ADR 0009 §4's default, `docs/TAXONOMY.md` §7 repair 1 · **Relates to** ADR 0009 (the permission ladder), ADR 0010 (add the thing that shows a control stopped working), `docs/TAXONOMY.md` (the two-grid model), `design-system/specs/write-policy.md` (the design), `docs/SECURITY.md` (known weakness 2, and the one that replaces it), PRD §3.4, PRD_PART2 F3 ## Context `packages/core/src/permissions/clamp.ts` opens with the sentence the product has kept since it was written: > No text a machine produced becomes the live version of a note above `open` without a named person > accepting it. The review queue is that person. Every assistant write on an instance under the default `SIGNPOST_WRITE_POLICY: "review"` became a `proposals` row, and somebody accepted it before it was true of the knowledge base. It is the one control standing between a pasted credential and a company's memory. Two things were measured against it. **The queue makes a workspace an assistant can fill but never read back.** A queued note is invisible to `browse`, to `read` and to `recall`. So an agent that writes 38 notes and then checks its work is told, truthfully and uselessly, that its work is not there. The comicadda audit log records the exact sequence: 38 writes in eleven minutes from one session, then a check, then the agent stopped. It did not retry and it did not report a problem, because from where it stood nothing had gone wrong — the knowledge base was simply empty. **A queue of 38 unreadable items is not a control.** The review screen led each card with a flattened dump of the file: 396 characters of YAML, list markers and table cells joined with spaces, which was also the card's accessible name. A reviewer facing 38 of those approves by pattern. A control that is exercised without being read is a wall people click through, and counting it as a control is how a system gets credit for safety it does not have. ## Decision **An assistant's write applies at once. The review queue becomes an opt-in and a backstop.** The capability ladder in `packages/core/src/permissions/capability.ts` — `none < read < suggest < write`, with `never` below `none` on the assistant column — was fully built, fully tested and had no storage. This wave stores it instead of deriving it. `suggest` already means "queue it", so no parallel concept is invented and there is one word for the queue at every level. An assistant's effective capability on one note is: ``` effective = min( instance ceiling, SIGNPOST_WRITE_POLICY, restated as a capability node, org > project > space > note, resolved outward link, session_tokens.scope, widened to read | suggest | write person the people column of the person the link acts for ) ``` composed in exactly one function, `effectiveAgentCapability`, whose body is two existing calls so that the `never` floor stays in one place. A source scan (`apps/worker/test/invariants/clamp-guards.test.ts`) refuses a second composition anywhere. Storage, per level: `org_routing.capability_assistants` — never better-auth's `organization` table — `project.capability_assistants`, `{folder}/_folder.md` frontmatter, note frontmatter with `notes.capability_assistants` as the resolved cache, and `session_tokens.scope`. **`project.capability_assistants` is organization-wide in practice, and that is not yet decided.** `ctx.projectId` is the organization id (`projectIdentityFor`), `notes.project_id` is the same id, and `POST /api/orgs/{id}/projects` can create a second `project` row that shares that one corpus — so the column cannot be resolved per project by construction. `loadStoredCapabilityChain` takes the narrowest value across the organization's rows, which never widens; whether the context should carry a real project id or the column should say "organization" is in `docs/DECISIONS-PENDING.md`. `capability_people` is stored at two levels and read by nothing, which is the same shape. **The link is the load-bearing half**, and the argument is the threat rather than the tree. The threat is a leaked credential, not a mislabelled space. A space cannot be revoked and cannot expire; a link can, in one request, with no cache in front of it. A person already mints it, names it and chooses its lifetime. So the control lives on the object a control can act on. ### The two defaults are different numbers, deliberately - **The chosen default is `write`**, and it is a stored row the onboarding wizard writes through `PATCH /api/org/{orgId}/defaults`. A person who accepts the wizard's pre-selection gets direct write *because a row was written*, and `org_defaults_changed` at `warn` records who chose it. - **The unset floor stays `suggest`.** `DEFAULT_ASSISTANT_CAPABILITY` does not move. It is reached whenever nothing on the path authored anything — every self-hosted instance, and every test fixture — so a deployment that takes this build and stores nothing keeps queueing exactly as it did before. **A value nobody chose is never the permissive one.** The same rule gives `Actor.scope` a floor of `suggest` when a door has not been taught to pass its credential through: an un-migrated door is safe rather than broken. ### The migration widens nothing Migration `0011` backfills every existing organization to `capability_assistants = 'suggest'`, which is today's behaviour exactly, and narrows every live `session_tokens.scope = 'write'` row to `'suggest'`, which is also today's behaviour, because a `write` link under the `review` policy already queued. **No existing tenant is flipped by the deploy.** The flip becomes choosable and becomes the pre-selection for new organizations; it is not applied to anybody. Say that in the release note. "Direct write is now the default" read without this paragraph is a claim that existing customers' permissions changed under them. ## What replaces the person, and what each one is worth The person is gone from the default path. Five controls carry the containment instead. None of them is new invention; four already shipped and one was already written down in the audit log and never read. 1. **Undo by session.** `GET`/`POST /api/sessions/{id}/undo`. Every commit already writes an audit row carrying `contentSha`, `previousSha` and the session id, inside the same D1 transaction as the note it records, and `appendAudit` has exactly two callers, both inside `CommitService`. The journal was complete by construction and **nothing read it**. This wave makes `session_id` a real indexed column, adds `on_behalf_of`, and gives it a door. **The journal was not complete for `verify`, and that was measured on 2026-08-28.** `commitVerification` takes an `AuditInput` rather than an `Actor` — a verification changes no byte, so it is not a `Change` — so nothing downstream could fill `session_id` in, and neither agent door passed it. Every assistant verification sat outside every sitting and outside undo: the one verb that changes what `recall` returns without changing a byte. Both doors now commit through `TrustService.transitionClaims`, which writes the sitting, the accountable person, the resolved address and the `from` census — so an undo puts a claim back to the state a person had set rather than to the floor. **Two things it still cannot reverse.** An accepted proposal's `note_created` row carries the **reviewer's** web session, because the accept path re-commits under the reviewer's principal, so the assistant's sitting has no row for it and undo of that link answers `{"steps":[],"undone":0}`. That is the setting a customer chooses when they want *more* safety, and it is `docs/DECISIONS-PENDING.md`'s to settle. And an undo run by somebody who can read only part of a sitting reverses only that part: the row now records `covered`, so a reader who can see more rows than the undo covered does not read the sitting as handled. 2. **The session write budget.** 25 applied direct writes per sitting, then every further write queues. It **degrades rather than refuses**: a refusal at write 26 loses the agent's work and teaches it the tool is broken; a queue loses nothing. A batch too large for a person to review is exactly the batch that should be reviewed. **A "sitting" is one credential, for the life of that credential.** Both agent doors fill `Actor.sessionId` from the link they authenticated, and `appliedWriteCount` never decrements, so the budget is 25 applied writes per link for ever and not 25 per run: the twenty-fifth applied write is the last one that link ever applies directly. Undo by session has the same scope — it reverses everything that link ever wrote, inside the window given. Keying either on a narrower unit is a change to `Actor.sessionId`, and until it is made the product says which one it means, on the sessions screen and in the API reference. **The number cannot be changed through any interface.** `session_write_budget` is read by `budgetedDecision` and written by nothing: `setOrgCapabilityDefaults` patches the two capability columns only, so an operator can reach it with `wrangler d1 execute` and no other way. 3. **The retrieval quarantine.** An assistant's write lands `unverified`, and `recall` does not return an unverified agent claim. A direct write is readable, browsable and editable, and does not yet speak. Nothing in this wave promotes a direct write to `verified`, and an assistant on a bearer credential can no longer promote one either: `refuseAssistantStateChange` refuses `verified` at every capability, refuses an assistant restating a person's claim, and refuses one reopening another principal's ruling. What is left is an assistant moving **its own** unverified draft between `stale`, `superseded` and `unverified`, which moves nothing a person relies on. **Amended 2026-09-17: this control no longer exists.** [ADR 0027](./0027-verification-is-tracking.md) removes the retrieval quarantine in both modes. `recall`, Quick Connect and Ask return an assistant's direct write at once in every org. With verification on, the write ranks below verified text and the app marks it Unverified. The control text above, and the amendments below that describe the quarantine, are historical. ADR 0027 lists the controls that now contain a direct write: the agent writes setting, the connection mode, the session write budget, history and undo, the marks, and Review for a change to an existing doc. **Amended 2026-09-16 (historical since ADR 0027): this control applies only to an org that uses verification.** `docs/ADR/0018-verification-is-an-org-choice.md` makes verification a setting per org, and a new org starts with it off. In such an org, `recall`, Quick Connect and Ask return an assistant's direct write at once, and the other controls in this list carry the containment alone: the ladder, the link's scope, the session write budget, Review for a change to an existing doc (ADR 0017), undo by session and revocation. An org with verification on keeps this control exactly as written above. A self-hosted deployment has no org setting and reads `on`. **Amended 2026-09-03: that last sentence was true when it was written and the acceptance rung made it false in the same wave.** `packages/core/src/lifecycle/acceptance.ts` added a third rung between `unverified` and `verified`: a claim a person accepted in the review queue but did not read line by line. An accepted claim stays `unverified` on purpose — the person let the words in, they did not attest to the line — and **`recall` returns it**. So "its own unverified draft" stopped meaning "a claim nobody relies on", and the three rules above kept protecting exactly the claims that no longer needed protecting. A field evaluation measured both halves of that on a running instance, on a write link the owner had minted herself. `POST /s/{tok}/verify {"state":"superseded"}` lowered five claims she had accepted, the note left every answer, and nothing refused the call. `POST /s/{tok}/edit {"op":"retract"}` deleted a note she had read and accepted four minutes earlier. Two guards closed those two verbs, and neither was a new policy: - `refuseAssistantStateChange` takes a **fourth** rule. An assistant does not undo a person's acceptance. It calls the one implementation of the rung rather than reading `accepted_by` and `accepted_at` itself (rule 5). - `planRetraction` takes a **third** rule. A note carrying an accepted claim is not this sitting's to unmake, whichever sitting wrote it. Same predicate, same reason. **Amended again 2026-09-03: two guards were two verbs, and the same evaluator then measured four more ways to the same five claims.** This paragraph said two paths remained open. Four did. Every one of them applied, twice, on the write link the owner had minted herself: | Call | What it did | | --- | --- | | `remember(supersedes: [x])` | all five claims `superseded`; the link's own label written into `verified_by`, over the reviewer's acceptance; the note left `recall`; the replacing note stayed quarantined | | `edit op=delete_note` | `{"outcome":"deleted"}`, then `read` answers "No live note exists" | | `edit op=replace_block` | the accepted paragraph replaced, the other four acceptances kept | | `edit op=set_property status` | `status: superseded` on a note a person accepted | A document that under-counts its own hole is worse than one that says nothing, and this one under-counted by two. The count was wrong for a structural reason rather than an oversight: each round fixed the verb the round before had measured, and a fifth named check beside four others would have been the same mistake a fourth time. **So the rule is no longer stated per verb.** `refuseAcceptedClaimChange` in `packages/core/src/lifecycle/acceptance.ts` is one rule over the four ways a change can **reach** a claim, and `CommitService` asks it wherever the write path computes what a change reaches: | Reach | What it means | Where the write path knows it | | --- | --- | --- | | `rewritten` | the accepted words change | `planNoteWrite`, from `updatedBlockIds` | | `removed` | the block, or the note holding it, goes | `planNoteWrite` from `removedBlockIds`, and `commitDeletion` over the whole note | | `relabelled` | the note's lifecycle status moves under the claim | `planNoteWrite`, comparing `notes.status` before and after | | `retrusted` | the claim's own verification state moves | `refuseAssistantStateChange` for `verify`, and `planSupersessions` for the marks a declared replacement writes | `planNoteWrite` is one bound for `create`, `update`, `splice` and `move`, because every content write goes through it. The MCP verbs map onto four `Change` ops and no others (`mcp/operations.ts`), and `test/services/accepted-claims.test.ts` enumerates `changeSchema` so a fifth op turns the suite red until somebody has shown it is covered. **An assistant may not edit an accepted block at all, and that is the argued half.** The other answer was to allow the edit and drop the acceptance with the words. The acceptance cannot survive the edit either way — `planNoteWrite` already deletes the provenance row of any block whose text changed and writes a fresh `unverified` one, the same rule that stops an edit inheriting a person's `verified` mark — so the note's count was never lying. "4 of 5 accepted" was arithmetic on the truth. What was wrong is that an assistant could delete one person's ruling, silently, and leave the note looking reviewed because the other four rulings were real. A person may still make that edit; they are the control the refusal stands in for, and their edit drops the acceptance exactly as before. Adding beside an accepted claim is untouched: an `append` reaches nothing, the accepted words stay byte-identical, and the count reads "5 of 6". **`planSupersessions` needs its own call, and a green suite nearly hid that.** A verification on 2026-09-03 deleted that one call site and the whole runtime file stayed green: the scan went red and nothing else did. The reason is `#107`'s shape. `planSupersessions` rewrites the target's header, which moves `notes.status`, so `planNoteWrite` refuses the same change on the `relabelled` reach and the supersession bound is never what answers. A target replaced **once before** already reads `status: superseded`, so the second replacement moves no status, and the only block the rewrite touches is the frontmatter, which carries no claim. `relabelled` is then empty and `rewritten` is filtered out. `test/services/accepted-claims.test.ts` now seeds that case, and it is the one test that goes red when the call site goes. **A `move` is not a reach, and that is a decision rather than an omission.** A move changes the address and no block, so the accepted words are byte-identical, the ruling stands, the status does not move and the claim keeps answering. The visibility ladder already stops the case that would matter: `sealed` and `private` are `never` for an assistant, so a move cannot carry an accepted note somewhere its readers cannot follow. **The review queue was the fourth round, and this document asserted the bound it did not have.** The paragraph above says `planNoteWrite` is one bound for every content write "because every content write goes through it". That is true of `commit` and false of the queue. A change the clamp queues never reaches `planNoteWrite`, `commitDeletion` or `planSupersessions` at all: it becomes a `proposals` row carrying the whole `Change`, and `POST /api/proposals/{id}/accept` re-commits it under the reviewer's own principal, so the rule correctly answers *a person is writing this* and returns. Measured on this build against `SIGNPOST_WRITE_POLICY: "review"` — which is this ADR's own opt-out and the shipped default — a splice over an accepted paragraph, a delete of an accepted note and a `status: superseded` on one all answered `{"status":"queued","proposalId":"prop_…"}`, and the card a reviewer sees says only "This instance queues agent writes for review". Nothing on it names the five acceptances an accept would undo. Three things make that reachable rather than theoretical, and all three are in this ADR: `review` is the default this document keeps for tenants that do not opt in, an `escorted` note queues at every setting (control 3's own ceiling), and the session write budget in control 3 queues **everything** after the twenty-fifth write — which is what the field evaluation's tenant did, 25 applied and 9 queued. So the bound held for the first 25 calls of a sitting and stopped holding for the rest. `writeProposalRow` is the one writer of a `proposals` row, so it is the one place to ask, and it asks through `assertQueuedChangeSparesAcceptedClaims`. It computes the reach the way the write path does — the same `blockDiff`, the same `reachedByNoteWrite`, the same `refuseAcceptedClaimChange` — rather than refusing every change to a note that holds an accepted claim. An `append` has to stay free on the queue exactly as it is on the direct path: a queue stricter than `commit` is a smaller failure than a queue looser than it, and it is still the two paths disagreeing, which is the defect rather than the fix. The sentence is the one `assertQueuedChangeHonest` already made twice for two other rules: **a change that may not be committed may not be queued for a person to commit either.** `apps/worker/test/invariants/one-acceptance-rule.test.ts` is the scan. It refuses a bound that copies the pair instead of asking for it, and it refuses any of the four losing its call. The behaviour is asserted through every door an assistant reaches — `/mcp`, `POST /s/{tok}/verify` and `POST /s/{tok}/edit` — and every call is made twice. **One path is still open and it is deliberate.** The rule fires on the two doors a bearer credential reaches, `/mcp` and Quick Connect. It does not fire on WebMCP or `/api/assistant/write`, which write as `agent` and are cookie-authenticated: a named person is in the request, and that person is the control every one of these refusals stands in for. Clamping them would refuse an edit a person has just asked their own assistant to make. `docs/SECURITY.md` weakness 2 states it as a residue rather than a bound. 4. **Revocation.** Immediate, no cache, bounded in the meantime by 60 requests a minute and 1,000 an hour per link. This is why the capability rides on the link. 5. **Provenance and the audit log.** Per block, permanent, and now queryable by sitting with the accountable person on every row. Three things had to be fixed before that sentence was true, and each was a record that could not be read. The redactor ate a note's own address out of `target` whenever the address contained a credential-shaped run — `ops/s/deploy-runbook` was stored as `ops/s/[redacted]` — so the row named an act without naming what it acted on; `redactTarget` now keeps an address that round-trips through the ref grammar, and `integrity.unaddressable` counts the rows already damaged and is on the wire. A block-level target carries a `#blk_…` fragment, which matched no note, so a `private` note's address reached any member on the instance default: the fragment is stripped before the match now, in SQL and in TypeScript. And a queued write named its sitting in `proposals` and not in `audit_log`, so the journal could not group it with the writes beside it. ### The hole in control 3, closed in the same change The MCP `verify` tool released the quarantine. `writesAllowed` refused only a `locked` instance and `commitVerification` ran no clamp at all, so an assistant already promoted its own claims from weight 0.85 to 1.0 with no person anywhere. `docs/SECURITY.md` named it as known weakness 2. Inverting the write default makes that hole load-bearing: a direct write plus a self-verify is the whole of the containment gone in two calls. So `commitVerification` now takes the same capability decision every other write takes, at the one place both doors pass through. ### The record that an address chose a permission A note's address chooses its space, and a space carries a policy. So **an assistant already assigns a permission every time it invents a top-level space**, no permission field changes, and no guard fires. `CommitService` now writes a `space_created` audit row and a `warn` line naming the space and its resolved capabilities. This is ADR 0010 applied: the thing that would show this control had stopped working is a space nobody knows was created. ## The alternative that was rejected **Keep the queue as the default and make it bulk-reviewable.** Group the cards by session, give each group one accept and one reject, fix the card head so a reviewer can read what they are approving, and leave the human gate in place. It was rejected for two reasons, and the first is the decisive one. **It does not fix what actually broke.** The comicadda agent did not stall because review was slow. It stalled because it could not read its own work back — a queued note is invisible to every read surface — so it concluded it had failed. A faster queue is still a queue, and every note in it is still invisible to the agent that wrote it. The workspace an assistant can fill but never read back stays exactly as broken. **A bulk accept is the queue admitting it is not being read.** A control whose fix is a button that approves 38 things at once has stopped being review and become latency. Better to say so, remove it from the default path, and spend the effort on controls that work after the fact — which is what undo, the budget, the quarantine and revocation are — than to keep a ceremony whose safety nobody can demonstrate. The bulk accept is still built, and it is deliberately restricted: creates only, no removals, no address collision. So it can never delete and never overwrite. ## Consequences **What is lost, plainly.** `clamp.ts`'s opening sentence is false for an organization that chooses direct write. An assistant with a leaked write link writes live notes for as long as the link lives. That is a real loss and the product says so on the mint dialog, in the ADR, and in `SECURITY.md`. **What is bounded.** Deletion still needs an explicit tombstone (I2) and an error still never deletes. Every version of every note stays in `blobs/`. The visibility ladder still floors `sealed` and `private` at `never` and still sends an `escorted` note's change to the queue at any setting, so the inversion reaches `open` notes and nothing else. Every mutation still goes through `CommitService` (I1) and every read still runs under a `ReadScope` (I7). **So every mistake this wave makes possible is reversible, and undo by session is the thing that reverses it.** **The gate.** §5 of the design ships in the same change as §2. Direct write without undo by session is convenient; with it, defensible. A builder who lands one and defers the other has landed the trade without the compensation. ## Two departures from the design this was built from, recorded rather than smuggled 1. **The instance ceiling tightens; it does not widen.** `design-system/specs/write-policy.md` §2.1 states the model as `min(instance ceiling, node, link, person)` and then gives a body folding the instance value in with `widenedAssistantCapability`, which would make it an inherited root any node could widen past. Under that reading `SIGNPOST_WRITE_POLICY: "locked"` locks nothing, because the migration backfills every organization to `suggest` and an authored value would win. §2.2 calls the value "a ceiling an operator can lower in one redeploy during an incident" and §2.5 promises `"review"` gets the queue back for everything, now. Neither is true unless it tightens. A control an operator reaches for during an incident that silently does not control is worse than no control, so the headline formula wins and the one contradicting paragraph is dropped. 2. **A `verify` from an assistant below `write` is refused, not queued — and an assistant's `verified` is refused at every capability.** §2.8 asks for a queued proposal. A `proposals` row carries a whole `Change`, and a verification is structurally not one — no permalink, no Markdown, no content SHA — which is the argument `CommitService` already makes for why verification is a separate entry point. Inventing a proposal shape for it is a design decision rather than a wiring one, so the hole is closed the honest way and the queue is left for a wave that designs it. The refusal names the fix, and it loses no work: the note's bytes are already written and already readable, and only the quarantine stays shut. The second half went further than the design asked. §2.8 clamps a verification by capability; measured on 2026-08-27, an assistant at `write` — the value the wizard pre-selects — passed that clamp and lowered a person's own verified claims to `superseded`, which takes the note out of `recall`. So the capability decision is not the whole rule: an assistant never writes `verified`, never restates a person's claim, never reopens another principal's ruling, and — since 2026-09-03 — never undoes a person's acceptance **through the `verify` verb**. That is `VerificationState`'s own definition rather than a new policy — *a person said it is true* — and it is the sentence this ADR promised and did not keep. The four rules bound one verb, and one verb was never the unit: a declared replacement, `delete_note`, `replace_block` and `set_property` reach the same claim by four other paths. Control 3 above states the rule over what a change reaches instead, and names the one door it deliberately leaves alone. ## How a person gets the queue back One word, `suggest`, at five levels, and `min()` does the rest. | A person wants | They set | Where | | --- | --- | --- | | This one note reviewed | Assistants: Can suggest | The access panel, note scope | | This whole space reviewed | Assistants: Can suggest | The access panel, space scope — writes `capability_assistants: suggest` into `{folder}/_folder.md` | | This whole knowledge base reviewed | Assistants: Can suggest | Settings → Access, project scope | | Every knowledge base in the organization reviewed | Assistants start with: Can suggest | Settings → Access, organization defaults | | This one link reviewed, whatever else says | scope: suggest | The mint dialog, or re-mint | | Everything, now, with no D1 write | `SIGNPOST_WRITE_POLICY: "review"` | `wrangler.jsonc`, one redeploy | ## What would make me revisit this - **A session write budget that nothing ever hits, or that everything hits.** 25 is a judgement and not a measurement. The measure is: how many sittings reach it, and how many of the notes that queued were then rejected. **That measure is not measurable while a sitting is a link**, because a link that has ever gone over stays over and one row can span months. Fix the unit before reading the number. - **A tenant that turned direct write on and then off.** That is the strongest available evidence that the trade is wrong for somebody, and the reason should be recorded rather than guessed at. - **An undo that nobody presses after an incident.** If a person's actual recovery path turns out to be restoring notes one at a time from history, then undo by session is not the control this decision claims it is, and the claim should change rather than the wording.