diff --git a/docs/adr/ADR-017-attention-routing.md b/docs/adr/ADR-017-attention-routing.md index bdde078cd..d524e556b 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) @@ -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.** 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,11 +284,11 @@ 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`. **#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. @@ -358,10 +358,117 @@ 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.** +### 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 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 { + 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. **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. + +**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 + +``` +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). + +**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. + +**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 `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. + +**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 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. + + +#### 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" — 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`. + +**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 + +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.