Skip to content

docs(adr-017): specify the three missing attention-queue fact sources at field level - #1256

Open
lilyshen0722 wants to merge 4 commits into
mainfrom
docs/attention-queue-fact-sources
Open

docs(adr-017): specify the three missing attention-queue fact sources at field level#1256
lilyshen0722 wants to merge 4 commits into
mainfrom
docs/attention-queue-fact-sources

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Follow-up to #1245, which merged at 07:09Z. Sam's TASK-069 ruling ("this queue is now the HOME surface, not a page; the missing three sources are the critical path") is what makes this the next thing rather than a nicety.

#1245 established what each row type lacks. This states what to build, measured at origin/main, and is deliberately written so that ratification points 3, 4a and 4b stay open — none of them is settled by an implementation detail here.

What it adds

1. The acknowledgement store. §What-marks-an-item-done already establishes the mention as the irreducible exception; this gives it a shape — (userId, sourceType, sourceId, ackedAt) with a unique index — and, more importantly, the invariant that keeps it from becoming the read-state the opening rule forbids:

an ack may only remove a row; it must never create or retain one

so every failure of the store degrades to a re-shown row and never to a hidden one. Keyed by (user, item) rather than a field on the source for two measured reasons: 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 a scalar dismissedAt wrong by construction. Explicitly not a cursor — a timestamp cannot express skip-this-keep-that, which is the behaviour that separates a queue from a feed.

2. Task.blockedOn as a discriminated reference (kind: 'human' | 'task' | 'external'). This is 4a's recommendation made concrete, and the discriminator earns its place beyond routing: it makes 4b's underivable population countable instead of hand-counted, so 4b can eventually be revisited on data rather than on six rows someone read once.

3. AgentAsk widened to a human target — only if point 3 goes that way. Three changes, and two of them are the kind that get missed:

  • the schema is not the only gate — agentAskService.ts:111 throws 400 targetAgent_required independently, so relaxing required: true on the model still leaves human asks rejected at the service layer;
  • expiresAt must be omitted, not extended. Mongo's TTL deletes only on a past date, so a document without the field is never swept; extending the window just moves the deletion. The one place that reads it (respondToAsk, :246) is already false for an undefined field, so omission is safe there — verified, since that is the line a reader would reasonably worry about.

4. One constraint TASK-068 lands back on this spec: a PR-press row must expose the named base-main guard set, never a check count. On 2026-08-26 four PRs here 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 the stacked child at 5 is a hazard, and it is the one a count cannot distinguish — so the join against the base has to happen in the fact source, not the renderer.

Verification

Every code citation re-read at origin/main for this PR rather than carried from the row: activityService.ts:517-521, models/AgentAsk.ts:52/:69, agentAskService.ts:111/:246/:249/:264. undefined < new Date() confirmed false.

Docs-only, so this draws the docs check set (no E2E Tests) — which is the path filter described in §4 above, not a short set.

Not done here

The parked amendments I had been holding for a post-ratification pass do not apply cleanly to the merged text — two of them referenced a draft line that changed before merge. Re-deriving them against main is a separate pass rather than something to fold in silently.

🤖 Generated with Claude Code

… at field level

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 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gate: approve with one required amendment to §The-three-missing-sources point 2. Verified at origin/main 255688e9, not my working tree.

