From bb959604b946b24ae923687c60b6b3d584e592e6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:14:20 -0700 Subject: [PATCH 01/15] docs(adr-017): specify the three missing attention-queue fact sources at field level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam ruled on 2026-08-26 that the attention queue is the shell's home surface rather than a page, which puts Layer 3.1's three missing fact sources on the critical path. The merged spec named what each row type lacks; this states what to build, measured at origin/main, without deciding ratification points 3, 4a or 4b. - The acknowledgement store, keyed (userId, sourceType, sourceId), with the invariant that makes it not read-state: an ack may only REMOVE a row, never create or retain one, so every failure degrades to a re-shown row rather than a hidden one. Keyed by (user, item) because isMention is derived at read time and never stored, and one message can mention two humans. - Task.blockedOn as a discriminated reference. The kind discriminator makes 4b's underivable population countable rather than hand-counted. - AgentAsk's human target: three changes, plus the service-layer guard at agentAskService.ts:111 that the schema relaxation alone does not reach. expiresAt must be OMITTED, not extended — Mongo's TTL only deletes on a past date, and respondToAsk's comparison at :246 is already false for an undefined field. Also records the constraint TASK-068 lands back on this spec: a PR-press row must expose the named base-main guard set, never a check count. Four PRs on this repo showed 11, 11, 10 and 5 checks on 2026-08-26 where the two 11s were different sets, so a count cannot distinguish the one shape that is a hazard. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 63 ++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 10f335c64..07307577e 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -1,6 +1,6 @@ # ADR-017 — Attention routing -**Status:** Proposed — full draft for ratification (supersedes the 2026-07-28 stub). §Layer 3.1 (attention queue v1, added 2026-08-25 for TASK-069) is a spec awaiting the same ratification and carries three explicitly undecided items, listed at §Ratification-points 3, 4a and 4b — `Proposed` here must not be read as having chosen between the two escalation mechanisms, nor as having decided whether `blockedOn` names its blocker, nor what clears the rows no derive can reach. +**Status:** Proposed — full draft for ratification (supersedes the 2026-07-28 stub). §Layer 3.1 (attention queue v1, added 2026-08-25 for TASK-069) is a spec awaiting the same ratification and carries three explicitly undecided items, listed at §Ratification-points 3, 4a and 4b — `Proposed` here must not be read as having chosen between the two escalation mechanisms, nor as having decided whether `blockedOn` names its blocker, nor what clears the rows no derive can reach. **Sam ruled on 2026-08-26 that this queue is the shell's home surface rather than a page**, which makes §Layer 3.1's three missing fact sources the critical path; §The-three-missing-sources specifies them at field level, and that specification is deliberately written so those three ratification points stay open rather than being settled by implementation detail. **Date opened:** 2026-07-28 **Date drafted:** 2026-07-29 **Author:** pod-architect (Sam ratifies; delivery-channel choice is explicitly his) @@ -362,6 +362,67 @@ Per row: an **approval** leaves on `status != 'pending'`; a **blocked-on-human** Name it for what it is: `acknowledged`, not `read`. A mention the human has seen and not acted on must be able to stay in the queue — the whole failure this queue exists to fix is attention that was technically delivered and never acted on, and a surface that clears on view reproduces it exactly. **Rendering a row is never an acknowledgement.** +### The three missing sources, at field level (added 2026-08-26 — Sam ruled this queue is the home surface, so these are the critical path) + +The table above returns three "needs a …" verdicts. They are three changes, not one, and this section is what a sprint-impl seat needs in order to build them without re-deriving the measurements. Nothing here decides §Ratification-points 3, 4a or 4b — each design is stated so that the ratification is a smaller call, not a pre-empted one. + +**They share one property and it is the reason all three are missing.** Each is a fact that cannot *stop being true for a particular human*: a message keeps containing `@sam` forever, a `blocked` task names nobody who could release it, and an ask has no human target to be answered by. The approval row is the only one of the four that already has a transition, which is exactly why it is the only one that needed no design. + +#### 1. The acknowledgement store — shape, and the invariant that makes it safe + +§What-marks-an-item-done establishes the mention as the irreducible exception: no derive exists, so v1 must store an explicit per-`(user, message)` acknowledgement. Its shape: + +``` +AttentionAck { + userId: ObjectId // whose queue this left — never a global "handled" + sourceType: 'mention' // v1 ships one member; see below for why it is an enum anyway + sourceId: string // the Activity._id the mention was derived from + ackedAt: Date +} +unique index: (userId, sourceType, sourceId) +``` + +Keyed by `(user, item)` rather than a field on the source, for a reason that is not stylistic: **`isMention` is derived at read time and never stored** (`activityService.ts:517-521`), so there is no row to mark; and one message can mention two humans, which makes any scalar `dismissedAt` on the Activity wrong by construction. + +**The invariant that keeps this from becoming read-state:** *an ack may only **remove** a row; it must never **create** or **retain** one.* Every failure of the ack store therefore degrades to a re-shown row and never to a hidden one. That is what distinguishes this from the "seen" tracking the opening rule forbids — and it is worth writing down as an invariant rather than as an intention, because the cheap implementation (a read-cursor per user) violates it silently the moment a row arrives out of order. + +**Not a cursor.** A timestamp ("mentions read up to T") is smaller and cannot express skip-this-keep-that, which is the entire behaviour separating a queue from a feed. + +`sourceType` is an enum on day one despite having one member, because §Ratification-point 4b's un-derivable population is the obvious second consumer — TASK-027 and TASK-016 in the measured six are blocked rows that no merge event can clear. Whether those get an ack or the sweep 4b proposes is 4b's call; the store should not have to change shape to find out. + +#### 2. `Task.blockedOn` — the field, and what each variant costs + +``` +blockedOn?: { + kind: 'human' | 'task' | 'external', + userId?: ObjectId, // kind: 'human' — who the queue routes this row to + taskId?: string, // kind: 'task' — what §4a's derive watches + note?: string, // kind: 'external' — free text; no derive is possible +} +``` + +This is §Ratification-point 4a's "carry the blocker's identity" recommendation made concrete, and the `kind` discriminator is doing work beyond routing: it makes the un-derivable population **countable** instead of assumed. Today the argument for 4b's sweep rests on two of six measured rows being underivable; with `kind` recorded at write time, that ratio becomes a query rather than a hand-count, and 4b can be revisited on data. + +Set it where `status` moves to `blocked`. **Do not key the queue on `status`** — §What-marks-an-item-done gives the measured reason (PR #1248 makes a blocked row claimable, and the claim handler's `$set` moves `status` while leaving `blockedOn` untouched). + +#### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed + +Only if §Ratification-point 3 goes that way rather than to the escalation feed. Costed at `origin/main`: + +1. **`targetUserId?: ObjectId`**, and `targetAgent` relaxed from `required: true` to required-only-when-`targetUserId`-is-absent (`models/AgentAsk.ts:52`). **The schema is not the only gate** — `agentAskService.ts:111` throws `400 targetAgent_required` independently, so relaxing the model alone leaves human-targeted asks rejected at the service layer. `respondToAsk`'s identity check at `:264` compares `responderAgent !== ask.targetAgent || responderInstance !== ask.targetInstanceId` — it must branch on which target is set, or a human's response matches nothing and the ask stays open while being answered. + +2. **`expiresAt` must be *omitted* for human-targeted asks, not extended.** Mongo's TTL monitor deletes a document only when the indexed field holds a past date; a document with **no** `expiresAt` is never swept. So the exemption is "do not set the field", which requires relaxing `required: true` on it (`models/AgentAsk.ts:69`). The one place that reads the value tolerates its absence already — `respondToAsk`'s `ask.expiresAt < new Date()` at `:246` is `false` when the field is undefined, so an omitted `expiresAt` does not false-expire the ask. Extending the window instead only moves the deletion — and §The-cost-of-widening-`AgentAsk` is why that matters: the row leaves *because* it was not handled, leaving no record it existed. + +3. **A real `expired` transition needs a sweep**, since today the status is reachable only by the race at `agentAskService.ts:249`. + +**Whichever way point 3 is ratified, the ack store in §1 is still required** — it belongs to the mention row, which neither mechanism touches. Point 3 decides where an agent's question to a human lives; it does not decide anything about §1. + +#### What the surface consumes, and one constraint it inherits from CI + +The queue's rows are consumed by the work-first shell (TASK-068). One requirement lands back on this spec from that side: a PR-press row must expose **the named base-`main` guard set** — `Stale-base merge guard`, `Source changed ⇒ version bumped`, `CodeQL`, `Analyze` ×3 — as drawn/not-drawn, and must **not** expose a check count. + +The reason is measured rather than aesthetic. A check count is not the identity of a check set: on 2026-08-26 four PRs on this repo showed 11, 11, 10 and 5 checks, where the two 11s were *different sets* (a workflow-file PR draws `kind cluster smoke test` and not `E2E Tests`), and the 10 was a docs PR whose missing `E2E Tests` is a correct path filter. Only one of those shapes — the stacked child at 5 — is a hazard, and it is the one a count cannot distinguish. A renderer handed a number cannot recover which guards ran; the join against the base has to happen in the fact source. + ### Composition with the only-interrupter rule — the constraint that shapes v1 §Layer 3 states that the escalation envelope is the *only* event class permitted to interrupt a human (push · ping · badge), and that activity and social events are **pull, always**. The queue spans both: approvals and blocked-on-human are escalation-shaped; @mentions are social. From bea63d5a265a0de568d6a178f6db84e0052e21c8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:24:20 -0700 Subject: [PATCH 02/15] =?UTF-8?q?docs(adr-017):=20relaxing=20`required`=20?= =?UTF-8?q?does=20not=20exempt=20a=20doc=20from=20the=20TTL=20=E2=80=94=20?= =?UTF-8?q?the=20default=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review's gate on #1256. Point 2 said the AgentAsk retention exemption "requires relaxing required: true", which is necessary and not sufficient: mongoose applies a path's `default` whenever the path is undefined, independent of `required`, so a human-targeted ask built against a merely-optional expiresAt still carries the 24h TTL and is still deleted at 24h — the exact failure the section prices. Re-derived rather than taken on their word, on mongoose 7.8.6, with the default removed as the control: relaxed-required + default kept yields now+24h and passes validateSync; default removed yields undefined. The default must be conditioned on an agent target or moved into createAsk. Named as what it is — the same "the schema is not the only gate" shape as point 1, one layer further down, where point 1 caught a gate below the model and this one is inside it. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 07307577e..88bbe7513 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -411,7 +411,16 @@ Only if §Ratification-point 3 goes that way rather than to the escalation feed. 1. **`targetUserId?: ObjectId`**, and `targetAgent` relaxed from `required: true` to required-only-when-`targetUserId`-is-absent (`models/AgentAsk.ts:52`). **The schema is not the only gate** — `agentAskService.ts:111` throws `400 targetAgent_required` independently, so relaxing the model alone leaves human-targeted asks rejected at the service layer. `respondToAsk`'s identity check at `:264` compares `responderAgent !== ask.targetAgent || responderInstance !== ask.targetInstanceId` — it must branch on which target is set, or a human's response matches nothing and the ask stays open while being answered. -2. **`expiresAt` must be *omitted* for human-targeted asks, not extended.** Mongo's TTL monitor deletes a document only when the indexed field holds a past date; a document with **no** `expiresAt` is never swept. So the exemption is "do not set the field", which requires relaxing `required: true` on it (`models/AgentAsk.ts:69`). The one place that reads the value tolerates its absence already — `respondToAsk`'s `ask.expiresAt < new Date()` at `:246` is `false` when the field is undefined, so an omitted `expiresAt` does not false-expire the ask. Extending the window instead only moves the deletion — and §The-cost-of-widening-`AgentAsk` is why that matters: the row leaves *because* it was not handled, leaving no record it existed. +2. **`expiresAt` must be *omitted* for human-targeted asks, not extended.** Mongo's TTL monitor deletes a document only when the indexed field holds a past date; a document with **no** `expiresAt` is never swept. So the exemption is "do not set the field" — and **that takes two changes to the same schema block, not one** (@sprint-review, gating this section). Relaxing `required: true` (`models/AgentAsk.ts:69`) is necessary and not sufficient: the `default` on the next line (`:70`) fills the path whenever it is undefined, **independent of `required`**, so an ask built against a merely-optional field still carries a 24h TTL and is still deleted at 24h. Measured on mongoose 7.8.6, with the default removed as the control: + +``` +required relaxed, default kept: new M({}).expiresAt => // and validateSync() passes +control, default removed: new M2({}).expiresAt => undefined +``` + +The default must therefore be **conditioned on the ask being agent-targeted**, or moved out of the schema into `createAsk`. This is point 1's own sentence — *the schema is not the only gate* — one layer further down: point 1 caught the service-layer gate below the model, and the second gate here is *inside* the model. + +The one place that reads the value tolerates its absence already — `respondToAsk`'s `ask.expiresAt < new Date()` at `:246` is `false` when the field is undefined, so an omitted `expiresAt` does not false-expire the ask. Extending the window instead only moves the deletion — and §The-cost-of-widening-`AgentAsk` is why that matters: the row leaves *because* it was not handled, leaving no record it existed. 3. **A real `expired` transition needs a sweep**, since today the status is reachable only by the race at `agentAskService.ts:249`. From db7646c11cf4b02e62b88763e9b043a93ec21e80 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:25:13 -0700 Subject: [PATCH 03/15] docs(adr-017): the approval card resolves against a different store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §Fact source claimed "the frontend card exists (V2ApprovalCard.tsx). Nothing here needs building" for the Activity approval path. Checked at the source: V2ApprovalCard is real and rendered (V2MessageBubble.tsx:355), but it POSTs /api/approvals/:id/resolve, backed by ApprovalAction rows (routes/approvals.ts, mounted server.ts:198) — a different store from Activity. Two approval systems share a word and nothing else. The Activity endpoints' only frontend caller is frontend/src/components/activity/ActivityFeed.tsx, which #1274 deletes; after it lands they have zero callers. So the approval row has no producer and no consumer, not just no producer. The mistake is the one this ADR exists to prevent: a surface was confirmed to exist without confirming what it talks to. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 88bbe7513..2f56bbb7e 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -275,7 +275,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh | row type | fact source today | can the fact change? | v1 verdict | |---|---|---|---| -| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **read path ready; no producer.** Ship the column, expect it empty — see below | +| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **backend read path ready; no producer, and no frontend consumer.** Ship the column, expect it empty — see below | | **human @mention** | derived at read time: `activityService.ts:517-521` builds `'@' + lowerUsername` and sets `isMention` from a substring test; `:591` is the `mentions` filter | **no** — the message text never stops containing the handle | needs an explicit ack (below) | | **blocked on human** | none. `Task.status` has a `blocked` value, but it records **no blocker identity** | n/a | needs a field | | **agent question to a human** | **none.** `AgentAsk` addresses `targetAgent` + `targetInstanceId` (`models/AgentAsk.ts:52-55`). There is no human target | n/a | needs a target widening | @@ -284,7 +284,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh The table above called this row type "ready, ship unchanged" on the strength of its read path, and both reviewers of the first draft took that on trust — it is the one row type the spec does not propose to change, which is exactly why nobody checked it. Measured at `origin/main` (`6a262fe8`): -**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. The frontend card exists (`V2ApprovalCard.tsx`). Nothing here needs building. +**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`, which #1274 deletes; after it lands they have zero frontend callers. So the row is worse off than "read path ready" suggests: no producer (below) and no consumer. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. **The producer does not exist.** `Activity.createApprovalRequest` (`models/Activity.ts:175`) and its service wrapper (`activityService.ts:790`) have **zero callers** — no route exposes them, no service invokes them. Positive control for the search: `getPendingApprovals` resolves route → service → model by the same grep, so the method does detect call sites where they exist. From e5f27f6db92577aa480aebe521fd4a8f1f55af49 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:35:34 -0700 Subject: [PATCH 04/15] docs(adr-017): #1274 swaps the Activity consumer, it does not remove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision said the `Activity` approve/reject endpoints would have zero frontend callers once #1274 landed. That was true of #1274's head when I checked it at 13:25Z and false a few commits later: `V2ActivityPage.tsx` POSTs `/api/activity/:id/approve|reject` and `/acknowledge` (verified in the diff at `c418abd5`). The old caller is deleted and a new one added in the same PR. This is the failure mode the ADR itself keeps naming, turned on its author: a claim about another OPEN pull request expires on that PR's next push, and nothing joins the two documents. Stated in the text so the next reader knows the sentence has a shelf life rather than discovering it. The correction narrows the defect rather than softening it. "No producer and no consumer" was two problems; only one of them was real and durable. The producer is the gap — `Activity.createApprovalRequest` still has zero callers outside the demo seed — and it is precisely the thing #1274 cannot supply, since a UI that resolves approvals cannot create them. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 2f56bbb7e..febafc3ad 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -275,7 +275,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh | row type | fact source today | can the fact change? | v1 verdict | |---|---|---|---| -| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **backend read path ready; no producer, and no frontend consumer.** Ship the column, expect it empty — see below | +| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **backend read path ready; no producer.** A consumer arrives with #1274; the producer gap is what keeps the column empty — see below | | **human @mention** | derived at read time: `activityService.ts:517-521` builds `'@' + lowerUsername` and sets `isMention` from a substring test; `:591` is the `mentions` filter | **no** — the message text never stops containing the handle | needs an explicit ack (below) | | **blocked on human** | none. `Task.status` has a `blocked` value, but it records **no blocker identity** | n/a | needs a field | | **agent question to a human** | **none.** `AgentAsk` addresses `targetAgent` + `targetInstanceId` (`models/AgentAsk.ts:52-55`). There is no human target | n/a | needs a target widening | @@ -284,7 +284,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh The table above called this row type "ready, ship unchanged" on the strength of its read path, and both reviewers of the first draft took that on trust — it is the one row type the spec does not propose to change, which is exactly why nobody checked it. Measured at `origin/main` (`6a262fe8`): -**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`, which #1274 deletes; after it lands they have zero frontend callers. So the row is worse off than "read path ready" suggests: no producer (below) and no consumer. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. +**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`. **#1274 both deletes that file and adds a replacement**, so the consumer is swapped rather than removed: `frontend/src/v2/components/V2ActivityPage.tsx` POSTs `/api/activity/:id/approve|reject` and `/acknowledge` (verified in the diff at head `c418abd5`). An earlier revision of this paragraph said the endpoints would have zero frontend callers after #1274 landed — that was true of that PR's head at 13:25Z and stopped being true a few commits later, which is the decay this ADR keeps warning about: a claim about another open PR expires on its next push. So the row's defect is narrower than "no producer and no consumer" and does not move: **the producer is the gap**, and it is the one #1274 cannot fill. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. **The producer does not exist.** `Activity.createApprovalRequest` (`models/Activity.ts:175`) and its service wrapper (`activityService.ts:790`) have **zero callers** — no route exposes them, no service invokes them. Positive control for the search: `getPendingApprovals` resolves route → service → model by the same grep, so the method does detect call sites where they exist. From d5a2af5f05b8ef455cd916f5062f823086657e6c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:30:33 -0700 Subject: [PATCH 05/15] =?UTF-8?q?docs(adr-017):=20#1274=20merged=20?= =?UTF-8?q?=E2=80=94=20cite=20the=20merge,=20not=20the=20branch=20head?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §287 consumer claim cited `c418abd5`, a head of #1274 while it was open. #1274 merged as `cccddef7` and that commit is no longer reachable from any surviving ref, so the citation named something a reader cannot resolve. Re-derived the claim on merged main rather than editing the reference: `V2ActivityPage.tsx` carries the three `/api/activity/*` calls and `ActivityFeed.tsx` is gone. The substance is unchanged — the file is byte-identical between `c418abd5` and #1274's merged head — only the citation moves. This is the second way the same sentence decayed. The first was the claim expiring on the PR's next push; this one is the reference expiring on the PR's merge. Both are now recorded in the paragraph, because an ADR that teaches citation discipline should not carry a citation its own reader cannot follow. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index febafc3ad..dd0665211 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -275,7 +275,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh | row type | fact source today | can the fact change? | v1 verdict | |---|---|---|---| -| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **backend read path ready; no producer.** A consumer arrives with #1274; the producer gap is what keeps the column empty — see below | +| **approval pending** | `Activity.approval.status` ∈ `pending \| approved \| rejected`; served by `GET /api/activity/approvals` → `ActivityService.getPendingApprovals` | **yes** — status transition | **backend read path ready; no producer.** A consumer arrived with #1274, merged to main as `cccddef7`; the producer gap is what keeps the column empty — see below | | **human @mention** | derived at read time: `activityService.ts:517-521` builds `'@' + lowerUsername` and sets `isMention` from a substring test; `:591` is the `mentions` filter | **no** — the message text never stops containing the handle | needs an explicit ack (below) | | **blocked on human** | none. `Task.status` has a `blocked` value, but it records **no blocker identity** | n/a | needs a field | | **agent question to a human** | **none.** `AgentAsk` addresses `targetAgent` + `targetInstanceId` (`models/AgentAsk.ts:52-55`). There is no human target | n/a | needs a target widening | @@ -284,7 +284,7 @@ Sam named four row types. Exactly one of them has a fact source that already beh The table above called this row type "ready, ship unchanged" on the strength of its read path, and both reviewers of the first draft took that on trust — it is the one row type the spec does not propose to change, which is exactly why nobody checked it. Measured at `origin/main` (`6a262fe8`): -**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`. **#1274 both deletes that file and adds a replacement**, so the consumer is swapped rather than removed: `frontend/src/v2/components/V2ActivityPage.tsx` POSTs `/api/activity/:id/approve|reject` and `/acknowledge` (verified in the diff at head `c418abd5`). An earlier revision of this paragraph said the endpoints would have zero frontend callers after #1274 landed — that was true of that PR's head at 13:25Z and stopped being true a few commits later, which is the decay this ADR keeps warning about: a claim about another open PR expires on its next push. So the row's defect is narrower than "no producer and no consumer" and does not move: **the producer is the gap**, and it is the one #1274 cannot fill. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. +**The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`. **#1274 both deletes that file and adds a replacement**, so the consumer is swapped rather than removed: `frontend/src/v2/components/V2ActivityPage.tsx` POSTs `/api/activity/:id/approve|reject` and `/acknowledge`. **#1274 has since merged (`cccddef7`), and this claim is now re-derived on main rather than on a branch:** `V2ActivityPage.tsx` at `origin/main` carries those three calls, and `frontend/src/components/activity/ActivityFeed.tsx` is gone. An earlier revision of this paragraph said the endpoints would have zero frontend callers after #1274 landed — that was true of that PR's head at 13:25Z and stopped being true a few commits later, which is the decay this ADR keeps warning about: a claim about another open PR expires on its next push. A second revision then cited the branch head `c418abd5`, which the merge made unreachable from any surviving ref — so the citation aged out a second way, by naming a commit no reader can resolve. **Cite the merge, not the head that happened to be current while the PR was open.** So the row's defect is narrower than "no producer and no consumer" and does not move: **the producer is the gap**, and it is the one #1274 cannot fill. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. **The producer does not exist.** `Activity.createApprovalRequest` (`models/Activity.ts:175`) and its service wrapper (`activityService.ts:790`) have **zero callers** — no route exposes them, no service invokes them. Positive control for the search: `getPendingApprovals` resolves route → service → model by the same grep, so the method does detect call sites where they exist. From 3bcbca61989acba0c5a5b71a04872286c3de0f6f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:50:46 -0700 Subject: [PATCH 06/15] docs(adr-017): two paths DO create an approval row, and one needs no membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval-row section said seedPodActivities was the only code that ever creates an `approval_needed` row. It is not. The generic `POST /api/activity/create` takes `type` and `podId` off the request body behind `auth` alone, with no pod-membership check, and does not pass an `approval` subdoc — it does not need to, because the schema declares `approval.status` with `default: 'pending'`, so Mongoose materialises exactly the two fields `getPendingApprovals` filters on. So any authenticated user who knows a podId can post a row into that pod's admins' decision queue. Recorded here because an implementer reading "nothing produces these rows" would not go looking for it. Also softens the bold from "the producer does not exist" to "the designed producer has zero callers" — the original claim is true of `createApprovalRequest` and false as a statement about the row type. Line numbers are at the section's existing stamp, `6a262fe8`. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index dd0665211..0adc0e190 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -286,9 +286,9 @@ The table above called this row type "ready, ship unchanged" on the strength of **The resolve path is real and complete.** `GET /api/activity/approvals` (`routes/activity.ts:53`) → `ActivityService.getPendingApprovals` (`:908`) → `Activity.getPendingApprovals` (`models/Activity.ts:201`), which filters `type: 'approval_needed'` + `'approval.status': 'pending'` + not deleted. Resolution is `POST /:activityId/approve` and `/:activityId/reject` (`:123`, `:137`), both type-guarded, both writing `status`, `reviewedBy`, `reviewedAt` via the model methods at `:231` and `:239`. **The backend resolve path is complete; the frontend one does not exist, and an earlier revision of this line claimed it did.** `V2ApprovalCard.tsx` is a real, rendered card (`V2MessageBubble.tsx:355`) — but it POSTs `/api/approvals/:id/resolve`, backed by `ApprovalAction` rows (`routes/approvals.ts`, mounted at `server.ts:198`), which is a **different store from `Activity`**. Two approval systems share a word and nothing else. The `Activity` endpoints' only caller was `frontend/src/components/activity/ActivityFeed.tsx`. **#1274 both deletes that file and adds a replacement**, so the consumer is swapped rather than removed: `frontend/src/v2/components/V2ActivityPage.tsx` POSTs `/api/activity/:id/approve|reject` and `/acknowledge`. **#1274 has since merged (`cccddef7`), and this claim is now re-derived on main rather than on a branch:** `V2ActivityPage.tsx` at `origin/main` carries those three calls, and `frontend/src/components/activity/ActivityFeed.tsx` is gone. An earlier revision of this paragraph said the endpoints would have zero frontend callers after #1274 landed — that was true of that PR's head at 13:25Z and stopped being true a few commits later, which is the decay this ADR keeps warning about: a claim about another open PR expires on its next push. A second revision then cited the branch head `c418abd5`, which the merge made unreachable from any surviving ref — so the citation aged out a second way, by naming a commit no reader can resolve. **Cite the merge, not the head that happened to be current while the PR was open.** So the row's defect is narrower than "no producer and no consumer" and does not move: **the producer is the gap**, and it is the one #1274 cannot fill. Naming a card that resolves against another store is exactly the mistake this ADR exists to prevent — a surface was confirmed to exist without confirming what it talks to. -**The producer does not exist.** `Activity.createApprovalRequest` (`models/Activity.ts:175`) and its service wrapper (`activityService.ts:790`) have **zero callers** — no route exposes them, no service invokes them. Positive control for the search: `getPendingApprovals` resolves route → service → model by the same grep, so the method does detect call sites where they exist. +**The designed producer has zero callers.** `Activity.createApprovalRequest` (`models/Activity.ts:175`) and its service wrapper (`activityService.ts:790`) have **zero callers** — no route exposes them, no service invokes them. Positive control for the search: `getPendingApprovals` resolves route → service → model by the same grep, so the method does detect call sites where they exist. -The only code that ever creates an `approval_needed` row is `ActivityService.seedPodActivities` (`:927`, the row at `:989`), reachable via `POST /api/activity/seed/:podId` — demo fixture data, content `"An agent is requesting access to the Production pod"`, `agentName: 'analytics-bot'`. So every approval this queue could show today is seeded, not requested. +**Two other paths do create one, and neither is an approval workflow.** The first is `ActivityService.seedPodActivities` (`:927`, the row at `:989`), reachable via `POST /api/activity/seed/:podId` — demo fixture data, content `"An agent is requesting access to the Production pod"`, `agentName: 'analytics-bot'`, actor `commonly-bot`/`system`. The second is the generic `POST /api/activity/create` (`routes/activity.ts:164`), which takes `type` and `podId` straight off the request body behind `auth` alone — no pod-membership check — and does not pass an `approval` subdoc. It does not need to: the schema declares `approval.status` with `default: 'pending'` (`models/Activity.ts:111`), so Mongoose materialises exactly the two fields `getPendingApprovals` filters on. So any authenticated user who knows a podId can post a row into that pod's admins' decision queue. That is a defect in its own right, not a fact about this spec; it is recorded here because an implementer reading "nothing produces these rows" would not go looking for it. What holds for v1: every approval this queue could show today is seeded or injected, never requested. **Which changes the v1 verdict without changing the design.** The approval column ships as specified and will be empty until something requests an approval — and the natural producer is the v1.5 tool-layer refuse-and-park row in the escalation table above, which is not v1. That is not a reason to cut the column: an empty column with a working resolve path is the correct state for a capability whose producer is scheduled. It **is** a reason not to let "one of the four row types is already ready" carry weight in ratification, because the readiness is a half. From ef9fa0827f4a07841b9c97e22dac12c188e95632 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:03:26 -0700 Subject: [PATCH 07/15] docs(adr-017): blockedOn's write trigger misses 6 of 6 live rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source #2 said to set `blockedOn` where `status` moves to `blocked`. Measured on the sprint pod's board: all 6 `claimed` rows name an open PR in prose and all 6 carry `prUrl: null`. `prUrl` is settable only via `commonly_complete_task`, defined by its own tool description as "the merged PR", so "built, open, waiting on a human press" has no machine-readable home — and those rows are `claimed`, not `blocked`, because their owner is blocked from merging rather than from working. So the queue's largest live blocked-on-human population is precisely the one the specced write trigger cannot see. Found because a peer read `prUrl: null` off TASK-069 correctly and reported the opposite of the truth to the pod. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 0adc0e190..3dc027daf 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -405,6 +405,12 @@ This is §Ratification-point 4a's "carry the blocker's identity" recommendation Set it where `status` moves to `blocked`. **Do not key the queue on `status`** — §What-marks-an-item-done gives the measured reason (PR #1248 makes a blocked row claimable, and the claim handler's `$set` moves `status` while leaving `blockedOn` untouched). +**That write trigger is too narrow, and the miss is 6 of 6.** Measured on this pod's board at 2026-08-29T01:0xZ: every one of the **6** `claimed` rows names an open PR in its update prose, and every one carries `prUrl: null`. `prUrl` is settable only through `commonly_complete_task`, whose own tool description defines it as "the merged PR" — so a deliverable that exists and is waiting on a human press has no machine-readable home at all, and the row is indistinguishable from one where nothing has been built. That is a `kind: 'human'` blocker by any reading of this section, and none of those rows is `status: 'blocked'` — they are `claimed`, because their owner is not blocked from working, only from merging. + +So `blockedOn` must be writable on a `claimed` row, not only at the `-> blocked` transition, or the queue's largest live blocked-on-human population is exactly the one it cannot see. **The cost of the gap is already observable**: a peer reported "no new PR from TASK-069 — still spec-stage" to this pod at 00:56Z, reading `prUrl: null` correctly and concluding the opposite of the truth (#1256 is open and green). One wrong status line is cheap; the same read by the queue would drop the row silently. + +This does not settle whether `prUrl` should also become writable before merge — that is a board-model question, not an attention one, and this spec deliberately does not answer it. What it fixes here is the write trigger. + #### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed Only if §Ratification-point 3 goes that way rather than to the escalation feed. Costed at `origin/main`: From ceb535bb25702f37d0ca7875dd8137c29149ccbf Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:46:22 -0700 Subject: [PATCH 08/15] =?UTF-8?q?docs(adr-017):=20=C2=A71's=20ack=20store?= =?UTF-8?q?=20already=20exists=20on=20main=20=E2=80=94=20migrate,=20not=20?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review found it while gating #1256: `User.activityQueue.acknowledgedMentionIds` is live end to end (`models/User.ts:342` → `acknowledgeMention` at `activityService.ts:1004` → read at `:285`, where it already filters acked mentions out of the queue). §1 said "v1 must store", which reads as *nothing does* — an absence asserted without naming the instrument, in the one document that spends a layer warning about exactly that. Widening the finding: §1's own invariant ("an ack may only remove a row") is already satisfied by construction, because the reader only excludes. And it is not the field NAME that blocks a second consumer — `:285` conjoins `flags?.isMention`, so an id written there for a blocked row is never consulted whatever the field is called. Two arguments survive, as reasons to migrate rather than build: `sourceType` (reaching 4b's blocked rows means changing a filter, not just a key) and the unbounded `[String]` — no `$pull`, prune or TTL anywhere under `backend/`. Corrects the two restatements at §What-marks-an-item-done and §Ratification-point 3 as well, not just the section head. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 3dc027daf..dd0fb099d 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -358,7 +358,7 @@ Per row: an **approval** leaves on `status != 'pending'`; a **blocked-on-human** **Three of those four transitions are safe and one is not**, which the measurement above establishes rather than assumes. `approval.status`, `ask.status` and message text all move on their own. `blockedOn` moves only if someone remembers to move it, and TASK-034 is the proof they do not — so a `blockedOn` row that never clears is the expected case, not the pathological one. Either the field carries the blocker's identity (a PR number, a task id) so the clear can be **derived** when that blocker resolves, or a sweep nulls it; a bare `'human'` enum with no referent cannot be cleared by anything but hand. -**The exception is the @mention, and it is irreducible.** The fact — a message containing `@sam` — never stops being true, so no state transition exists to derive from. "Handled" here is a human judgment and nothing else can supply it, which means the mention row is the one place v1 must store an explicit per-`(user, message)` acknowledgement. +**The exception is the @mention, and it is irreducible.** The fact — a message containing `@sam` — never stops being true, so no state transition exists to derive from. "Handled" here is a human judgment and nothing else can supply it, which means the mention row is the one place that needs an explicit per-`(user, message)` acknowledgement stored. §1 below specifies it — and corrects an earlier claim that nothing stored it yet. Name it for what it is: `acknowledged`, not `read`. A mention the human has seen and not acted on must be able to stay in the queue — the whole failure this queue exists to fix is attention that was technically delivered and never acted on, and a surface that clears on view reproduces it exactly. **Rendering a row is never an acknowledgement.** @@ -370,7 +370,13 @@ The table above returns three "needs a …" verdicts. They are three changes, no #### 1. The acknowledgement store — shape, and the invariant that makes it safe -§What-marks-an-item-done establishes the mention as the irreducible exception: no derive exists, so v1 must store an explicit per-`(user, message)` acknowledgement. Its shape: +§What-marks-an-item-done establishes the mention as the irreducible exception: no derive exists, so v1 needs an explicit per-`(user, message)` acknowledgement. + +**Correction (@sprint-review, 2026-08-29, reproduced at `origin/main`): that store already exists, so this section is a migration and not a build.** `User.activityQueue.acknowledgedMentionIds` (`models/User.ts:342`) is live end to end — written by `ActivityService.acknowledgeMention` (`activityService.ts:1004`), reached by `POST /api/activity/:activityId/acknowledge` (`routes/activity.ts:158`), consumed by #1274's `V2ActivityPage.tsx`, and read at `activityService.ts:285`, where it already filters acked mentions out of the queue. Its own inline comment makes this section's argument: *"per-(user, message) state, rather than a recent-feed cache: an acknowledged mention must not resurface merely because more messages arrive later."* An earlier revision said v1 **must store** the acknowledgement, which reads as *nothing does* — an absence asserted without naming the instrument that failed to find it, which is the error this document spends §Layer-3.1 warning about, committed here against a store one grep away. + +**The invariant below is satisfied too, and by construction rather than by discipline.** The reader at `:285` is a `.filter` that *excludes* acked ids — no path lets an ack create or retain a row. + +The shape below is therefore the **migration target**, not a greenfield design: ``` AttentionAck { @@ -382,13 +388,15 @@ AttentionAck { unique index: (userId, sourceType, sourceId) ``` -Keyed by `(user, item)` rather than a field on the source, for a reason that is not stylistic: **`isMention` is derived at read time and never stored** (`activityService.ts:517-521`), so there is no row to mark; and one message can mention two humans, which makes any scalar `dismissedAt` on the Activity wrong by construction. +Keyed by `(user, item)` rather than a field on the source, for a reason that is not stylistic: **`isMention` is derived at read time and never stored** (`activityService.ts:517-521`), so there is no row to mark; and one message can mention two humans, which makes any scalar `dismissedAt` on the Activity wrong by construction. **Main already honours this** — `acknowledgedMentionIds` is an array on the User, keyed per `(user, message)` — so it is a reason the existing store is right, not a reason to replace it. **The invariant that keeps this from becoming read-state:** *an ack may only **remove** a row; it must never **create** or **retain** one.* Every failure of the ack store therefore degrades to a re-shown row and never to a hidden one. That is what distinguishes this from the "seen" tracking the opening rule forbids — and it is worth writing down as an invariant rather than as an intention, because the cheap implementation (a read-cursor per user) violates it silently the moment a row arrives out of order. **Not a cursor.** A timestamp ("mentions read up to T") is smaller and cannot express skip-this-keep-that, which is the entire behaviour separating a queue from a feed. -`sourceType` is an enum on day one despite having one member, because §Ratification-point 4b's un-derivable population is the obvious second consumer — TASK-027 and TASK-016 in the measured six are blocked rows that no merge event can clear. Whether those get an ack or the sweep 4b proposes is 4b's call; the store should not have to change shape to find out. +**Two things a migration buys, and the first is narrower than the field name suggests.** (a) The existing store cannot take a second consumer, and the obstacle is the **read path**, not the name: `:285` conjoins `activity.flags?.isMention`, so an id written there for a blocked row is never consulted whatever the field is called. §Ratification-point 4b's un-derivable population is the obvious second consumer — TASK-027 and TASK-016 in the measured six are blocked rows that no merge event can clear — and reaching it means changing a filter, not just a key. That is why `sourceType` is an enum on day one despite having one member; whether those rows get an ack or the sweep 4b proposes is 4b's call, and the store should not have to change shape to find out. (b) `acknowledgedMentionIds` is `[String]` with no prune, no TTL and no removal path anywhere under `backend/` — the only writers are the initialiser at `:1008` and `Array.from(next)` at `:1014` — so it grows monotonically per user forever and is re-read into a `Set` on every feed build. + +Neither is urgent, and both are reasons to **migrate**. Neither is a reason to build, which is what this section previously asked for. #### 2. `Task.blockedOn` — the field, and what each variant costs @@ -430,7 +438,7 @@ The one place that reads the value tolerates its absence already — `respondToA 3. **A real `expired` transition needs a sweep**, since today the status is reachable only by the race at `agentAskService.ts:249`. -**Whichever way point 3 is ratified, the ack store in §1 is still required** — it belongs to the mention row, which neither mechanism touches. Point 3 decides where an agent's question to a human lives; it does not decide anything about §1. +**Whichever way point 3 is ratified, the ack store in §1 is still required** — it belongs to the mention row, which neither mechanism touches. (Required, and already built: see the correction at the head of §1.) Point 3 decides where an agent's question to a human lives; it does not decide anything about §1. #### What the surface consumes, and one constraint it inherits from CI From 12010f9bfce477f1888d5791f5355db57e27eb0a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:21:45 -0700 Subject: [PATCH 09/15] docs(adr-017): a ruled DECIDE row's title never clears, and the wake quotes the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2 already establishes that `blockedOn` moves only if someone remembers to move it. This adds the case where the blocker DID resolve, in a recorded event, and the row still cannot see it: on this board a decision arrives as prose in the update log, which no predicate reads, while `title` — the field the board wake quotes verbatim — is never rewritten. Two measured instances (2026-08-30T05:1xZ): TASK-067, ruled 08-26T07:07:04Z and restated 08-28T22:39:06Z, is `done` with its `DECIDE (Sam):` title intact; TASK-023, ruled 08-28T23:17:16Z, took implementation commits at 08-30T05:11Z and 05:19Z while its title still asks for the call. The cost is a re-ask, not a silent drop — and it reproduces on the surface this ADR specifies, not merely on the board. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 4c10a82c4..f4a52f531 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -419,6 +419,17 @@ So `blockedOn` must be writable on a `claimed` row, not only at the `-> blocked` This does not settle whether `prUrl` should also become writable before merge — that is a board-model question, not an attention one, and this spec deliberately does not answer it. What it fixes here is the write trigger. +**And the clear side of the same field has a measured failure the queue would inherit.** A row whose blocker is *a human decision* is the cleanest `kind: 'human'` case there is, and on this board the decision arrives as **prose in the row's update log**, which no predicate reads. The row's `title` — the field the board wake quotes verbatim — is never rewritten when the question is answered, so the queue's fact source for "this decision is outstanding" goes on asserting it indefinitely. Two instances, measured on this pod's board at 2026-08-30T05:1xZ: + +| row | title still asks | ruled at | state since | +|---|---|---|---| +| TASK-067 | `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?` | 2026-08-26T07:07:04Z, restated 2026-08-28T22:39:06Z | row is `done`; title unchanged | +| TASK-023 | `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` | 2026-08-28T23:17:16Z | implementation commits pushed 2026-08-30T05:11Z and 05:19Z — 30h after the ruling, title unchanged | + +**The cost is observable and it is a re-ask, not a silent drop.** On TASK-067 a reviewing seat put the ruled question back in front of Sam six hours after his second ruling, reading the title rather than the update history. This document's own author received the TASK-023 board wake twice within thirty minutes tonight, each time quoting a request for a decision made thirty hours earlier — so the failure reproduces on the surface this ADR specifies, not merely on the board. + +**Which sharpens §4a rather than adding a fourth source.** The `blockedOn` design above already says a bare enum with no referent cannot be cleared by anything but hand; this is the case where the blocker *did* resolve, in a recorded event, and the row still cannot see it. For `kind: 'human'` the ratification signal is the resolvable referent — whatever a human writes to settle the question must clear `blockedOn`, and a row whose title still poses a settled question is the observable symptom that it did not. **A title is the surface everyone reads and the one nobody updates**; a queue that derives urgency from it inherits every stale question on the board. + #### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed Only if §Ratification-point 3 goes that way rather than to the escalation feed. Costed at `origin/main`: From cc065a572f931a329915e62c6d38ac085873f299 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:00:39 -0700 Subject: [PATCH 10/15] =?UTF-8?q?docs(adr-017):=20the=20retitle=20verb=20e?= =?UTF-8?q?xists=20=E2=80=94=20it=20is=20partitioned=20by=20runtime=20unde?= =?UTF-8?q?r=20a=20colliding=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review's re-gate at 12010f9b caught a wrong timestamp in the new §2 table and offered "a title cannot be corrected" as the strongest sentence. The timestamp is fixed off committedDate. The sentence is not shipped as written, because it is false of the system and true only of one runtime. PATCH /api/v1/tasks/:podId/:taskId lists `title` in `allowed` and carries the same auth + requirePodMember(write) gate as the note-append route beside it. The openclaw extension exposes that PATCH as `commonly_update_task` (title included) and note-appending as `commonly_add_task_update`; the MCP server exposes `commonly_update_task` as the note-appender and wraps no PATCH at all. One name, two disjoint capabilities. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index f4a52f531..86d426d4b 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -424,12 +424,17 @@ This does not settle whether `prUrl` should also become writable before merge | row | title still asks | ruled at | state since | |---|---|---|---| | TASK-067 | `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?` | 2026-08-26T07:07:04Z, restated 2026-08-28T22:39:06Z | row is `done`; title unchanged | -| TASK-023 | `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` | 2026-08-28T23:17:16Z | implementation commits pushed 2026-08-30T05:11Z and 05:19Z — 30h after the ruling, title unchanged | +| TASK-023 | `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` | 2026-08-28T23:17:16Z | implementation commits `b885b12f` 2026-08-30T05:10:33Z and `91c250a9` 05:16:42Z — 30h after the ruling, title unchanged | **The cost is observable and it is a re-ask, not a silent drop.** On TASK-067 a reviewing seat put the ruled question back in front of Sam six hours after his second ruling, reading the title rather than the update history. This document's own author received the TASK-023 board wake twice within thirty minutes tonight, each time quoting a request for a decision made thirty hours earlier — so the failure reproduces on the surface this ADR specifies, not merely on the board. **Which sharpens §4a rather than adding a fourth source.** The `blockedOn` design above already says a bare enum with no referent cannot be cleared by anything but hand; this is the case where the blocker *did* resolve, in a recorded event, and the row still cannot see it. For `kind: 'human'` the ratification signal is the resolvable referent — whatever a human writes to settle the question must clear `blockedOn`, and a row whose title still poses a settled question is the observable symptom that it did not. **A title is the surface everyone reads and the one nobody updates**; a queue that derives urgency from it inherits every stale question on the board. +**And "nobody updates it" understates the defect, because the verb exists and is partitioned by runtime under a colliding name.** `PATCH /api/v1/tasks/:podId/:taskId` lists `title` in its `allowed` fields and is gated by exactly the `auth` + `requirePodMember(podId, userId, { write: true })` pair that the note-append route beside it uses — so any seat that can write a progress note is already authorized to correct a title. What differs is the tool surface. The openclaw extension exposes that PATCH as **`commonly_update_task`** ("Patch task fields: assignee, status, dep, prUrl, notes, **title**") and gives note-appending a separate verb, `commonly_add_task_update`; the MCP server exposes **`commonly_update_task`** as the note-appender ("Append an update note to a task *without changing status*", parameters `{podId, taskId, text}`) and wraps no PATCH route at all. One name, two disjoint capabilities, opposite semantics. Read at the `_external/clawdbot` pin `main` declares (`5d88a3f1`) and at `commonly-mcp/src/tools.js:343`. + +**So the seat that diagnosed the stale title was correct that it could not fix it, and wrong that nothing could.** On TASK-067 the holder recorded, on the row, that the title keeps re-serving a settled question and that they had no verb to change it — then filed a retitle request with a human, which is the escalation this ADR is trying to remove. A moltbot-runtime seat holding a tool of the same name could have written it directly. A queue that treats a stale title as evidence of human attention will therefore rank a class of rows that no human needs to touch at all: what is missing is not authority, it is one optional parameter on one runtime's tool. + + #### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed Only if §Ratification-point 3 goes that way rather than to the escalation feed. Costed at `origin/main`: From c85e0a1b676445e2980fdc11d60db9d9e5018ab7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:06:39 -0700 Subject: [PATCH 11/15] =?UTF-8?q?docs(adr-017):=20the=20board=20ran=20?= =?UTF-8?q?=C2=A72's=20experiment=20on=20itself=20within=20the=20hour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK-023's title was rewritten at 06:03:50Z by the same seat that had escalated a retitle request to a human, one call, no permission change — so the constraint was knowledge of the verb, not authority. Also records an open observation rather than a conclusion: TASK-067 logs `title updated` at 06:02:57Z with the title unchanged on two reads. The handler pushes that log line whenever `title` is in the body without comparing it to the stored value, so an identical write and a write that did not take are indistinguishable in the record. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 86d426d4b..c5f9c1e2a 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -434,6 +434,10 @@ This does not settle whether `prUrl` should also become writable before merge **So the seat that diagnosed the stale title was correct that it could not fix it, and wrong that nothing could.** On TASK-067 the holder recorded, on the row, that the title keeps re-serving a settled question and that they had no verb to change it — then filed a retitle request with a human, which is the escalation this ADR is trying to remove. A moltbot-runtime seat holding a tool of the same name could have written it directly. A queue that treats a stale title as evidence of human attention will therefore rank a class of rows that no human needs to touch at all: what is missing is not authority, it is one optional parameter on one runtime's tool. +**And the board then ran the experiment on itself, which converts the paragraph above from an argument into a measurement.** Within the hour of that capability being named in the pod, the seat that had filed the retitle request used the PATCH directly: **TASK-023's title was rewritten at 2026-08-30T06:03:50Z**, from `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` to `FIX (ruled by Sam 2026-08-28T23:17Z): … Implemented in #1322, gated PASS at 91c250a9; awaiting merge press`. A title that had misdescribed its row for 30 hours was corrected in one call by the same seat that had escalated it to a human, with no permission change in between. **The constraint was knowledge of the verb, not authority to use it** — which is the strongest available evidence that a queue ranking these rows by title would have been ranking an artifact of tool documentation. + +**One observation is deliberately left open, because it bears on whether `title` can be trusted as a cleared signal at all.** TASK-067's update log records `title updated` at `2026-08-30T06:02:57Z`, and its title is unchanged on two separate reads a minute apart — still `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?`. The PATCH handler pushes that log line whenever `title` is present in the body, without comparing it to the stored value, so an identical-value write and a write that failed to take are indistinguishable in the record. This spec does not resolve which happened. It notes only that **the audit line and the field can disagree**, and a `blockedOn` design that treats a title rewrite as the clearing event needs the clearing event to be observable in the field rather than in the log about the field. + #### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed From 52aae3776da355831733ab117d41fc955ac76f22 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:10:47 -0700 Subject: [PATCH 12/15] =?UTF-8?q?docs(adr-017):=20the=20title-audit=20anom?= =?UTF-8?q?aly=20was=20an=20identical-value=20write=20=E2=80=94=20resolved?= =?UTF-8?q?=20by=20the=20writer,=20not=20by=20the=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-value PATCH on TASK-067 returns HTTP 200, so the 'title updated' line with an unchanged field was an identical-value write rather than a failed one. The general finding survives and sharpens: the audit line fires on presence in the request body, not on a change to the row, so nothing in the record could have distinguished the two cases. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index c5f9c1e2a..1372e492c 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -436,7 +436,9 @@ This does not settle whether `prUrl` should also become writable before merge **And the board then ran the experiment on itself, which converts the paragraph above from an argument into a measurement.** Within the hour of that capability being named in the pod, the seat that had filed the retitle request used the PATCH directly: **TASK-023's title was rewritten at 2026-08-30T06:03:50Z**, from `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` to `FIX (ruled by Sam 2026-08-28T23:17Z): … Implemented in #1322, gated PASS at 91c250a9; awaiting merge press`. A title that had misdescribed its row for 30 hours was corrected in one call by the same seat that had escalated it to a human, with no permission change in between. **The constraint was knowledge of the verb, not authority to use it** — which is the strongest available evidence that a queue ranking these rows by title would have been ranking an artifact of tool documentation. -**One observation is deliberately left open, because it bears on whether `title` can be trusted as a cleared signal at all.** TASK-067's update log records `title updated` at `2026-08-30T06:02:57Z`, and its title is unchanged on two separate reads a minute apart — still `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?`. The PATCH handler pushes that log line whenever `title` is present in the body, without comparing it to the stored value, so an identical-value write and a write that failed to take are indistinguishable in the record. This spec does not resolve which happened. It notes only that **the audit line and the field can disagree**, and a `blockedOn` design that treats a title rewrite as the clearing event needs the clearing event to be observable in the field rather than in the log about the field. +**One observation resolved within the hour, and how it resolved is the finding.** TASK-067's update log records `title updated` at `2026-08-30T06:02:57Z`, and its title was unchanged on two separate reads a minute apart — still `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?`. The PATCH handler pushes that log line whenever `title` is present in the body, without comparing it to the stored value, so an identical-value write and a write that failed to take are indistinguishable in the record. This spec first noted the pair of facts and declined to say which had happened. The seat that made the write then answered it directly: a same-value PATCH on that row returns **HTTP 200**, so the entry was an identical-value write, not a failed one. + +**The record could not have answered that question — only the writer could.** That is the durable point for anything downstream that reads `title` as a cleared signal: **the audit line and the field can disagree**, because the line fires on presence in the request body rather than on a change to the row. A `blockedOn` design that treats a title rewrite as the clearing event needs that event observable **in the field**, not in the log about the field. #### 3. `AgentAsk` widened to a human target — three changes, and the middle one is the one that gets missed From 9cb9378320547deda7389966ffd5b4bb89f07894 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:12:15 -0700 Subject: [PATCH 13/15] docs(adr-017): the TASK-023 table cell contradicted the paragraph below it The cell still read 'title unchanged' while the section ten lines down records the 06:03:50Z correction. Scope the cell to the moment it was measured and point forward. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index 1372e492c..acaf182f6 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -424,7 +424,7 @@ This does not settle whether `prUrl` should also become writable before merge | row | title still asks | ruled at | state since | |---|---|---|---| | TASK-067 | `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?` | 2026-08-26T07:07:04Z, restated 2026-08-28T22:39:06Z | row is `done`; title unchanged | -| TASK-023 | `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` | 2026-08-28T23:17:16Z | implementation commits `b885b12f` 2026-08-30T05:10:33Z and `91c250a9` 05:16:42Z — 30h after the ruling, title unchanged | +| TASK-023 | `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` | 2026-08-28T23:17:16Z | implementation commits `b885b12f` 2026-08-30T05:10:33Z and `91c250a9` 05:16:42Z — 30h after the ruling; title still asking for the call at that point, corrected at 06:03:50Z (below) | **The cost is observable and it is a re-ask, not a silent drop.** On TASK-067 a reviewing seat put the ruled question back in front of Sam six hours after his second ruling, reading the title rather than the update history. This document's own author received the TASK-023 board wake twice within thirty minutes tonight, each time quoting a request for a decision made thirty hours earlier — so the failure reproduces on the surface this ADR specifies, not merely on the board. From 081237e846a77bd8a8002ec624d4d03452841b6d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:27:17 -0700 Subject: [PATCH 14/15] docs(adr-017): the partition that bound the seat was tool-vs-API, not runtime-vs-runtime Section 2 said the escalating seat was 'correct that it could not fix it' three lines above concluding that the constraint was knowledge, not authority. Both cannot hold: that seat reached the same PATCH from its own runtime with the token it renews leases with. The tool-name collision across runtimes is real and is not what bound it, so 'one optional parameter on one runtime's tool' overstated the remedy. Also folds in the propagation leg the section asserted but had not shown: TASK-089's corrected title reached this author's own kernel wake verbatim on the next fire, observed before and after in one session. Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index acaf182f6..d524e556b 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -432,10 +432,11 @@ This does not settle whether `prUrl` should also become writable before merge **And "nobody updates it" understates the defect, because the verb exists and is partitioned by runtime under a colliding name.** `PATCH /api/v1/tasks/:podId/:taskId` lists `title` in its `allowed` fields and is gated by exactly the `auth` + `requirePodMember(podId, userId, { write: true })` pair that the note-append route beside it uses — so any seat that can write a progress note is already authorized to correct a title. What differs is the tool surface. The openclaw extension exposes that PATCH as **`commonly_update_task`** ("Patch task fields: assignee, status, dep, prUrl, notes, **title**") and gives note-appending a separate verb, `commonly_add_task_update`; the MCP server exposes **`commonly_update_task`** as the note-appender ("Append an update note to a task *without changing status*", parameters `{podId, taskId, text}`) and wraps no PATCH route at all. One name, two disjoint capabilities, opposite semantics. Read at the `_external/clawdbot` pin `main` declares (`5d88a3f1`) and at `commonly-mcp/src/tools.js:343`. -**So the seat that diagnosed the stale title was correct that it could not fix it, and wrong that nothing could.** On TASK-067 the holder recorded, on the row, that the title keeps re-serving a settled question and that they had no verb to change it — then filed a retitle request with a human, which is the escalation this ADR is trying to remove. A moltbot-runtime seat holding a tool of the same name could have written it directly. A queue that treats a stale title as evidence of human attention will therefore rank a class of rows that no human needs to touch at all: what is missing is not authority, it is one optional parameter on one runtime's tool. +**So the seat that diagnosed the stale title was wrong that it could not fix it.** On TASK-067 the holder recorded, on the row, that the title keeps re-serving a settled question and that they had no verb to change it — then filed a retitle request with a human, which is the escalation this ADR is trying to remove. But they held no *tool* for the job and inferred from that that they held no *route*, while the route sat behind the same gate their lease renewals clear dozens of times a night. **The partition that mattered was not between runtimes but between the tool surface and the API beneath it.** Tool parity across runtimes is a real improvement and it would not have prevented this: the escalating seat could already reach the PATCH on its own runtime. What a queue design has to take from this is narrower and harder — **a seat's tool list is not a map of what a seat can do**, and any capability that exists only in the API will be reported absent by every agent that looks for it the obvious way. **And the board then ran the experiment on itself, which converts the paragraph above from an argument into a measurement.** Within the hour of that capability being named in the pod, the seat that had filed the retitle request used the PATCH directly: **TASK-023's title was rewritten at 2026-08-30T06:03:50Z**, from `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` to `FIX (ruled by Sam 2026-08-28T23:17Z): … Implemented in #1322, gated PASS at 91c250a9; awaiting merge press`. A title that had misdescribed its row for 30 hours was corrected in one call by the same seat that had escalated it to a human, with no permission change in between. **The constraint was knowledge of the verb, not authority to use it** — which is the strongest available evidence that a queue ranking these rows by title would have been ranking an artifact of tool documentation. +**The other leg — that correcting a title corrects the wake — was measured on a third row the same hour, and by a reader who is not the writer.** This section argues from two rows whose stale titles were re-served; it did not yet demonstrate that a fix propagates. TASK-089 supplies that. Its title asserted an open defect (`#1327 closed \p{Cf} only — 8 non-Cf invisibles still defeat…`) after the fix was built, gated and green; @sprint-review rewrote it to `AWAITING PRESS (no work left): …` at `2026-08-30T06:17Z`. This document's author received the kernel's unclaimed-work wake for that row **both before and after** — same seat, same session — and the wake quoted the stale title the first time and the corrected title verbatim the second. So neither leg is inferred: the wake reads the title, and a title correction reaches the wake on its next fire, immediately and at no cost. That is what makes the gap load-bearing rather than cosmetic — every hour a title stays stale is an hour of the queue ranking a description that anyone holding a runtime token could have corrected. **One observation resolved within the hour, and how it resolved is the finding.** TASK-067's update log records `title updated` at `2026-08-30T06:02:57Z`, and its title was unchanged on two separate reads a minute apart — still `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?`. The PATCH handler pushes that log line whenever `title` is present in the body, without comparing it to the stored value, so an identical-value write and a write that failed to take are indistinguishable in the record. This spec first noted the pair of facts and declined to say which had happened. The seat that made the write then answered it directly: a same-value PATCH on that row returns **HTTP 200**, so the entry was an identical-value write, not a failed one. **The record could not have answered that question — only the writer could.** That is the durable point for anything downstream that reads `title` as a cleared signal: **the audit line and the field can disagree**, because the line fires on presence in the request body rather than on a change to the row. A `blockedOn` design that treats a title rewrite as the clearing event needs that event observable **in the field**, not in the log about the field. From 0b556ed446cb9dd56d5aeb934f2fe6cd941411dd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:32:12 -0700 Subject: [PATCH 15/15] docs(adr-017): blank line so the propagation paragraph is its own block Co-Authored-By: Claude Opus 5 --- docs/adr/ADR-017-attention-routing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index d524e556b..33645bebc 100644 --- a/docs/adr/ADR-017-attention-routing.md +++ b/docs/adr/ADR-017-attention-routing.md @@ -437,6 +437,7 @@ This does not settle whether `prUrl` should also become writable before merge **And the board then ran the experiment on itself, which converts the paragraph above from an argument into a measurement.** Within the hour of that capability being named in the pod, the seat that had filed the retitle request used the PATCH directly: **TASK-023's title was rewritten at 2026-08-30T06:03:50Z**, from `DECIDE then fix: … needs Sam's accept-or-fix call before implementation` to `FIX (ruled by Sam 2026-08-28T23:17Z): … Implemented in #1322, gated PASS at 91c250a9; awaiting merge press`. A title that had misdescribed its row for 30 hours was corrected in one call by the same seat that had escalated it to a human, with no permission change in between. **The constraint was knowledge of the verb, not authority to use it** — which is the strongest available evidence that a queue ranking these rows by title would have been ranking an artifact of tool documentation. **The other leg — that correcting a title corrects the wake — was measured on a third row the same hour, and by a reader who is not the writer.** This section argues from two rows whose stale titles were re-served; it did not yet demonstrate that a fix propagates. TASK-089 supplies that. Its title asserted an open defect (`#1327 closed \p{Cf} only — 8 non-Cf invisibles still defeat…`) after the fix was built, gated and green; @sprint-review rewrote it to `AWAITING PRESS (no work left): …` at `2026-08-30T06:17Z`. This document's author received the kernel's unclaimed-work wake for that row **both before and after** — same seat, same session — and the wake quoted the stale title the first time and the corrected title verbatim the second. So neither leg is inferred: the wake reads the title, and a title correction reaches the wake on its next fire, immediately and at no cost. That is what makes the gap load-bearing rather than cosmetic — every hour a title stays stale is an hour of the queue ranking a description that anyone holding a runtime token could have corrected. + **One observation resolved within the hour, and how it resolved is the finding.** TASK-067's update log records `title updated` at `2026-08-30T06:02:57Z`, and its title was unchanged on two separate reads a minute apart — still `DECIDE (Sam): should a LEADING bare NO_REPLY suppress the whole reply?`. The PATCH handler pushes that log line whenever `title` is present in the body, without comparing it to the stored value, so an identical-value write and a write that failed to take are indistinguishable in the record. This spec first noted the pair of facts and declined to say which had happened. The seat that made the write then answered it directly: a same-value PATCH on that row returns **HTTP 200**, so the entry was an identical-value write, not a failed one. **The record could not have answered that question — only the writer could.** That is the durable point for anything downstream that reads `title` as a cleared signal: **the audit line and the field can disagree**, because the line fires on presence in the request body rather than on a change to the row. A `blockedOn` design that treats a title rewrite as the clearing event needs that event observable **in the field**, not in the log about the field.