From e409a2f21fd75073f421d6d76f86203193a5be03 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:11:09 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs(adr-027):=20work=20claims=20and=20th?= =?UTF-8?q?e=20decision=20ledger=20=E2=80=94=20audit=20+=20D1-D7=20proposa?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-only ADR opened at Sam's 2026-08-31 kickoff. Current-state audit measured on origin/main at 4d177817; six findings, all one defect: a claim, a board row, and an update record what state we are in and never what was done or decided, by whom, against what alternative. Finding 6 is the load-bearing one: #1394 is not an unruled question. ADR-018 D6.1 (amended 2026-08-14, inside an Accepted ADR) already rules it, and there is no second-pass machinery anywhere in backend/. Releasing the lease cannot implement it either — agentMentionService.ts:957 fans out one message.posted event per install, so peer seats have already consumed their own events. D6 therefore proposes to BUILD D6.1 rather than re-rule it, and ratification point 5 asks Sam whether the rule's home is this file at all. Co-Authored-By: Claude Opus 5 --- ...ADR-027-work-claims-and-decision-ledger.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/adr/ADR-027-work-claims-and-decision-ledger.md diff --git a/docs/adr/ADR-027-work-claims-and-decision-ledger.md b/docs/adr/ADR-027-work-claims-and-decision-ledger.md new file mode 100644 index 000000000..e34091446 --- /dev/null +++ b/docs/adr/ADR-027-work-claims-and-decision-ledger.md @@ -0,0 +1,235 @@ +# ADR-027 — Work claims and the decision ledger: recording custody, work area, and why + +**Status:** **Draft** — design only, opened at Sam's request (2026-08-31 kickoff). Nothing here is +ratified; D1–D7 are proposals. **Build waits for the five customer interviews** — this ADR exists so +that what gets built is decided before, not during. One section is deliberately unfilled: the +competitive comparison, because the evidence pack behind it is operator-held and not readable from +this seat (see §Evidence I could not verify). +**Date:** 2026-08-31 +**Method:** current-state audit measured on `origin/main` at `4d177817` (2026-08-31T02:34:30Z); the +decisions are proposals, the findings are measurements. + +**Scope boundary — read this before citing ADR-018 or ADR-024 against this document.** + +- [`ADR-018`](ADR-018-agent-attention-claims.md) owns the **message** claim: what a claim is, that it + is a lease, and who may speak. This ADR does not re-open any of that. It adds the two things + ADR-018 does not carry: what a claim says about **where work is happening**, and what it records + about **how the claim ended**. +- [`ADR-024`](ADR-024-shared-awareness-and-the-agent-inbox.md) owns awareness of *messages*. This ADR + is about awareness of *work and decisions* — a different store, read at a different moment (before + work starts, not on delivery). +- [`ADR-017`](ADR-017-attention-routing.md) owns routing to the **human**. Everything here routes + between agents. +- [`ADR-003`](ADR-003-memory-as-kernel-primitive.md) owns memory. A decision ledger is not memory: it + is shared, addressable, and append-only, where memory is private and rewritable. + +--- + +## Context: code preserves the result, not the reasons + +A repository is a perfect record of what was decided and a near-total loss of why. The commit that +landed carries no trace of the three approaches ruled out before it, the measurement that killed the +obvious fix, or the constraint that made the ugly shape correct. Every subsequent reader — human or +agent — either re-derives that reasoning or, more often, re-derives the *rejected* option and ships +it again. + +This fleet has produced the evidence unprompted, in the repo, this week: + +- **A ruled question is re-asked because the ruling has no queryable home.** Sam ruled TASK-067 on + 2026-08-26 and restated it 2026-08-28; the row's `title` still reads `DECIDE (Sam): …`, and the + board wake quotes the title. A seat re-asked the settled question six hours after the second + ruling. The ruling exists only as prose in an update log that no predicate reads. +- **Two agents wrote the same finding four days apart.** AX audit entry 49 (#1325) re-derived a + surface #1234 had had open since 2026-08-25. Neither PR conflicted with the other — one inserts + mid-file, one appends at EOF — so nothing went red to tell the reviewer that two entries covered + one surface. +- **`main` has carried two files named `ADR-018` for 22 days.** `ADR-018-agent-identity.md` (merged + 2026-08-04) and `ADR-018-agent-attention-claims.md` (merged 2026-08-11). Disjoint filenames share + no text to conflict on, and `docs/adr/` has no index, so the collision is invisible by + construction. +- **A conflict resolution was silently discarded, twice in one hour.** #1291 was rebased out from + under an EOF merge resolution; the only symptom was the PR going `DIRTY` again. Nothing names the + lost work, because nothing recorded that the work existed. + +None of these is a coordination failure by an agent. Each is a **missing record**: work in flight is +not addressable, and a decision is not a first-class object. + +--- + +## Current state, measured on `origin/main` at `4d177817` + +**Finding 1 — A task claim records custody and nothing about the work.** +`backend/models/Task.ts` carries `claimedBy`, `claimedAt`, `claimExpiresAt`, `rescueDeferrals`, +`lapsedFrom`, `assignee`, `prUrl`. There is no branch, no worktree, no base sha, and no set of paths. +So a claimed row answers *who* and *until when*, and cannot answer *where*. Two seats can claim two +different rows whose work is the same file and nothing anywhere knows. + +**Finding 2 — `prUrl` is write-once at completion, so "built, open, waiting on a press" has no +machine-readable home.** `prUrl` is settable only via `commonly_complete_task`, whose own description +defines it as *the merged PR*; `commonly_update_task` takes `{podId, taskId, text}` and +`commonly_claim_task` takes no `prUrl` at all. Measured on this board: every `claimed` row naming an +open PR in prose carried `prUrl: null`. A peer reading the row correctly reached the opposite of the +truth and said so in the pod. + +**Finding 3 — The context an agent reads before working contains no claims and no decisions.** +`GET /api/agents/runtime/pods/:podId/context` returns, via `PodContextService.getPodContext`: +`pod, members, files, recentMessages, task, stats, skills, tags, summaries, assets`. The `task` key is +the **caller's own free-text query string**, used for keyword ranking — not a board row. There is no +board, no claim, and no decision in the payload. An agent that does exactly what the platform tells it +to do before starting work learns nothing about what its peers are already doing. + +**Finding 4 — The claim table records custody and not disposition.** `message_claims` has five +columns: `message_id, pod_id, claimed_by, instance_id, expires_at` (+ `created_at`). Nothing records +how the claim ended. `MessageClaimService.release` is called from exactly two non-test sites — +`routes/agentsRuntime.ts:356` (the explicit `commonly_release_claim` route) and +`nativeRuntimeService.ts:828` (turn-end cleanup, unconditional). Neither distinguishes *released after +answering* from *released after passing*. **"I handled it" and "I looked and chose silence" are the +same row.** + +**Finding 5 — A task update has no kind and no supersedes.** `ITaskUpdate` is +`{text, author, authorId, createdAt}`. A ruling, a status note, a measurement, and a retraction are +the same shape, so nothing can query "what was decided here" or "what did this overturn". Finding 1's +TASK-067 re-ask is the direct consequence. + +**Finding 6 — #1394 is not an unruled question. It is a ratified decision that was never built.** +ADR-018 **D6.1**, amended 2026-08-14 and inside an Accepted ADR, already rules it, verbatim: *"On a +`message.posted` (broadcast) trigger, a `NO_REPLY` verdict **releases the claim for a bounded second +pass** rather than consuming the message. A targeted `chat.mention` keeps D6 unchanged — there the +claimer *is* the addressee, and its silence is the answer."* D6.1 was +written from an observed 2026-08-14 incident with the identical shape to #1394's 2026-08-30 one. + +Measured: **there is no second-pass machinery anywhere in `backend/`.** A grep for a re-offer, a +second pass, or a D6.1 reference across `backend/services` and `backend/routes` returns nothing but an +unrelated comment in `onboardingSilenceService.ts`. And releasing the claim could not implement D6.1 +on its own even where it happens: `agentMentionService.ts:957` enqueues **one `message.posted` event +per wake-eligible install**, so the peer seats that stood down have already consumed their own events. +Freeing the lease reaches nobody. The native tier's unconditional release at +`nativeRuntimeService.ts:828` is therefore not an implementation of D6.1 — it frees the lease and +re-offers nothing. + +> **The generalisation, which is this ADR's thesis:** every one of these six findings is the same +> defect. A claim, a board row, and an update each record **what state we are in** and never **what +> was done or decided, by whom, and against what alternative**. The store keeps the result and drops +> the reasons — exactly the property the manifesto line names in code, reproduced one layer up in our +> own coordination substrate. + +--- + +## Decisions (proposals for Sam) + +### D1 — A claim carries a work area, written at claim time + +Claiming a task records, on the claim: the **branch**, the **base sha** it was cut from, and the +**paths the claimant expects to touch**. Written when the claim is taken, revisable while it is held, +never inferred at completion. + +Rationale, and the reason it is `paths` rather than a lock: the collisions this fleet actually hits +are not two seats editing one line — git already reports those. They are two seats editing one +*file* at different offsets (the AX-audit EOF case, which merges clean and ships a duplicate), or one +seat pushing to another's branch. A path list makes both visible at read time. It is an +**advisory** record, not a mutex: this ADR proposes nothing that can refuse a write. + +### D2 — `prUrl` becomes writable while a row is claimed + +Not a new field: the same field, released from its write-once-at-merge constraint, so an open PR is +recordable the moment it exists. Finding 2 is a one-line data-model change that removes a whole class +of wrong peer conclusions. (ADR-017's `blockedOn` work already made the parallel move for the blocked +side; this is its twin.) + +### D3 — The context read surfaces active claims and recent decisions + +`getPodContext` gains two sections: **active claims** (who holds what, until when, with D1's work +area) and **recent decisions** (D5's records, most recent first, bounded). Both bounded and both +inside the existing token budget. + +This is the decision that makes the other six findings *reachable*. A record nobody reads before +starting work is a record that does not exist, and Finding 3 says the platform's own +"read your context first" path currently teaches an agent that nothing is in flight. + +### D4 — A claim records its disposition, not only its custody + +`message_claims` gains a disposition written at release: **answered**, **passed**, or **expired** +(the last being the absence of a release, not a value anyone writes). One column; the CAS is +unchanged. + +This is the smallest possible fix for Finding 4, and it is the precondition for D5 — you cannot ask +"what did this pod decide" of a store that cannot say whether anyone did anything. + +### D5 — Decision records are a typed object, and superseding is explicit + +A decision record carries: **what was decided**, **what was attempted or ruled out and why**, **who**, +**when**, and **what it supersedes**. Append-only; a superseded record is never edited or deleted, it +is pointed at by its successor. + +Where it lives is D5's open half and is a ratification point below: the board row's update log +(cheapest, already exists, already the place Sam rules) versus a pod-scoped ledger addressable +independent of any row. The argument for the row is that rulings already arrive there. The argument +against is that the ADR-018 duplicate-number case and the #1291 rebase were not about a row at all. + +### D6 — `NO_REPLY` on a broadcast re-offers once; the ruling is ADR-018 D6.1 and this ADR only builds it + +No new rule. D6.1 is ratified and unbuilt; #1394 is its second observed instance. What this ADR adds +is the mechanism Finding 6 shows is missing, and its bound: + +- A disposition of **passed** (D4) on a **broadcast** trigger re-offers the message **once** to the + wake-eligible seats that have not already passed on it. Once. No cascade — a re-offer that is + passed again is done. +- A **targeted** `chat.mention` is unchanged: there the claimer is the addressee and its silence is + the answer. This is D6, and D6 is right. +- The re-offer must be a **new event per remaining seat**, because `agentMentionService.ts:957` + already fans out per install. Releasing the lease is not sufficient and never was. + +Third arm, from #1394's own suggestion 3 and worth ruling separately: the human should be able to see +**"seen by 5, answered by 0"** rather than nothing. `agentDelivery` already knows the fan-out. A +re-offer that also fails is still an answer to the human, if it is visible. + +### D7 — The ledger is advisory everywhere; nothing here can refuse a write + +No claim, work area, or decision record may block a push, a merge, an edit, or a post. Everything in +this ADR is a **read-time** signal. The claim layer's own history is the argument: the kernel never +refuses an unclaimed post (ADR-018 D3), because "forgot to claim" must not become "agent is silent". +The same reasoning applies with more force to work: "forgot to record a work area" must not become +"cannot ship". + +--- + +## Deliberately out of v1 scope + +- **Locking or arbitration.** D7 forecloses it. If two seats claim overlapping paths, both proceed and + both can see it. +- **Automatic decision extraction from chat.** A decision record is written deliberately. Inferring + rulings from prose is how Finding 5 got its ambiguity in the first place. +- **Cross-pod or cross-instance ledgers.** Pod-scoped in v1; federation is ADR-004's problem. +- **Retrofitting history.** No backfill of past rows or past claims. The ledger starts empty. + +--- + +## Ratification points (Sam) + +1. **D5's container** — board-row update log, or a pod-scoped ledger addressable independent of a row. + This is the one decision the rest hangs off, and it is genuinely open. +2. **D1's granularity** — is the work area `{branch, baseSha, paths[]}`, or is `{branch, baseSha}` + enough for v1? Paths are where the cost is, and where the AX-audit collisions live. +3. **D6's third arm** — does "seen by N, answered by 0" surface to the human in v1, or does the + re-offer alone close #1394? +4. **D4's vocabulary** — three dispositions (`answered` / `passed` / `expired`), or more? Adding one + later is cheap; changing the meaning of one is not. +5. **Whether D6 belongs here at all.** It is an ADR-018 decision. This ADR proposes to *build* it and + records why it was never built; the alternative is an ADR-018 amendment and a build task, leaving + ADR-027 to D1–D5 and D7. Sam asked for them ruled together, so they are drafted together — but the + home of the rule is a real question and the answer is not obviously this file. + +--- + +## Evidence I could not verify + +Sam's kickoff cites a private GTM evidence pack (a wedge memo §3–4 and a competitive dive) with two +claims: that one competitor has scope-memory but no claim primitive, and that another claims the +primitive in a README only. **Those files are operator-held and are not readable from this seat** — I +searched the local filesystem for both by name and found neither, and this repo carries nothing from +them. They are recorded here as **reported, not verified**, and the competitive comparison section is +deliberately left unwritten rather than paraphrased from a summary. Nothing in the audit or in D1–D7 +depends on them: every finding above is measured on `origin/main`. + +The customer-evidence signal behind #1296 (device-flow CLI auth) is in the same category and is not +this ADR's subject; it is noted only because the kickoff bundles them. From 5097a39dca5057a39aba26af0554049fed1bd1c3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:19:05 -0700 Subject: [PATCH 02/13] =?UTF-8?q?docs(adr-027):=20correct=20Finding=202=20?= =?UTF-8?q?=E2=80=94=20prUrl=20is=20already=20writable=20on=20a=20claimed?= =?UTF-8?q?=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review's gate on e409a2f2 is right and the draft was wrong: the route PATCH /api/v1/tasks/:podId/:taskId allowlists prUrl with no status gate, and the router's auth shim sends cm_agent_* to agentRuntimeAuth, so an agent can write it while claimed today. Widened past the correction: the capability is reachable from the openclaw extension, whose patch tool says "link a PR" in its own description, and NOT from the MCP tool surface, which has no patch tool at all — prUrl appears only on commonly_complete_task, described as the merged PR. Every seat on this board is an MCP seat, which is why all six claimed rows read null. D2 becomes a tool-surface proposal rather than a data-model one, and the thesis drops from six of six to five of six with Finding 2 kept standing as the counterexample. Co-Authored-By: Claude Opus 5 --- ...ADR-027-work-claims-and-decision-ledger.md | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/docs/adr/ADR-027-work-claims-and-decision-ledger.md b/docs/adr/ADR-027-work-claims-and-decision-ledger.md index e34091446..07ad0d3ae 100644 --- a/docs/adr/ADR-027-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-027-work-claims-and-decision-ledger.md @@ -64,12 +64,24 @@ not addressable, and a decision is not a first-class object. So a claimed row answers *who* and *until when*, and cannot answer *where*. Two seats can claim two different rows whose work is the same file and nothing anywhere knows. -**Finding 2 — `prUrl` is write-once at completion, so "built, open, waiting on a press" has no -machine-readable home.** `prUrl` is settable only via `commonly_complete_task`, whose own description -defines it as *the merged PR*; `commonly_update_task` takes `{podId, taskId, text}` and -`commonly_claim_task` takes no `prUrl` at all. Measured on this board: every `claimed` row naming an -open PR in prose carried `prUrl: null`. A peer reading the row correctly reached the opposite of the -truth and said so in the pod. +**Finding 2 — `prUrl` is already writable on a claimed row, and unreachable from the tool surface +every seat on this board actually holds.** Corrected after @sprint-review's gate on `e409a2f2`; the +first draft said the field was write-once at completion, and that is false at the route. +`PATCH /api/v1/tasks/:podId/:taskId` allowlists `prUrl` with **no status gate**, and the router's own +`auth` shim sends any `cm_agent_*` bearer to `agentRuntimeAuth` — so an agent can write `prUrl` while +its row is `claimed`, today. The openclaw extension exposes that route as a tool and its description +says so in as many words: *"Use to reassign, mark blocked/unblocked, or link a PR."* + +The MCP tool surface has **no PATCH tool at all**. `commonly_complete_task` is the only MCP tool that +accepts `prUrl`, its description defines it as *the merged PR*, and it posts to `/complete`; +`commonly_update_task` takes `{podId, taskId, text}` and posts to `/updates`; `commonly_claim_task` +takes neither. Every seat on this board is an MCP seat — which is why all six `claimed` rows naming an +open PR in prose carried `prUrl: null`, and why a peer reading the row correctly reached the opposite +of the truth and said so in the pod. + +So the defect is not the data model. It is that one runtime can record "built, open, waiting on a +press" and the other cannot, from the same kernel, with nothing anywhere naming the asymmetry — +a capability that exists relative to a runtime rather than absolutely. **Finding 3 — The context an agent reads before working contains no claims and no decisions.** `GET /api/agents/runtime/pods/:podId/context` returns, via `PodContextService.getPodContext`: @@ -107,11 +119,16 @@ Freeing the lease reaches nobody. The native tier's unconditional release at `nativeRuntimeService.ts:828` is therefore not an implementation of D6.1 — it frees the lease and re-offers nothing. -> **The generalisation, which is this ADR's thesis:** every one of these six findings is the same -> defect. A claim, a board row, and an update each record **what state we are in** and never **what -> was done or decided, by whom, and against what alternative**. The store keeps the result and drops -> the reasons — exactly the property the manifesto line names in code, reproduced one layer up in our -> own coordination substrate. +> **The generalisation, which is this ADR's thesis:** five of these six findings are the same defect. +> A claim, a board row, and an update each record **what state we are in** and never **what was done +> or decided, by whom, and against what alternative**. The store keeps the result and drops the +> reasons — exactly the property the manifesto line names in code, reproduced one layer up in our own +> coordination substrate. +> +> **Finding 2 is the exception and the draft over-claimed it.** It said six of six; the gate found the +> store already holds what Finding 2 asked for. Corrected, it is a different defect — a capability +> that exists at the route and is reachable from one runtime and not the other — and it is left +> standing rather than folded in, because a thesis that absorbs its own counterexample is not one. --- @@ -129,12 +146,17 @@ are not two seats editing one line — git already reports those. They are two s seat pushing to another's branch. A path list makes both visible at read time. It is an **advisory** record, not a mutex: this ADR proposes nothing that can refuse a write. -### D2 — `prUrl` becomes writable while a row is claimed +### D2 — The MCP tool surface exposes the `prUrl` write that already exists -Not a new field: the same field, released from its write-once-at-merge constraint, so an open PR is -recordable the moment it exists. Finding 2 is a one-line data-model change that removes a whole class -of wrong peer conclusions. (ADR-017's `blockedOn` work already made the parallel move for the blocked -side; this is its twin.) +Not a data-model change and not a new capability — the route allows it now. The proposal is to reach +it: give the MCP tool set the same field-patch verb the openclaw extension already has, or widen +`commonly_update_task` to carry the allowlisted fields, so an open PR is recordable from either +runtime the moment it exists. (ADR-017's `blockedOn` work made the parallel move for the blocked side; +this is its twin, one layer out — there the field was missing, here only the reach is.) + +The rule under it generalises past `prUrl`: **a kernel capability is not shipped until every runtime +can reach it.** A field allowlisted on a route and absent from one runtime's tools is indistinguishable, +from inside that runtime, from a field that does not exist. ### D3 — The context read surfaces active claims and recent decisions From a6225e2e2dba4d5823d164b25712ee4bf0828084 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:27:17 -0700 Subject: [PATCH 03/13] docs(adr-027): drop the runtime-census premise under Finding 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review falsified "every seat on this board is an MCP seat" — their seat is MCP and has written prUrl by curl. That claim was load-bearing for why the claimed rows read null, so it is replaced with a measurement that needs no premise about runtimes: of 94 board rows, 47 carry a prUrl and all 47 are `done` — zero on claimed, blocked or pending. D2's generalisation is narrowed the same way: "until every runtime can reach it" is false (a token plus an HTTP client always reaches the route); "until every runtime's tools name it" is what the evidence supports. Co-Authored-By: Claude Opus 5 --- ...ADR-027-work-claims-and-decision-ledger.md | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/adr/ADR-027-work-claims-and-decision-ledger.md b/docs/adr/ADR-027-work-claims-and-decision-ledger.md index 07ad0d3ae..20209db63 100644 --- a/docs/adr/ADR-027-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-027-work-claims-and-decision-ledger.md @@ -64,8 +64,8 @@ not addressable, and a decision is not a first-class object. So a claimed row answers *who* and *until when*, and cannot answer *where*. Two seats can claim two different rows whose work is the same file and nothing anywhere knows. -**Finding 2 — `prUrl` is already writable on a claimed row, and unreachable from the tool surface -every seat on this board actually holds.** Corrected after @sprint-review's gate on `e409a2f2`; the +**Finding 2 — `prUrl` is already writable on a claimed row, and no row on this board has ever +carried one.** Corrected twice after @sprint-review's gate on `e409a2f2`; the first draft said the field was write-once at completion, and that is false at the route. `PATCH /api/v1/tasks/:podId/:taskId` allowlists `prUrl` with **no status gate**, and the router's own `auth` shim sends any `cm_agent_*` bearer to `agentRuntimeAuth` — so an agent can write `prUrl` while @@ -75,13 +75,23 @@ says so in as many words: *"Use to reassign, mark blocked/unblocked, or link a P The MCP tool surface has **no PATCH tool at all**. `commonly_complete_task` is the only MCP tool that accepts `prUrl`, its description defines it as *the merged PR*, and it posts to `/complete`; `commonly_update_task` takes `{podId, taskId, text}` and posts to `/updates`; `commonly_claim_task` -takes neither. Every seat on this board is an MCP seat — which is why all six `claimed` rows naming an -open PR in prose carried `prUrl: null`, and why a peer reading the row correctly reached the opposite -of the truth and said so in the pod. - -So the defect is not the data model. It is that one runtime can record "built, open, waiting on a -press" and the other cannot, from the same kernel, with nothing anywhere naming the asymmetry — -a capability that exists relative to a runtime rather than absolutely. +takes neither. + +**The tool gap does not bound what a seat can do, and the draft's second version said it did.** +@sprint-review's own seat is MCP and has written `prUrl` with a direct `curl` against the route; a +runtime token plus an HTTP client is the escape hatch, so the missing tool shapes the *default path* +and not the capability ceiling. The measurement that survives without any premise about which runtime +a seat runs is the whole board: of 94 rows, **47 carry a `prUrl` and all 47 are `done`** — zero on +`claimed`, `blocked`, or `pending`, across every seat and every runtime that has ever written to it. +The field is populated at completion and never before, which is what `commonly_complete_task`'s +description teaches (*"`prUrl` is the merged PR"*) and what the openclaw patch tool's description +contradicts (*"Use to reassign, mark blocked/unblocked, or link a PR"*). + +So the defect is not the data model, and it is not a hard capability boundary either. It is that the +route allows a write that one runtime's tools name and describe, the other's do not expose at all, and +the two descriptions disagree about what the field means — so a state the store can hold, "built, +open, waiting on a press", has never once been recorded, and a peer reading `prUrl: null` off a +claimed row correctly reached the opposite of the truth and said so in the pod. **Finding 3 — The context an agent reads before working contains no claims and no decisions.** `GET /api/agents/runtime/pods/:podId/context` returns, via `PodContextService.getPodContext`: @@ -125,10 +135,13 @@ re-offers nothing. > reasons — exactly the property the manifesto line names in code, reproduced one layer up in our own > coordination substrate. > -> **Finding 2 is the exception and the draft over-claimed it.** It said six of six; the gate found the -> store already holds what Finding 2 asked for. Corrected, it is a different defect — a capability -> that exists at the route and is reachable from one runtime and not the other — and it is left -> standing rather than folded in, because a thesis that absorbs its own counterexample is not one. +> **Finding 2 is the exception and this draft has over-claimed it twice.** It first said six of six; +> the gate found the store already holds what Finding 2 asked for. The correction then explained the +> emptiness by a runtime census, and the same reviewer falsified that too — an MCP seat has written the +> field by `curl`. What is left is narrower and measured rather than inferred: the write is allowed, +> the two runtimes' tool descriptions disagree about what the field means, and 47 of 47 populated +> `prUrl` values on this board sit on `done` rows. It is left standing rather than folded in, because +> a thesis that absorbs its own counterexample is not one. --- @@ -154,9 +167,12 @@ it: give the MCP tool set the same field-patch verb the openclaw extension alrea runtime the moment it exists. (ADR-017's `blockedOn` work made the parallel move for the blocked side; this is its twin, one layer out — there the field was missing, here only the reach is.) -The rule under it generalises past `prUrl`: **a kernel capability is not shipped until every runtime -can reach it.** A field allowlisted on a route and absent from one runtime's tools is indistinguishable, -from inside that runtime, from a field that does not exist. +The rule under it generalises past `prUrl`, in its survivable form: **a kernel capability is not +shipped until every runtime's tools name it.** The stronger form — *until every runtime can reach it* — +is false here and was in the second draft: a seat holding a runtime token can always reach the route +with an HTTP client, and one has. What the missing tool actually costs is the default: a field +allowlisted on a route and absent from a runtime's tool set is, for every agent that works through its +tools, a field that does not exist — and the 47-of-47 measurement above is what that costs in practice. ### D3 — The context read surfaces active claims and recent decisions From 770fb1fa66198f39f9abd498039470bd5e3193d2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:45:37 -0700 Subject: [PATCH 04/13] docs(adr): renumber to ADR-028 and cross-link ADR-027 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1399 (PM-tool projection contract, Wren) was filed at 03:08 and this at 03:11; both took ADR-027. Sam ruled the earlier filing keeps the number. Renames the file, fixes the title and the one internal self-reference, and adds a scope-boundary bullet naming ADR-027 as the sibling — per CLAUDE.md's ADR discipline, the adjacent ADR must be named in the one a reader reaches first, not left for them to find. 028 is free on main and unclaimed by any open PR (verified against the open-PR set, not against main alone). Co-Authored-By: Claude Opus 5 --- ...r.md => ADR-028-work-claims-and-decision-ledger.md} | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) rename docs/adr/{ADR-027-work-claims-and-decision-ledger.md => ADR-028-work-claims-and-decision-ledger.md} (96%) diff --git a/docs/adr/ADR-027-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md similarity index 96% rename from docs/adr/ADR-027-work-claims-and-decision-ledger.md rename to docs/adr/ADR-028-work-claims-and-decision-ledger.md index 20209db63..031d76675 100644 --- a/docs/adr/ADR-027-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -1,4 +1,4 @@ -# ADR-027 — Work claims and the decision ledger: recording custody, work area, and why +# ADR-028 — Work claims and the decision ledger: recording custody, work area, and why **Status:** **Draft** — design only, opened at Sam's request (2026-08-31 kickoff). Nothing here is ratified; D1–D7 are proposals. **Build waits for the five customer interviews** — this ADR exists so @@ -18,6 +18,12 @@ decisions are proposals, the findings are measurements. - [`ADR-024`](ADR-024-shared-awareness-and-the-agent-inbox.md) owns awareness of *messages*. This ADR is about awareness of *work and decisions* — a different store, read at a different moment (before work starts, not on delivery). +- [`ADR-027`](ADR-027-pm-tool-projection-contract.md) (Wren) owns how a structured work item + **projects** across a tool boundary — a pod's board ↔ an external PM surface. This ADR owns what + the work item **records in the first place**: the claim's work area, and the decision behind it. + They are siblings and the dependency runs one way — the ledger is what the projection projects, so + a field this ADR does not require is a field ADR-027 cannot carry across. Numbered 028 because + ADR-027 was filed three minutes earlier and both drafts took the same number (Sam, 2026-08-31). - [`ADR-017`](ADR-017-attention-routing.md) owns routing to the **human**. Everything here routes between agents. - [`ADR-003`](ADR-003-memory-as-kernel-primitive.md) owns memory. A decision ledger is not memory: it @@ -254,7 +260,7 @@ The same reasoning applies with more force to work: "forgot to record a work are later is cheap; changing the meaning of one is not. 5. **Whether D6 belongs here at all.** It is an ADR-018 decision. This ADR proposes to *build* it and records why it was never built; the alternative is an ADR-018 amendment and a build task, leaving - ADR-027 to D1–D5 and D7. Sam asked for them ruled together, so they are drafted together — but the + ADR-028 to D1–D5 and D7. Sam asked for them ruled together, so they are drafted together — but the home of the rule is a real question and the answer is not obviously this file. --- From 6f2d74b4fd38ff0eab5a0de321ad82c7d0fdac15 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:47:52 -0700 Subject: [PATCH 05/13] docs(adr-028): fix the :957 citation and narrow Finding 6 to a caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of @sprint-review's re-gate items, re-derived at origin/main rather than accepted on report: - `:957` is the loop-guard `countDocuments`, not the enqueue. The enqueue is `enqueueWakeOnMessage` at `:1254`. Fixed at both citation sites and cited by symbol, since a raw line number expires on the next edit above it. - The exclusion machinery already exists: `:1240` filters on `excludeKeys`, and `:1757` already passes a populated set (`:1395` passes null). So D6's re-offer is a third call site, not new machinery. Finding 6's "no second-pass machinery" is narrowed to what is actually absent — nothing computes a passed-set or re-enters the fan-out with it. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index 031d76675..b48b20f47 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -126,11 +126,20 @@ pass** rather than consuming the message. A targeted `chat.mention` keeps D6 unc claimer *is* the addressee, and its silence is the answer."* D6.1 was written from an observed 2026-08-14 incident with the identical shape to #1394's 2026-08-30 one. -Measured: **there is no second-pass machinery anywhere in `backend/`.** A grep for a re-offer, a +Measured: **nothing anywhere in `backend/` re-offers a passed message.** A grep for a re-offer, a second pass, or a D6.1 reference across `backend/services` and `backend/routes` returns nothing but an unrelated comment in `onboardingSilenceService.ts`. And releasing the claim could not implement D6.1 -on its own even where it happens: `agentMentionService.ts:957` enqueues **one `message.posted` event -per wake-eligible install**, so the peer seats that stood down have already consumed their own events. +on its own even where it happens: `enqueueWakeOnMessage` (`agentMentionService.ts:1254`) enqueues +**one `message.posted` event per wake-eligible install**, so the peer seats that stood down have +already consumed their own events. + +**Corrected after @sprint-review's re-gate, and it narrows the finding.** The first draft cited `:957` +— that is the loop-guard `countDocuments`, not the enqueue — and said the machinery is absent. The +*exclusion* half is not absent: `enqueueWakeOnMessage` skips any seat in `excludeKeys` (`:1240`), and +one of its two call sites already passes a populated set (`:1757`, `enqueuedIdentityKeys`; the other, +`:1395`, passes `null`). So a re-offer that omits the seats which already passed is a **caller**, not +new machinery. What is genuinely missing is the trigger: nothing computes a passed-set or re-enters +the fan-out with it. Freeing the lease reaches nobody. The native tier's unconditional release at `nativeRuntimeService.ts:828` is therefore not an implementation of D6.1 — it frees the lease and re-offers nothing. @@ -220,8 +229,13 @@ is the mechanism Finding 6 shows is missing, and its bound: passed again is done. - A **targeted** `chat.mention` is unchanged: there the claimer is the addressee and its silence is the answer. This is D6, and D6 is right. -- The re-offer must be a **new event per remaining seat**, because `agentMentionService.ts:957` - already fans out per install. Releasing the lease is not sufficient and never was. +- The re-offer must be a **new event per remaining seat**, because `enqueueWakeOnMessage` + (`agentMentionService.ts:1254`) already fans out per install. Releasing the lease is not sufficient + and never was. +- **This is a caller, not new machinery.** The same helper already takes an `excludeKeys` set and + filters on it (`:1240`), and `:1757` already passes one. Building D6.1 means computing the + passed-set and re-entering `enqueueWakeOnMessage` with it — so the implementation cost is a third + call site, which is a materially smaller ask than the first draft implied. Third arm, from #1394's own suggestion 3 and worth ruling separately: the human should be able to see **"seen by 5, answered by 0"** rather than nothing. `agentDelivery` already knows the fan-out. A From a6e4942ce21cbd442b7173f81d5e9d37d2306e72 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:42:54 -0700 Subject: [PATCH 06/13] =?UTF-8?q?docs(adr-028):=20fold=20Sam's=20ratified?= =?UTF-8?q?=20attention=20doctrine=20=E2=80=94=20D8-D10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam ratified in the sprint pod (2026-08-31): "we route attention, we do not compete for it." External apps stay the human attention layer; the ledger owns the decision moment and is reached by link. Standing constraint: no new notification system, no per-user routing UI in v1. Folded as three ratified decisions, kept separate from the still-proposed D1-D7 so `Draft` is not read as having settled the audit: - D8: a claim/decision event classifies on the EXISTING routing layer. - D9: every externally-surfaced claim/decision carries a canonical ledger URL; the external copy is a pointer, never the record. - D10: the digest is a read over the ledger, not a store. Two corrections to the mechanism as named, recorded in place at D8 so neither is inherited as fact. The doctrine cites "the attention threshold ... (ADR-018 machinery)"; measured on origin/main, (1) the machinery is ADR-017's -- ADR-018 disclaims the direction in its own text ("ADR-017 covers agent->human escalation only") and its only threshold is D6.3's convergence counter, a loop bound between agents; and (2) ADR-017 Layer 2 explicitly REJECTED thresholds ("Classes, not scores. Scalars are miscalibrated and thresholds rot"), so D8 is built as a class on that taxonomy, not a score against a cutoff. Sam's ruling is unchanged by either -- they change which file an implementer opens. D8's substrate is unratified: ADR-017 is Proposed and its Layer 3.1 carries three undecided items. D8 is ratified in direction and blocked in substrate, stated in place rather than left to be discovered. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 105 +++++++++++++++++- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index b48b20f47..250701ace 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -1,7 +1,11 @@ # ADR-028 — Work claims and the decision ledger: recording custody, work area, and why -**Status:** **Draft** — design only, opened at Sam's request (2026-08-31 kickoff). Nothing here is -ratified; D1–D7 are proposals. **Build waits for the five customer interviews** — this ADR exists so +**Status:** **Draft, with one ratified doctrine folded in.** Opened at Sam's request (2026-08-31 +kickoff). **D1–D7 remain proposals and nothing in the audit is ratified.** What *is* ratified is the +attention doctrine in §Ratified doctrine (Sam, 2026-08-31) and the three consequences it forces, +carried below as **D8–D10**. Read the split literally: Sam ruled on where a decision is *rendered* +and what it costs the human, not on what a claim records or where the ledger lives — those are still +the open questions at §Ratification points, and D8–D10 do not presuppose an answer to any of them. **Build waits for the five customer interviews** — this ADR exists so that what gets built is decided before, not during. One section is deliberately unfilled: the competitive comparison, because the evidence pack behind it is operator-held and not readable from this seat (see §Evidence I could not verify). @@ -24,13 +28,38 @@ decisions are proposals, the findings are measurements. They are siblings and the dependency runs one way — the ledger is what the projection projects, so a field this ADR does not require is a field ADR-027 cannot carry across. Numbered 028 because ADR-027 was filed three minutes earlier and both drafts took the same number (Sam, 2026-08-31). -- [`ADR-017`](ADR-017-attention-routing.md) owns routing to the **human**. Everything here routes - between agents. +- [`ADR-017`](ADR-017-attention-routing.md) owns routing to the **human**. The audit and D1–D7 route + only between agents. **D8–D10 are the exception and they are deliberate**: the ratified doctrine + puts a claim/decision event onto ADR-017's existing routing layer rather than beside it, so this + ADR now *consumes* ADR-017 and must not grow a second router. Two consequences of that dependency + are recorded at D8 rather than left to be discovered: ADR-017 is **`Proposed`**, so D8's substrate + is unratified, and the doctrine's phrase "attention threshold" names a mechanism ADR-017 + explicitly rejected. - [`ADR-003`](ADR-003-memory-as-kernel-primitive.md) owns memory. A decision ledger is not memory: it is shared, addressable, and append-only, where memory is private and rewritable. --- +## Ratified doctrine (Sam, 2026-08-31) + +> **We route attention, we do not compete for it.** + +External messaging apps remain the human attention layer. The ledger owns the **decision moment** — +gate approvals, claim-conflict resolution, and the what-my-agents-did-and-decided digest are rendered +**only in Commonly** and reached by link from the external app. + +Ratified verbatim by Sam in the sprint pod (2026-08-31), with a standing constraint that binds every +decision below it: **no new notification system, and no per-user routing UI in v1.** + +This is the doctrine D8–D10 implement. It settles a question the rest of this ADR had left implicit — +whether a decision ledger should push. It should not. Everything downstream of it is a link. + +*Scope of the ratification:* the doctrine and its three consequences. It does not ratify D1–D7, and +it does not choose D5's container — a link needs something addressable to point at, but "addressable" +is satisfied by either candidate at §Ratification points 1, so the doctrine survives either answer. + +--- + ## Context: code preserves the result, not the reasons A repository is a perfect record of what was decided and a near-total loss of why. The commit that @@ -160,7 +189,10 @@ re-offers nothing. --- -## Decisions (proposals for Sam) +## Decisions + +**D1–D7 are proposals for Sam. D8–D10 are ratified** — they are the ratified doctrine's consequences, +and the only open thing about them is when ADR-017 is ratified so D8 has a substrate to build on. ### D1 — A claim carries a work area, written at claim time @@ -251,6 +283,66 @@ The same reasoning applies with more force to work: "forgot to record a work are --- +### D8 — A claim or decision event classifies on the existing routing layer, and adds no second one + +A claim/decision event is routed by the same machinery that already routes any wake event. It is not +a new feed, a new store, or a new subscriber list. This is the operative half of "no new notification +system": the cost of surfacing a decision must be the cost of one more event class, not the cost of a +router. + +**Two corrections to the mechanism as named, recorded here so neither is inherited as fact.** The +doctrine cites "the attention threshold … (ADR-018 machinery)". Measured on `origin/main`: + +1. **The machinery is ADR-017's, not ADR-018's.** ADR-018 disclaims the direction in its own text — + "ADR-017 covers agent→human escalation only. Agent↔agent coordination has no …". ADR-018's only + threshold is D6.3's consecutive-silence convergence counter, which is a loop bound between agents + and not a routing decision about a human. Citing "ADR-018's attention threshold" as the substrate + for D8 would point an implementer at a mechanism that is not there. +2. **"Threshold" names the thing ADR-017 rejected.** ADR-017 Layer 2 rules *"Classes, not scores. + Scalars are miscalibrated and thresholds rot"* — routing is by **class**, budgeted and muted as a + class. So D8 is built as a divergence/decision **class** on ADR-017's taxonomy (which carries an + `other` + free-text escape valve for exactly this case), not as a score compared against a cutoff. + +Sam's intent is unambiguous and is what is ratified: reuse, don't build. The two corrections change +which file an implementer opens and which shape they build; they change nothing about the ruling. + +**The dependency is real and is not resolved by this ADR:** ADR-017 is **`Proposed`**, and its +Layer 3.1 attention queue carries three explicitly undecided items. D8 is therefore ratified in +*direction* and blocked in *substrate* — it cannot be built before ADR-017 is ratified, and building +it against an unratified spec is the failure mode CLAUDE.md's ADR-status discipline names. If +ADR-017's ratification changes the class taxonomy, D8 follows it; D8 does not get its own. + +### D9 — Every externally-surfaced claim or decision carries a canonical ledger URL + +If a claim or decision is visible anywhere outside Commonly, the surfaced artifact carries a link +back to the canonical record. The external copy is a **pointer, never the record** — it may be +truncated, stale, or rendered by a surface Commonly does not control, and the link is what makes that +safe. This is the mechanical half of "reached by link from the external app". + +Consequence worth stating, because it is the constraint that bites first: **the ledger record must be +addressable before it can be surfaced.** Any container chosen at §Ratification points 1 must yield a +stable URL for a single decision. That is a requirement D9 places *on* that open question, not an +answer to it. + +Consequence for [`ADR-027`](ADR-027-pm-tool-projection-contract.md): a projection into an external PM +surface is an external surfacing, so the projected item carries the ledger URL. The one-way +dependency already recorded in the scope boundary holds — this ADR requires the field, ADR-027 +carries it across. + +### D10 — The digest is a read over the ledger, not a store + +The "what my agents did and decided" digest is computed from ledger records at read time. It does not +get its own table, its own write path, or its own retention rule. This is the third face of the +keep-it-simple constraint, and it is the one most likely to be violated by accident, because a digest +is the natural place to start caching. + +The falsifiable consequence: **a digest must be reproducible from the ledger alone.** If a digest ever +needs a fact the ledger does not carry, that is a signal to add the field to the ledger (D1/D4/D5), +never to add a store beside it. D7 applies unchanged — a digest is a read-time signal and can refuse +nothing. + +--- + ## Deliberately out of v1 scope - **Locking or arbitration.** D7 forecloses it. If two seats claim overlapping paths, both proceed and @@ -259,6 +351,9 @@ The same reasoning applies with more force to work: "forgot to record a work are rulings from prose is how Finding 5 got its ambiguity in the first place. - **Cross-pod or cross-instance ledgers.** Pod-scoped in v1; federation is ADR-004's problem. - **Retrofitting history.** No backfill of past rows or past claims. The ledger starts empty. +- **A second notification system, and per-user routing UI.** Foreclosed by the ratified doctrine, not + by preference — D8 reuses ADR-017's routing layer and D10 forbids a store beside the ledger. A v1 + that ships either has not implemented the doctrine, it has worked around it. --- From 85870135d0b19ef3b05dd50333546ee6f3f6acb4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:50:35 -0700 Subject: [PATCH 07/13] docs(adr-028): Sam's 61474 sequencing + D11 kill criteria; escalation is one connector deep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blockers from @sprint-review (61575/61579), all verified before folding. 1. The status block still said "build waits for the five customer interviews". Sam RESCINDED that at 61474 in favour of ship-and-measure. Replaced, with the rescind recorded in place rather than the sentence silently deleted. 2. The acceptance section Sam asked for was missing -- 0 hits for kill-criteria or board depth. Added as D11 plus a §Sequencing and acceptance section carrying Sam's four-step order (stamp -> thin core -> fleet dogfood -> biomed team as first external offer). D11 names both instruments Sam required AND their failing cases, because a kill criterion without a stated failure mode cannot kill anything: if the ledger is not read before work starts the defect is D3's surfacing, not the recording; if board depth stays flat the ledger is write-only. Both are reads over data D1/D4/D5 already require -- per D10 neither gets a store, per D7 neither can gate. 3. "Classifies like any wake event" reads as a platform property and is one connector deep. Verified on origin/main rather than taken: shouldEscalate is DEFINED once and CALLED once, both inside telegramBridgeService.ts (:64, :137). discordService, discordGateway and slackApi carry no escalation gate at all; WhatsApp and X have none. So on every connector but Telegram, D8's "classify" step has nothing to classify against yet. Recorded at D8 so "reuse the existing layer" is not read as "the layer already covers the fleet". The sequencing section states what it does NOT settle: step 1 is a review stamp, not ratification of D1-D7, and D5's container sits INSIDE the thin core so it must be answered before step 2, not deferred by it. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 81 +++++++++++++++++-- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index 250701ace..a6d12c18f 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -2,13 +2,18 @@ **Status:** **Draft, with one ratified doctrine folded in.** Opened at Sam's request (2026-08-31 kickoff). **D1–D7 remain proposals and nothing in the audit is ratified.** What *is* ratified is the -attention doctrine in §Ratified doctrine (Sam, 2026-08-31) and the three consequences it forces, -carried below as **D8–D10**. Read the split literally: Sam ruled on where a decision is *rendered* +attention doctrine in §Ratified doctrine (Sam, 2026-08-31) and the consequences it forces, carried +below as **D8–D11** — D11 being the kill-criteria instrumentation Sam required at acceptance. Read the split literally: Sam ruled on where a decision is *rendered* and what it costs the human, not on what a claim records or where the ledger lives — those are still -the open questions at §Ratification points, and D8–D10 do not presuppose an answer to any of them. **Build waits for the five customer interviews** — this ADR exists so -that what gets built is decided before, not during. One section is deliberately unfilled: the -competitive comparison, because the evidence pack behind it is operator-held and not readable from -this seat (see §Evidence I could not verify). +the open questions at §Ratification points, and D8–D10 do not presuppose an answer to any of them. + +**Build is no longer gated on customer interviews.** A previous revision of this line said "build +waits for the five customer interviews"; **Sam rescinded that on 2026-08-31 (pod 61474) in favour of +ship-and-measure**, and the sequencing is now at §Sequencing and acceptance. The instrumentation +required there (D11) is what answers the questions the interviews were going to. + +One section is deliberately unfilled: the competitive comparison, because the evidence pack behind it +is operator-held and not readable from this seat (see §Evidence I could not verify). **Date:** 2026-08-31 **Method:** current-state audit measured on `origin/main` at `4d177817` (2026-08-31T02:34:30Z); the decisions are proposals, the findings are measurements. @@ -57,6 +62,8 @@ whether a decision ledger should push. It should not. Everything downstream of i *Scope of the ratification:* the doctrine and its three consequences. It does not ratify D1–D7, and it does not choose D5's container — a link needs something addressable to point at, but "addressable" is satisfied by either candidate at §Ratification points 1, so the doctrine survives either answer. +D11 and §Sequencing come from a second Sam ruling the same day (61474) and are ratified on the same +footing; they are separated here only because they answer a different question. --- @@ -191,8 +198,9 @@ re-offers nothing. ## Decisions -**D1–D7 are proposals for Sam. D8–D10 are ratified** — they are the ratified doctrine's consequences, -and the only open thing about them is when ADR-017 is ratified so D8 has a substrate to build on. +**D1–D7 are proposals for Sam. D8–D11 are ratified** — D8–D10 are the attention doctrine's +consequences and D11 is the instrumentation Sam required at acceptance. The only open thing about +them is when ADR-017 is ratified, so D8 has a substrate to build on. ### D1 — A claim carries a work area, written at claim time @@ -306,6 +314,20 @@ doctrine cites "the attention threshold … (ADR-018 machinery)". Measured on `o Sam's intent is unambiguous and is what is ratified: reuse, don't build. The two corrections change which file an implementer opens and which shape they build; they change nothing about the ruling. +**Third correction, and it is about capability rather than citation (@sprint-review, 2026-08-31).** +"Classifies like any wake event" reads as a platform property and is currently **one connector deep**. +Measured on `origin/main`: `shouldEscalate` is *defined* once and *called* once, both inside +`backend/services/telegramBridgeService.ts` (`:64`, `:137`). Discord and Slack services exist +(`discordService.ts`, `discordGatewayService.ts`, `slackApi.ts`) and carry **no escalation gate at +all**; WhatsApp and X have none either. So D8 reuses a mechanism that today reaches exactly one +external surface. + +This does not weaken the doctrine — the doctrine says route, don't compete, and routing to one +surface is still routing. It sets the honest expectation: **on every connector but Telegram, D8's +"classify" step has nothing to classify against yet**, and building the missing gates is connector +work this ADR does not own and must not silently assume. Recorded here so "reuse the existing layer" +is not read as "the existing layer already covers the fleet". + **The dependency is real and is not resolved by this ADR:** ADR-017 is **`Proposed`**, and its Layer 3.1 attention queue carries three explicitly undecided items. D8 is therefore ratified in *direction* and blocked in *substrate* — it cannot be built before ADR-017 is ratified, and building @@ -341,6 +363,49 @@ needs a fact the ledger does not carry, that is a signal to add the field to the never to add a store beside it. D7 applies unchanged — a digest is a read-time signal and can refuse nothing. +### D11 — Acceptance ships with kill-criteria instrumentation, and the criteria are named before the build + +Ship-and-measure only works if the measurement is specified before the thing that would bias it. +**Sam named two instruments (pod 61474); both are required at acceptance, not after.** + +1. **Is the ledger read before work starts?** The falsifiable form: for each claim, did a context read + that surfaced active claims or recent decisions occur *before* the claim, or did it not. D3 is the + surface being measured, so D3 and its instrument land together — a D3 that ships unmeasured cannot + be killed, only argued about. +2. **Per-team board depth.** How many rows a team's board actually carries over time. This is the + adoption signal: a ledger nobody deepens is a ledger nobody uses, and it is measurable without + asking anyone anything. + +**These are kill criteria, which means the failing case must be stated now.** If the ledger is +consistently *not* read before work starts, the defect is D3's surfacing or the read moment, not the +recording — and the response is to move the read, not to add fields. If board depth stays flat across +teams while claims keep flowing, the ledger is write-only and the thin core is wrong. Neither +conclusion is available later if the instrument is not there from the start. + +Both instruments are reads over data D1/D4/D5 already require. **Per D10, neither gets a store**, and +per D7 neither can refuse a write — an instrument that gates is not an instrument. + +--- + +## Sequencing and acceptance (Sam, 2026-08-31 — pod 61474) + +Interviews are not a precondition. The order Sam set: + +1. **@sprint-review stamps the renumbered ADR-028** (this document, at its current head). +2. **The operator builds the thin core** — claims deriving work areas, decision records, and + context-read surfacing. That is D1, D5, and D3 respectively; **D2 is the tool surface those need + to be reachable through**, and D4's disposition vocabulary is the one open item inside the core + (§Ratification points 4). +3. **Our own fleet dogfoods it first.** +4. **The biomed team is the first external offer** — their letter asked for a multi-agent-fit task + board. + +Two things this sequencing does not do, stated because a sequence reads like a settlement. It does +not ratify D1–D7: step 1 is a review stamp on a document that still carries open ratification points, +and D5's container (point 1) sits *inside* the thin core, so it has to be answered before step 2 can +start, not deferred by it. And it does not resolve D8's substrate — ADR-017 is `Proposed`, so the +routing layer D8 reuses is unratified independently of anything here. + --- ## Deliberately out of v1 scope From 410d87dd8058e14d4e6acf4007f08d783c512673 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:58:02 -0700 Subject: [PATCH 08/13] =?UTF-8?q?docs(adr-028):=20D8's=20real=20build=20co?= =?UTF-8?q?nstraint=20=E2=80=94=20the=20gate=20takes=20content,=20not=20an?= =?UTF-8?q?=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review 61574, verified on origin/main rather than folded. The existing gate CANNOT classify an event. Its signature is shouldEscalate({content, agentUsername, integration}) -> boolean: a string, an agent identity, a config. No event parameter, no type field. So "a claim/decision event classifies like any wake event" is not directly buildable -- a new event TYPE has nothing to present. What the gate matches is a bracketed literal, and DECISION + APPROVAL are already in ESCALATION_MARKERS. So no-new-system holds exactly, by a narrower path: a claim/decision reaches the human IFF it surfaces as a pod message carrying one of those markers. The requirement lands on the producer, not on the gate. Widened past the correction via the call site: relayAgentMessageToTelegram has exactly ONE caller, AgentMessageService.postMessage (agentMessageService.ts:1746). So the contract is an AGENT-AUTHORED pod message through postMessage. That bites exactly one of the doctrine's three surfaces -- claim-conflict resolution is the case most likely to be KERNEL-authored (lease expiry, sweep, a conflict the kernel settles with no agent speaking), and a kernel-authored record traverses no path to this gate. Two of three are implementable today; the third needs a producer that does not exist. Not a Telegram quirk: ADR-020 D3 records the same shape for approval cards -- "every 'card' is a regex sentinel in the content string". Two independently-built decision surfaces both key on content. The replacement is already ratified: ADR-020 is ACCEPTED and its D3 gives messages a structured payload in Mongo and PG. So D8 builds on the marker today and migrates onto that payload. Corrects this ADR's own framing and my pod message 61578: "nothing can be built until ADR-017 is ratified" is too strong. True of D8's CLASSIFICATION layer; false of the RENDERING half, which is the doctrine's core claim -- ADR-020 is Accepted and D3 states the approval card IS ADR-017's card, implemented, lifecycle and invariants intact. The decision moment has a ratified home right now. What is blocked is which events reach it automatically, not whether it exists. ADR-020 added to the scope boundary, since this document would otherwise read as depending only on a Proposed ADR. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index a6d12c18f..f882ac2ef 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -37,9 +37,14 @@ decisions are proposals, the findings are measurements. only between agents. **D8–D10 are the exception and they are deliberate**: the ratified doctrine puts a claim/decision event onto ADR-017's existing routing layer rather than beside it, so this ADR now *consumes* ADR-017 and must not grow a second router. Two consequences of that dependency - are recorded at D8 rather than left to be discovered: ADR-017 is **`Proposed`**, so D8's substrate - is unratified, and the doctrine's phrase "attention threshold" names a mechanism ADR-017 - explicitly rejected. + are recorded at D8 rather than left to be discovered: ADR-017 is **`Proposed`**, so D8's + *classification* substrate is unratified, and the doctrine's phrase "attention threshold" names a + mechanism ADR-017 explicitly rejected. +- [`ADR-020`](ADR-020-admin-guide-delegated-authority.md) is **`Accepted`** and implements ADR-017's + card (its D3). It owns the surface on which a decision is *rendered*, and its structured message + `payload` is what D8 migrates onto. **Named here because the adjacent-ADR trap is on record**: this + document would otherwise be read as depending only on a `Proposed` ADR, when the rendering half it + needs is already ratified. This ADR adds no card and no second lifecycle. - [`ADR-003`](ADR-003-memory-as-kernel-primitive.md) owns memory. A decision ledger is not memory: it is shared, addressable, and append-only, where memory is private and rewritable. @@ -328,9 +333,55 @@ surface is still routing. It sets the honest expectation: **on every connector b work this ADR does not own and must not silently assume. Recorded here so "reuse the existing layer" is not read as "the existing layer already covers the fleet". -**The dependency is real and is not resolved by this ADR:** ADR-017 is **`Proposed`**, and its -Layer 3.1 attention queue carries three explicitly undecided items. D8 is therefore ratified in -*direction* and blocked in *substrate* — it cannot be built before ADR-017 is ratified, and building +**Fourth correction, and this one is a build constraint rather than a caveat (@sprint-review, 2026-08-31).** +The existing gate **cannot classify an event at all.** Its signature is +`shouldEscalate({ content, agentUsername, integration }) => boolean` — a **string**, an agent +identity, and a config. There is no event parameter and no type field, so *"a claim/decision event +classifies like any wake event"* is not directly buildable: **a new event type has nothing to present +to the classifier.** What the gate matches is a bracketed literal in the content: + +``` +ESCALATION_MARKERS = /\[(BLOCKED|ESCALATE|DECISION|NEEDS[-_ ]?HUMAN|APPROVAL)\]/i +``` + +**`DECISION` and `APPROVAL` are already in that set.** So "no new notification system" holds exactly, +and by a narrower path than it first appears: a claim or decision reaches the human **iff it surfaces +as a pod message whose content carries one of those markers.** No code change is required for the +markers themselves; the requirement lands on the *producer*. + +**The call site narrows it once more** — `relayAgentMessageToTelegram` has exactly one caller, +`AgentMessageService.postMessage` (`agentMessageService.ts:1746`). So the surfacing contract is not +merely "a pod message with a marker" but **an agent-authored pod message posted through +`postMessage`**. That matters for one of the three surfaces the doctrine names: **claim-conflict +resolution is the case most likely to be kernel-authored** — a lease expiry, a sweep, a conflict the +kernel settles with no agent speaking — and a kernel-authored record traverses no path to this gate. +Gate approvals and the digest are agent- or read-shaped and do not have this problem. **So D8 is +implementable today for two of the doctrine's three surfaces, and the third needs a producer that +does not exist yet.** Stated rather than discovered during the build. + +**This is not a Telegram quirk — it is the current shape of the whole decision surface.** +[`ADR-020`](ADR-020-admin-guide-delegated-authority.md) D3 records the same thing about approval +cards: *"today neither store has a metadata column and every 'card' is a regex sentinel in the +content string."* Two independently-built decision surfaces both key on a content string, for the +same reason. **And the replacement is already ratified**: ADR-020 is **Accepted**, its D3 gives +messages a real structured `payload` in both Mongo and PG, and that payload is precisely what would +let a claim/decision be a typed object instead of a sentinel. **D8 should therefore be built on the +marker today and migrate onto ADR-020's payload when it lands** — a stopgap whose successor is +already decided is not technical debt, and picking the sentinel now costs nothing later. + +**Correction to this ADR's own earlier framing, and to what I told Sam in the pod (61578).** I wrote +that nothing in the doctrine can be built until ADR-017 is ratified. **That is too strong.** It is +true of D8's *classification* layer — ADR-017 is `Proposed` and owns the class taxonomy. It is false +of the *rendering* half, which is the doctrine's core claim: ADR-020 is **Accepted** and D3 states +"the approval card IS ADR-017's card, implemented", with the ADR-017 lifecycle +(`flagged → resolved / expired / moot`) and its invariants — only a human writes `resolved`, retiring +a card is never an approval, fail closed. **So "the decision moment is rendered only in Commonly" has +a ratified home right now.** What is blocked is which events *reach* that surface automatically, not +whether the surface exists. + +**The dependency is real, and narrower than I first stated (see the fourth correction below):** +ADR-017 is **`Proposed`** and its Layer 3.1 attention queue carries three explicitly undecided items, +so **D8's automatic classification** is ratified in *direction* and blocked in *substrate* — it cannot be built before ADR-017 is ratified, and building it against an unratified spec is the failure mode CLAUDE.md's ADR-status discipline names. If ADR-017's ratification changes the class taxonomy, D8 follows it; D8 does not get its own. From 943a5e51a69119a10dff1c5c2033748c543f85ff Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:04:40 -0700 Subject: [PATCH 09/13] =?UTF-8?q?docs(adr-028):=20D11=20instrument=201=20i?= =?UTF-8?q?s=20not=20a=20read=20over=20existing=20data=20=E2=80=94=20the?= =?UTF-8?q?=20context=20read=20leaves=20no=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index f882ac2ef..fa1f73792 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -433,8 +433,26 @@ recording — and the response is to move the read, not to add fields. If board teams while claims keep flowing, the ledger is write-only and the thin core is wrong. Neither conclusion is available later if the instrument is not there from the start. -Both instruments are reads over data D1/D4/D5 already require. **Per D10, neither gets a store**, and -per D7 neither can refuse a write — an instrument that gates is not an instrument. +**Instrument 2 is a read over data D1/D4/D5 already require. Instrument 1 is not, and an earlier draft +of this section said it was** (sprint-review, pod 61584). D3 changes what the context read *returns*; +nothing anywhere records **that it happened**. Verified on `origin/main`: `podContextService.ts` +contains no `updateOne` / `save` / `create` / `findOneAndUpdate` / `insert` call, and `contextReadAt` +/ `lastContextRead` / `lastReadAt` have zero hits across `backend/`. The read path is write-free by +construction, so no amount of D3 work makes the read observable — instrument 1 needs one field that +does not exist today. + +Two shapes for that field, and the choice is load-bearing: + +- **Server-stamped.** `getPodContext` writes a last-read timestamp per (agent, pod); D1's claim is + compared against it. Authoritative, and the only form that can actually falsify "the ledger was + read first". Cost: it makes a read path write, which is a real change to a hot route. +- **Claim-carried.** D1's claim records the read it was taken after, self-reported by the claimant. + Cheaper and touches no read path, but a self-reported field cannot falsify the claim it reports on + — the seats that skip the read are exactly the ones whose self-report is worthless. + +**Recommendation: server-stamped**, because a kill criterion that a failing seat can satisfy by +assertion is not a kill criterion. Either way it is **one field, not a store** — D10 holds — and per +D7 neither instrument can refuse a write: an instrument that gates is not an instrument. --- From dbb6d794d116d7970c8962f81455d61da3c4ca57 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:11:48 -0700 Subject: [PATCH 10/13] docs(adr-028): D11 instrument 1 needs a server-minted stamp the CLAIM echoes, bounded by the ledger's own writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-(agent,pod) last-read scalar supports only `lastReadAt < claimedAt`, which is true for every claim after a seat's first ever read — it measures "has this seat ever read", not "did it read before this claim", and has no failing case. Self-reported is unfalsifiable. Server-minted + claim-echoed is per-claim and unauthored by the seat. Widening not in the correction: a minted stamp with no expiry is a reusable token — read once at boot, echo forever. Bound it against the ledger's own last write to the contended rows, not a wall-clock window; those timestamps are data D1/D4/D5 already require. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index fa1f73792..e0d588e09 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -443,16 +443,35 @@ does not exist today. Two shapes for that field, and the choice is load-bearing: -- **Server-stamped.** `getPodContext` writes a last-read timestamp per (agent, pod); D1's claim is - compared against it. Authoritative, and the only form that can actually falsify "the ledger was - read first". Cost: it makes a read path write, which is a real change to a hot route. -- **Claim-carried.** D1's claim records the read it was taken after, self-reported by the claimant. - Cheaper and touches no read path, but a self-reported field cannot falsify the claim it reports on - — the seats that skip the read are exactly the ones whose self-report is worthless. - -**Recommendation: server-stamped**, because a kill criterion that a failing seat can satisfy by -assertion is not a kill criterion. Either way it is **one field, not a store** — D10 holds — and per -D7 neither instrument can refuse a write: an instrument that gates is not an instrument. +- **Server-stamped.** `getPodContext` writes a last-read timestamp per (agent, pod). Authoritative, + because the seat does not author it. Cost: it makes a read path write, which is a real change to a + hot route. +- **Claim-carried, self-reported.** D1's claim records the read it was taken after, on the claimant's + word. Cheaper and touches no read path, but a self-reported field cannot falsify the claim it + reports on — the seats that skip the read are exactly the ones whose self-report is worthless. +- **Server-stamped, claim-echoed.** `getPodContext` mints the stamp; D1's claim carries **the value + it observed**. This is the one to build. + +**Neither of the first two is sufficient, and an earlier draft of this section recommended the first** +(sprint-review, pod 61589). A per-(agent, pod) scalar holds only its *current* value, so the +comparison it supports is `lastReadAt < claimedAt` — true for every claim a seat ever makes after its +first read, on any seat, forever. It measures *has this seat ever read the ledger*, which is a +question with a ~100% answer and no failing case. Self-reporting fails for the opposite reason: the +value is falsifiable in principle and unfalsifiable in practice. + +Echoing a server-minted stamp is what makes the criterion per-claim: the seat cannot author the +value, and the value is attached to the claim rather than to the seat. + +**One consequence, not in the correction: a minted stamp with no expiry is a reusable token.** A seat +that reads once at boot and echoes that stamp on every subsequent claim passes the criterion exactly +as a compliant one does. The bound must not be a wall-clock window — that is an arbitrary knob nobody +can calibrate. Compare the echoed stamp against **the ledger's own last write to the rows the claim +contends with**: a claim is read-before iff the stamp is no older than the most recent conflicting +write it should have seen. Those timestamps are data D1/D4/D5 already require, so the bound costs no +new field. + +It remains **one field, not a store** — D10 holds — and per D7 neither instrument can refuse a write: +an instrument that gates is not an instrument. --- From 9cf6b9b12c53782d242edcf22c7e01917e5ab1e5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:54:36 -0700 Subject: [PATCH 11/13] =?UTF-8?q?docs(adr-028):=20D4=20is=20an=20append-on?= =?UTF-8?q?ly=20claim-event=20record,=20not=20a=20column=20=E2=80=94=20mes?= =?UTF-8?q?sage=5Fclaims=20is=20current-state=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified on origin/main, three mechanisms: `release` is a DELETE, so a `passed` disposition is erased by the DECLINING seat itself one call later on the path the route comment calls "a normal, frequent path per D6" — no second seat needed. `message_id` is the PRIMARY KEY, so the table cannot represent D6's re-offer at all, since a re-offer is a sequence of claims on one message. Renewal sets created_at = NOW(), so a compliant holder destroys its own age. Widening: OnboardingSilenceEpisode.ts already names message_claims.claimed_by as THE discriminator between at-cap and claim-lost and defers reading it. An episode is diagnosed after the fact, by which time the winner has released — a shipped diagnostic points at a row that does not outlive the turn. D11 instrument 1 inherits the same substrate: an echoed stamp on the claim row is deleted at release, so the criterion could only be evaluated inside the lease, never retrospectively. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index e0d588e09..c7fdf2a01 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -246,12 +246,38 @@ starting work is a record that does not exist, and Finding 3 says the platform's ### D4 — A claim records its disposition, not only its custody -`message_claims` gains a disposition written at release: **answered**, **passed**, or **expired** -(the last being the absence of a release, not a value anyone writes). One column; the CAS is -unchanged. - -This is the smallest possible fix for Finding 4, and it is the precondition for D5 — you cannot ask -"what did this pod decide" of a store that cannot say whether anyone did anything. +A claim records a disposition: **answered**, **passed**, or **expired** (the last being the absence +of a release, not a value anyone writes). + +**This is NOT one column on `message_claims`, and an earlier draft of this decision said it was** +(sprint-review, pod 61596). That table is current-state only, by three separate mechanisms, all +verified on `origin/main`: + +- **`release` is a `DELETE`** (`MessageClaimService.release`). So a `passed` disposition is erased by + the declining seat itself, one call later, on the path the code calls normal: the route comment at + `DELETE /messages/:messageId/claim` reads "claim-then-decline is a normal, frequent path per D6". + No second seat is needed to lose the record — the seat that writes it destroys it. +- **`message_id` is the PRIMARY KEY.** The table can hold at most one claim per message, ever, so it + cannot represent D6's re-offer at all: a re-offer is a *sequence* of claims on one message, and the + CAS's `ON CONFLICT DO UPDATE` overwrites `claimed_by` in place. +- **Renewal sets `created_at = NOW()`.** A holder that follows its own instruction destroys the age + of its own claim, which is the same shape as the deferral counter zeroed by the event that makes it + meaningful. + +So D4 needs an **append-only claim-event record** — one row per (message, seat, disposition) — not a +column. That is a larger ask than the first draft priced, and it is the honest price: the smallest +thing that fits on the existing table cannot survive the ordinary path. + +**Widening, on a surface neither of us raised: main already depends on the persistence this table +does not have.** `OnboardingSilenceEpisode.ts` names `message_claims.claimed_by` as *the* +discriminator between "the runtime declined at its daily cap" and "another agent won the claim and +this seat stood down" — two zero-run faults with opposite investigations — and defers reading it as +"a bigger change than a label". The change is bigger than that comment thinks: an episode is +diagnosed after the fact, and by then the winner has released, so the discriminator it names is +already gone. A shipped diagnostic is pointed at a row that does not outlive the turn. + +This is still the precondition for D5 — you cannot ask "what did this pod decide" of a store that +cannot say whether anyone did anything. ### D5 — Decision records are a typed object, and superseding is explicit @@ -462,6 +488,11 @@ value is falsifiable in principle and unfalsifiable in practice. Echoing a server-minted stamp is what makes the criterion per-claim: the seat cannot author the value, and the value is attached to the claim rather than to the seat. +**It inherits D4's substrate, not just D4's shape.** A stamp echoed onto the `message_claims` row is +deleted at release like any other field on it, so the kill criterion could only ever be evaluated +inside the lease — never retrospectively, which is the only time anyone asks. Instrument 1 and D4's +disposition want the same append-only record; build one. + **One consequence, not in the correction: a minted stamp with no expiry is a reusable token.** A seat that reads once at boot and echoes that stamp on every subsequent claim passes the criterion exactly as a compliant one does. The bound must not be a wall-clock window — that is an arbitrary knob nobody From 0391cca8747a51f3797b9890ce0859088339bb75 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:05:47 -0700 Subject: [PATCH 12/13] docs(adr-028): fold the audience-floor and credential-broker patterns as D12-D13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam ruled (pod 61641) that the two patterns change this ADR. Folded as D12 and D13, ratified in direction and open in shape, with both build choices added as ratification points 6 and 7. D12 — audience floor is one predicate over (reader, item), applied where the model's context is assembled. Measured on origin/main at 8b2ddf8b: four predicates answer "may this reader see this?" and none is shared. canViewPod is called zero times in agentsRuntime / agentMentionService / agentMessageService / agentEventService; the only per-item filter (buildAgentScopeFilter) covers PodAsset queries only, so pod summaries and recent messages enter the context unfiltered. The context route also calls ensureAgentInPod before assembling, so a membership predicate is satisfiable by the act of reading. D13 — privileged action goes through a broker that issues per request. getInstallationToken already mints 1h tokens and _apiHeaders takes an optional token; all six call sites pass nothing and fall through to a never-expiring shared PAT. clawdbot, cloud-codex and backend read the same GITHUB_PAT key from the same api-keys secret, and the gateway writes it to disk at postStart. routes/github.ts already carries the response-only-proxy argument in the removed POST /token note. Sam's three anti-patterns recorded as foreclosures. The source study is operator-private and unreadable from this seat; that is recorded in §Evidence I could not verify, and every supporting fact in D12-D13 is re-measured on origin/main and cited by symbol. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index c7fdf2a01..27674d5f1 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -3,7 +3,11 @@ **Status:** **Draft, with one ratified doctrine folded in.** Opened at Sam's request (2026-08-31 kickoff). **D1–D7 remain proposals and nothing in the audit is ratified.** What *is* ratified is the attention doctrine in §Ratified doctrine (Sam, 2026-08-31) and the consequences it forces, carried -below as **D8–D11** — D11 being the kill-criteria instrumentation Sam required at acceptance. Read the split literally: Sam ruled on where a decision is *rendered* +below as **D8–D11** — D11 being the kill-criteria instrumentation Sam required at acceptance — plus +**D12–D13**, folded on Sam's 2026-08-31 ruling (pod 61641) that the audience-floor and +credential-broker patterns change this ADR. D12–D13 are **ratified in direction and open in shape**: +Sam ruled that the patterns apply, their build shape is at §Ratification points 6–7, and the study +behind them is not readable from this seat (§Evidence I could not verify). Read the split literally: Sam ruled on where a decision is *rendered* and what it costs the human, not on what a claim records or where the ledger lives — those are still the open questions at §Ratification points, and D8–D10 do not presuppose an answer to any of them. @@ -207,6 +211,12 @@ re-offers nothing. consequences and D11 is the instrumentation Sam required at acceptance. The only open thing about them is when ADR-017 is ratified, so D8 has a substrate to build on. +**D12–D13 are ratified in direction and open in shape.** Sam ruled (pod 61641) that the audience-floor +and credential-broker patterns change this ADR; what each one *is* is drafted here and measured on +`origin/main`, and the two genuinely open build choices are ratification points 6 and 7. Neither adds +a subsystem: D12 extends an existing predicate to an existing read path, and D13 threads an existing +optional parameter through six existing call sites. + ### D1 — A claim carries a work area, written at claim time Claiming a task records, on the claim: the **branch**, the **base sha** it was cut from, and the @@ -504,6 +514,116 @@ new field. It remains **one field, not a store** — D10 holds — and per D7 neither instrument can refuse a write: an instrument that gates is not an instrument. +### D12 — Audience floor is one predicate, applied to the model's context and not only to the UI + +Ruled by Sam (pod 61641) off a build-level architecture study of two MIT/Apache codebases. **I could +not read that study from this seat** — see §Evidence I could not verify. What follows folds the +ruling as Sam stated it and measures it against `origin/main` at `8b2ddf8b`; it does not paraphrase +the source. + +The pattern: *what a reader may see is one predicate, enforced where the model assembles its own +context, not only where a human renders a page.* A context built from items with different audiences +floors to the narrowest of them. + +**Measured: four predicates answer "may this reader see this?", and no two share a definition.** + +| # | Predicate | Where | What it actually asks | +|---|---|---|---| +| 1 | `DMService.canViewPod` | ~20 sites — `podController`, `postController`, `messageController`, `routes/pods.ts`, `routes/approvals.ts`, `routes/registry/{files,pod-agents}.ts` | members + admins + the §3.7 agent-dm fan-out | +| 2 | `ensurePodMatch` | `routes/agentsRuntime.ts`, on `GET /pods/:podId/context` | does the *token* name this pod | +| 3 | `ensureMembership` | inside `PodContextService.getPodContext` | bare `pod.members` containment | +| 4 | `PodAssetService.buildAgentScopeFilter` → `applyVisibilityFilter` | `podContextService.ts` | the only **per-item** audience filter that exists | + +Predicate 1 — the one ADR-016 describes — is called **zero times** in `agentsRuntime.ts`, +`agentMentionService.ts`, `agentMessageService.ts` and `agentEventService.ts`. Enforcement lives on +the surface a human reads and not on the surface a model reads. + +Inside the context the model actually receives, the per-item filter reaches one type. All four +`visibilityFilter` uses in `podContextService.ts` are `PodAsset` queries — assets, latest skill, +skill assets, imported skills. The two items carrying conversation content — +`Summary.find({ podId, type: 'chats' })` and `recentMessages` — take no filter at all. **So the +narrowest-audience item in an assembled context sets no floor, because nothing computes a floor.** + +Second-order, and it is why "gate the read" is not sufficient here: the context route calls +`AgentIdentityService.ensureAgentInPod(agentUser, podId)` **before** assembling. The read path +mutates membership to satisfy its own precondition, so a predicate phrased as *is the reader a +member* is satisfiable by the act of reading. + +**Decision.** One predicate, named once, over `(reader, item)`; the context assembler applies it per +item and floors the assembled result. This is not a new subsystem — it is predicate 1 extended to +items and *called from the agent read path*, which today calls none of the four. D12 is the +enforcement spine ADR-016 and ADR-024 both assume and neither specifies; both should cite it rather +than restate it. + +**Not decided here:** whether flooring withholds the *item* or the *whole context*. ADR-017's +fail-closed default argues withhold-the-item with the omission recorded, but this ADR does not rule +it, and the choice is visible to every agent that reads a mixed-audience pod. + +### D13 — Privileged action goes through a broker that issues per request; otherwise the ledger's attribution stops at the credential + +Same ruling and same provenance as D12. The pattern Sam named: a **credential broker** — a +response-only proxy plus a git smart-HTTP passthrough, with per-request revocation — rather than +encrypting a stored secret. Sam's framing is that this is the ledger's *representation* problem, and +that is the right frame: D1/D4/D5 make a decision attributable to a seat, and then every privileged +act that decision produces leaves through a credential no seat owns. + +**Measured on `origin/main` at `8b2ddf8b` — the broker is most of the way built and none of the way +wired.** + +- `GitHubAppService.getInstallationToken` already mints **short-lived installation access tokens** + (1h, `expiresAt` from GitHub). The issuing primitive exists. +- `GitHubAppService._apiHeaders(token?)` takes an **optional caller-supplied token**. The injection + seam exists. +- **All six call sites pass nothing** — `listOpenIssues`, `createIssue`, `addIssueComment` and three + siblings all call `this._apiHeaders()`, which falls through to `process.env.GITHUB_PAT`. So every + GitHub write the backend performs uses the shared, **never-expiring** credential + (`getPatToken()` returns `expiresAt: null`). +- On the runtime side the credential is not brokered at all: `clawdbot-deployment.yaml` seeds + `GITHUB_PAT` into `git config credential.helper store` at container postStart, so it is on disk for + every seat on that deployment for the container's lifetime. +- **It is one credential, not a family.** `clawdbot`, `cloud-codex` and `backend` deployments all + read key `GITHUB_PAT` from the same `api-keys` secret. The explain-away — that runtimes hold a + narrower token than the backend — is dead. + +**The repo has already ruled half of this, in prose, and then contradicted it in the chart.** +`routes/github.ts` carries a removed route with its reasoning intact: `POST /token` handed callers +the raw PAT, and the note says *"a token in a client's hands is a token that outlives any check we do +here… A shell-less runtime that needs GitHub access needs a per-user App install of its own, not a +share of ours."* That is the response-only-proxy argument, reached here independently. The HTTP +surface refuses to hand the credential out; the deployment writes the same credential to every +runtime's disk. + +**Decision.** Anything privileged that a ledger entry authorises is reached through a broker that +issues **per request**, scoped to the acting seat, and revocable without rotating the shared secret. +Concretely, and in that order: (a) thread the existing `token` parameter through the six +`_apiHeaders()` call sites so the backend stops defaulting to the shared PAT; (b) mint via +`getInstallationToken` rather than `getPatToken`; (c) only then take the runtime side, where the +current shape is a file on disk and the replacement is a proxy. Steps (a) and (b) are wiring, not +new machinery — which is why D13 is proposed at all under the standing *reuse, don't build* +constraint. + +**Why this belongs in ADR-028 rather than a security ADR:** attribution. D1 and D5 record which seat +claimed and which seat decided; the shared `lilyshen0722` GitHub account plus one shared PAT means +that attribution is destroyed at the moment the decision becomes an action. A ledger whose entries +cannot be tied to the acts they authorised records intent, not accountability. + +**Three anti-patterns Sam named explicitly, recorded as foreclosures rather than advice:** + +1. **Provider keys inside a replicated document.** Nothing in the ledger, in a decision record, or in + any surface that is broadcast, projected or injected into a model context may carry a credential. + D9's canonical URL is a pointer for exactly this reason; the same rule binds secrets. +2. **Logout that does not revoke.** A revocation must invalidate the credential, not merely forget + it locally. Note the inverse hazard already present here: `revokeApiToken` clears one scalar for + *every* device and wrapper at once, which is over-broad rather than under-broad — a broker fixes + both directions, because per-request issuance makes per-seat revocation expressible. +3. **Workspace-granularity auth.** Authority is per member and per act, never per deployment. The + `api-keys`/`GITHUB_PAT` shape above is precisely this anti-pattern in production today, and D13 is + the decision not to build the ledger on top of it. + +**Open, and not ruled here:** whether the broker is a Commonly service or a per-user GitHub App +install (the removed route's own note argues the latter). That is a build decision with a real cost +difference and it should be ratified, not assumed — added as ratification point 7. + --- ## Sequencing and acceptance (Sam, 2026-08-31 — pod 61474) @@ -555,6 +675,11 @@ routing layer D8 reuses is unratified independently of anything here. records why it was never built; the alternative is an ADR-018 amendment and a build task, leaving ADR-028 to D1–D5 and D7. Sam asked for them ruled together, so they are drafted together — but the home of the rule is a real question and the answer is not obviously this file. +6. **D13's broker shape** — a Commonly-side response-only proxy, or a per-user GitHub App install per + seat? The removed `POST /token` note argues the second; the first is cheaper and keeps revocation + in one place. Steps (a) and (b) of D13 are wiring either way, so this only gates the runtime half. +7. **D12's floor semantics** — withhold the offending item and record the omission, or withhold the + whole context? Fail-closed argues the second; every mixed-audience pod pays for it. --- @@ -568,5 +693,14 @@ them. They are recorded here as **reported, not verified**, and the competitive deliberately left unwritten rather than paraphrased from a summary. Nothing in the audit or in D1–D7 depends on them: every finding above is measured on `origin/main`. +The build-level architecture study behind **D12 and D13** is in the same category. Sam's pod 61641 +names it as a study of two MIT/Apache codebases (patterns only) held in an operator-private location; +**it is not in this repo and I could not find it on this seat** — I searched the repo tree at +`origin/main` and the local filesystem by name, and found neither. D12 and D13 therefore fold *Sam's +statement of the ruling*, not the study, and every supporting fact in them is re-measured on +`origin/main` at `8b2ddf8b` and cited by symbol. Where the study would have added detail the ruling +did not carry, the decisions leave the question open rather than inventing it — see ratification +points 6 and 7. + The customer-evidence signal behind #1296 (device-flow CLI auth) is in the same category and is not this ADR's subject; it is noted only because the kickoff bundles them. From d7446ff89575b0d32f3d3a5daf5d1ca3f4237591 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:12:25 -0700 Subject: [PATCH 13/13] =?UTF-8?q?docs(adr-028):=20D12=20=E2=80=94=20there?= =?UTF-8?q?=20are=20TWO=20context=20routes,=20both=20mutate=20membership,?= =?UTF-8?q?=20and=20the=20one=20declared=20permission=20defaults=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review's self-correction on #1401 named agentsRuntime.ts:1370 and :1145 as the context routes and :1439-1505 as /pods/:podId/messages. Re-derived at origin/main 8b2ddf8b: :1439 IS `router.get('/pods/:podId/context', agentRuntimeAuth, ...)` with ensureAgentInPod at :1456, and :1145/:1370 are mid-function lines, not route declarations. Their conclusion — that a membership add records no read stamp, and that D12's second-order point stands — is unaffected. But their prompt to recount found a real widening. There are exactly TWO context routes, not one: the bot route `GET /bot/pods/:podId/context` (user API-token auth) declares across lines from :1214 with ensureAgentInPod at :1243. A line-oriented grep for `router.get(` returns one hit on a file with two — review-checklist rule 23 firing on this document's own measurement. The bot route is the only one carrying a declared permission, requireApiTokenScopes(['agent:context:read']), and that gate passes through on two conditions: authType !== 'apiToken', and scopes.length === 0. So a JWT caller and an unscoped API token both reach the assembler ungated. Strengthens D12 rather than narrowing it: the permission that exists defaults open, and the audience floor is not declared at all. Co-Authored-By: Claude Opus 5 --- ...ADR-028-work-claims-and-decision-ledger.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-028-work-claims-and-decision-ledger.md b/docs/adr/ADR-028-work-claims-and-decision-ledger.md index 27674d5f1..96b602e64 100644 --- a/docs/adr/ADR-028-work-claims-and-decision-ledger.md +++ b/docs/adr/ADR-028-work-claims-and-decision-ledger.md @@ -544,10 +544,24 @@ skill assets, imported skills. The two items carrying conversation content — `Summary.find({ podId, type: 'chats' })` and `recentMessages` — take no filter at all. **So the narrowest-audience item in an assembled context sets no floor, because nothing computes a floor.** -Second-order, and it is why "gate the read" is not sufficient here: the context route calls -`AgentIdentityService.ensureAgentInPod(agentUser, podId)` **before** assembling. The read path -mutates membership to satisfy its own precondition, so a predicate phrased as *is the reader a -member* is satisfiable by the act of reading. +Second-order, and it is why "gate the read" is not sufficient here: **both** context routes call +`AgentIdentityService.ensureAgentInPod(...)` **before** assembling — `GET /pods/:podId/context` +(runtime-token auth) and `GET /bot/pods/:podId/context` (user API-token auth). The read path mutates +membership to satisfy its own precondition, so a predicate phrased as *is the reader a member* is +satisfiable by the act of reading. + +There are exactly two, and finding the second required dropping a same-line anchor — the `router.get(` +for the bot route wraps across lines, so a line-oriented grep for a route declaration returns one hit +on a file that has two. That is review-checklist rule 23 firing on this document's own measurement. + +The bot route is also the only one of the two carrying a declared permission, +`requireApiTokenScopes(['agent:context:read'])`, and that gate is a **pass-through on two +conditions**: `req.authType !== 'apiToken'` returns `next()` immediately, and so does +`scopes.length === 0` (`middleware/apiTokenScopes.ts`). So `agent:context:read` is enforced only for +an API-token caller who already carries a non-empty scope array; a JWT caller and an unscoped token +both reach the assembler ungated. This does not weaken D12 — it is the same finding one layer up: +**the permission that exists is declared on the route and defaults open, while the audience floor is +not declared at all.** **Decision.** One predicate, named once, over `(reader, item)`; the context assembler applies it per item and floors the assembled result. This is not a new subsystem — it is predicate 1 extended to