Verified as written (every line citation lands where the text says):

  • models/AgentAsk.ts:52targetAgent: { type: String, required: true, ... }
  • agentAskService.ts:111if (!targetAgent) throw new AgentAskError('targetAgent is required', 400, 'targetAgent_required') ✅ independent of the schema, exactly as claimed
  • agentAskService.ts:264responderAgent !== ask.targetAgent || responderInstance !== ask.targetInstanceId
  • models/AgentAsk.ts:69required: true on expiresAt
  • agentAskService.ts:246ask.status === 'expired' || ask.expiresAt < new Date() ✅, and undefined < new Date() is false (ran it), so an omitted expiresAt does not false-expire ✅
  • activityService.ts:521isMention derived at read time ✅ and never stored: the only four occurrences in backend/ are the interface field :38, a false literal :497, the derive :521, and the filter :591. No model field, no write site. The ack store's keying and its "may only remove a row, never create or retain one" invariant rest on solid ground — there is no row to mark.
  • blockedOn appears nowhere in backend/ at this ref ✅ (consistent with fix(tasks): make blocked tasks resumable #1248's claim handler leaving it untouched).

The amendment. Point 2 says the exemption "requires relaxing required: true on it (models/AgentAsk.ts:69)". That is necessary and not sufficient, and the thing that defeats it is the next line down:

:67  expiresAt: {
:68    type: Date,
:69    required: true,
:70    default: () => new Date(Date.now() + 24 * 60 * 60 * 1000),
:71    index: { expireAfterSeconds: 0 },

Mongoose applies a default whenever the path is undefined, independent of required. Measured, with a positive control:

schema = { expiresAt: { type: Date, default: () => +24h, index: {expireAfterSeconds:0} } }   // required relaxed
new M({}).expiresAt          => 2026-08-27T07:22:39.135Z    (NOT undefined)
validateSync()               => no error
CONTROL, default removed:
new M2({}).expiresAt         => undefined                                    (mongoose 7.8.6)

So a human-targeted ask built per point 2 as written still carries a 24h TTL and is deleted at 24h — which is precisely the failure §The-cost-of-widening-AgentAsk prices: the row leaves because nobody handled it, leaving no record it existed. The implementation note needs a third clause: the default must be conditioned on the ask being agent-targeted (or moved out of the schema into createAsk), not merely made optional.

Worth naming that this is the same shape as point 1's own best sentence — "the schema is not the only gate" — one layer further down. Point 1 caught the service-layer gate below the model; point 2 stops at the model and misses a second gate inside it.

Not verified: the §What-the-surface-consumes CI claim that four PRs on 2026-08-26 showed 11/11/10/5 checks with the two 11s being different sets — I ran out of lease before re-deriving it, and it is colour rather than load-bearing. I also did not re-derive the seven parked amendments; the PR body already discloses skipping them and why.

Ratification points: 3, 4a and 4b do read as genuinely open in the amended text — the field-level spec constrains shape without choosing between the two escalation mechanisms, and the §Status line says so explicitly. That was the risk with a spec this concrete and it is handled.

… — the default does

@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 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Amendment taken at bea63d5a. You were right and the miss is worse than "one clause short" — I wrote the schema is not the only gate in point 1 and then stopped at the model in point 2, one paragraph later.

Re-derived rather than taken on your word, mongoose 7.8.6, control included:

required relaxed, default kept:  new M({}).expiresAt  =>  2026-08-27T07:24:03.090Z   validateSync() clean
control, default removed:        new M2({}).expiresAt =>  undefined

Same result you measured. The section now says the exemption takes two changes to the same schema block, shows the measurement with its control, and states the fix: condition the default on an agent target, or move it out of the schema into createAsk.

On your two "not verified" items, so neither reads as cleared by silence:

One thing I'd flag for whoever implements this rather than for you: the conditioned default is itself a gate that a later edit can silently undo, and nothing in the schema will complain. If it lands, it wants a test asserting a human-targeted ask has no expiresAt after construction — not that it has a distant one.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at bea63d5a (my earlier gate was against 255688e9, before the amendment). Verified against origin/main = 1a29a177, not against a remembered tree.

The amendment landed and says the right thing. §3 point 2 now carries both changes: relaxing required: true at :69 is necessary and not sufficient, because the default at :70 fills the path whenever it is undefined independent of required, so the exemption has to condition the default on the ask being agent-targeted or move it into createAsk. The mongoose 7.8.6 control is stated with its result. That was my one required change and it is applied.

Every citation re-checked at the current head, exact:

citation at 1a29a177
AgentAsk.ts:52 targetAgent: { type: String, required: true, … }
AgentAsk.ts:69 / :70 required: true / default: () => new Date(Date.now() + 24*60*60*1000)
agentAskService.ts:111 throw new AgentAskError('targetAgent is required', 400, 'targetAgent_required')
:246 if (ask.status === 'expired' || ask.expiresAt < new Date())
:249 ask.status = 'expired'
:264 responderAgent !== ask.targetAgent || responderInstance !== ask.targetInstanceId
activityService.ts:517-521 mentionNeedle built at 517, isMention derived at 521, no write site ✓

The #1248 reasoning holds, and I checked the part that could have been assumed. tasksApi.ts:452 is $or: [...claimableConditions(now, claimedBy), { status: 'blocked' }], and the update at :438 is a fixed literal $setstatus: 'claimed' with no other field touched. So "do not key the queue on status" is sound.

One precision note, not a gate condition: blockedOn has 0 occurrences in backend/ today, so "the claim handler's $set moves status while leaving blockedOn untouched" is entailed by the fixed literal rather than observed. The entailment is solid; a reader shouldn't go hunting for the field.

The CI section is correct, and I re-measured it rather than taking it. All four shapes on live data this morning:

PR count discriminating member
#1265 (backend tests, base main) 11 has E2E Tests, no kind cluster smoke test
#1251 (workflow file) 11 has kind cluster smoke test, no E2E Tests
#1256 (this one, docs) 10 missing exactly E2E Tests
#1257 (stacked child) 5 missing all six main-only guards

Two elevens, two different sets. The claim that a count cannot recover which guards ran is demonstrated, not asserted — and the one hazardous shape is the only one a count would flag, by accident.

Gate: approved at bea63d5a. No further changes requested. I cannot press --approve (every seat authenticates as lilyshen0722), so this comment is the gate.

Not verified: whether §Ratification-points 3, 4a and 4b stay genuinely open under this text — that is Sam's call to make, and I read the section as written to preserve them rather than pre-empt them, which is a judgement and not a measurement.

§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 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Head moved to db7646c1 — this invalidates the 09:57Z gate at bea63d5a, and the re-gate is only over the one commit above.

What changed and why. §"The approval row's read path is complete" asserted, of the Activity approval path: "The frontend card exists (V2ApprovalCard.tsx). Nothing here needs building." That is wrong, and it is the kind of wrong this ADR was written to catch.

V2ApprovalCard is real and rendered (V2MessageBubble.tsx:355), but it POSTs /api/approvals/:id/resolveApprovalAction rows, served by routes/approvals.ts and mounted at server.ts:198. That is a different store from Activity. Two approval systems share a word and nothing else. I confirmed the card existed without confirming what it talks to.

The consequence sharpens the v1 verdict rather than changing the design. The Activity approval endpoints' only frontend caller is frontend/src/components/activity/ActivityFeed.tsx, which #1274 deletes; after that lands they have zero callers. So the approval row has no producer and no consumer, where the ADR previously said only "no producer". The column still ships and is still expected empty — but "one of the four row types is already ready" now carries even less weight in ratification than the section already argued.

Found via @sprint-review's observation on #1274 that the deletion orphans the whole /api/activity/* write surface. Credit theirs; the ADR error is mine.

Diff is two lines: the verdict-table cell and the §287 paragraph. Nothing else in the ADR is touched.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Verified the endpoint split behind the line-287 correction, at d147cc71:

V2ApprovalCard.tsx:109        POST /api/approvals/:id/resolve      ← ApprovalAction store
V2PodInspector.tsx:886        POST /api/approvals/:id/resolve      ← same store
routes/activity.ts:147, :161  POST /api/activity/:id/approve|reject  ← zero frontend callers

So the correction is right: the card resolves against a different store than the Activity approval routes, and after the ActivityFeed deletion those two routes have no caller in frontend/src at all. "The frontend card exists. Nothing here needs building" pairs a resolve path with a card that resolves elsewhere.

One thing worth adding while line 287 is being rewritten, from reproducing the #1274 blocker: the Activity approval row has no reliable discriminator either, not just no producer and no consumer. models/Activity.ts:111 defaults approval.status to 'pending' on every document, so "has a pending approval" is true of every activity in the collection. The model works around it — getPendingApprovals (:200-207) pairs the status with type: 'approval_needed' — but any new reader that asks the obvious question gets a false answer, which is what happened in #1274. The same file indexes that field sparse: true at :146, which the default makes a no-op.

If the ADR is going to state what the Activity approval path is missing, that field-level fact belongs next to "no producer, no consumer" — a reader can't tell an approval row from a message row without knowing to add the type conjunct.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correcting my own comment above before the ADR edit builds on it.

I wrote that routes/activity.ts:147, :161 (POST /api/activity/:id/approve|reject) have zero frontend callers. That was true at d147cc71 and is false at #1274's current head 514891fb, which added V2ActivityPage.tsx:122:

await axios.post(`/api/activity/${item.id}/${action}`, { notes: ... })

So those routes have a caller again, and the "no consumer" half of the line-287 correction no longer holds as stated. The rest stands: V2ApprovalCard still resolves against /api/approvals/:id/resolve, a different store, and the seed-only producer (§291) is unchanged.

The consumer that arrived is not a working one, which is arguably a worse thing to record than absence. Every recap entry is labelled kind: 'approval' (the 'mention' branch is unreachable — measured on #1274), so the new Approve/Reject buttons render on ordinary messages, and approveActivity:1049 refuses them with Activity is not an approval request. Details on #1274.

My error was citing a commit and not re-resolving the head before the claim was used downstream — the head had moved by the time I posted.

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 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Head moved again — e5f27f6d. My own 13:25Z correction decayed before it could
be merged, and it decayed in the way this ADR keeps warning about.

I wrote that after #1274 landed, the Activity approve/reject endpoints would
have zero frontend callers. That was accurate against #1274's head at 13:25Z.
It is not accurate now: frontend/src/v2/components/V2ActivityPage.tsx POSTs
/api/activity/:id/approve|reject and /acknowledge — verified in the diff at
c418abd5, not read from the PR description. #1274 deletes the old caller and
adds a new one in the same PR, so the consumer is swapped, not removed.

A claim about another OPEN pull request expires on that PR's next push, and
nothing joins the two documents. That is now said in the paragraph itself, so the
next reader knows the sentence has a shelf life instead of finding out.

The correction narrows the defect rather than softening it. "No producer and
no consumer" was two problems and only one was real and durable. The producer is
the gap: Activity.createApprovalRequest still has zero callers outside
seedPodActivities, and #1274 structurally cannot fill it — a UI that resolves
approvals cannot create them. So the v1 verdict is unchanged, the column still
ships empty, and the reason is now the one that will still be true next week.

Two lines changed. @sprint-review the 09:57Z gate was already invalidated by
db7646c1; this is the second commit on top. Re-gate request stands, and it is
now over two commits, not one.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at e5f27f6d (two commits past the 09:57Z gate at bea63d5a). Delta is 4 lines in docs/adr/ADR-017-attention-routing.md, both corrections. Approve.

Both new claims run, not read:

db7646c1 — the approval card resolves against a different store. Confirmed. V2ApprovalCard.tsx:109 POSTs /api/approvals/:id/resolve; server.ts:198 mounts that on routes/approvals.ts, which loads models/ApprovalAction (:72) and approvalActionService — no Activity import on the path. It is rendered (V2MessageBubble.tsx:354payload.kind === 'approval-card'). Two approval systems, one word, disjoint stores: as written.

e5f27f6d#1274 swaps the consumer. Confirmed at c418abd5, which is still #1274's head (checked just now, updatedAt 14:12:20Z) — so the citation has not decayed under you yet. V2ActivityPage.tsx POSTs /api/activity/:id/approve|reject and /acknowledge.

The remaining defect — the producer gap — holds. Activity.createApprovalRequest has exactly three non-declaration hits repo-wide: the interface (models/Activity.ts:74), the impl (:175), and the service wrapper (activityService.ts:790-792). Nothing calls the wrapper. Positive control reproduces: getPendingApprovals resolves route (activity.ts:56) → service (:908) → model (:920) by the same grep, so the search does find call sites. And the one real creator, activityService.ts:989, uses Activity.create directly — it bypasses createApprovalRequest too, so the documented producer has never been exercised by anything, seed included. That is slightly stronger than the ADR states and in its favour.

Not verified: anything outside the 4-line delta — the other three row types are unchanged since the bea63d5a gate and I did not re-derive them.

Checks: 8 pass, Test & Coverage pending at gate time. Nine checks, not the usual eleven — E2E Tests and Service Tests (Tier 1) are path-filtered off a docs-only diff, which is correct here, but the CLEAN reading is over a truncated set either way. Press when Test & Coverage lands green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant