Repository navigation
Description
Summary
Aggregator proposal expiry (#1293) is clocked from the delegation's updatedAt, not from the proposal's own draft time proposal.at — the two timestamps the sanitizer validates independently and nothing ties together. A proposal can therefore stay actionable long past the 48 h TTL, and a fresh proposal can read as expired:
PROPOSAL_TTL_MSis documented as the age of the proposal: "A first-pass proposal older than this renders expired (impl(aggregator/orchestrator): proposal review state handling - proposal identity, supersede/expiry races, cross-surface counts #1293) and the publisher's self-check retires it on the next run" (pm-stack/aggregator/src/tier-items.ts:46-47).- The proposal carries its own draft timestamp:
DelegationProposal.at(src/model.ts:532), validated as ISO by the sanitizer and kept verbatim (tier-items.ts:168-169). The delegation state carries a separateupdatedAt(tier-items.ts:153-154), also validated, with no rule that the two agree whenstatus === "proposal-ready". - But
proposalExpired(tier-items.ts:306-310) computesDate.parse(d.updatedAt)— the delegation clock, neverd.proposal.at. - Both consumers inherit the wrong clock: the render box (
src/render.ts:1035-1037) prints "Proposal expired — drafted {proposal.at}" when the function fires, and prints the same drafted date in the live box (render.ts:1047) when it does not; the POST boundary (tier-items.ts:381-382, incheckActionAgainstItem) refuses/accepts approve-proposal and reject-proposal by the same function.
So whenever a publisher re-publishes a carried-forward proposal with a refreshed delegation updatedAt (the hourly section is re-published every run), the displayed draft age and the enforced TTL diverge in both directions.
Verified (this run, on main 9752a47)
Probe ON THE LAPTOP (Bun, driving the real tier-items.ts, sha256 cdb1fd21… identical in the sandbox clone and the laptop clone; items built through the real sanitizeTierItemExtensions; probe + log in the goal's hidden_files/probe-2026-10-28-laneB-proposalttl/):
- CTRL fresh (updatedAt and proposal.at both 1 h old) →
proposalExpired = false(correct). - CTRL old (both 72 h old) →
proposalExpired = true(correct). - BUG-A: proposal.at 72 h old, updatedAt 1 h old → sanitizer keeps the delegation as
proposal-ready;proposalExpired = false;checkActionAgainstItem(approve-proposal)returnsnull— approval ACCEPTED for a proposal drafted 72 h ago, past the 48 h TTL. The card would render the live proposal box saying "Drafted 2026-10-25…". - BUG-B: proposal.at 1 h old, updatedAt 72 h old →
proposalExpired = true; approve-proposal is refused with "this proposal has expired" — a 1-hour-old proposal is unactionable.
Existing tests (test/needs-user.test.ts, "proposal identity + expiry (#1293)") only exercise the case where the two timestamps are equal, so the divergence is unpinned.
Why it matters
The 48 h TTL is the mechanism that stops a stale first-pass proposal from being approved after the world has moved on (#1293: an expired proposal "is no longer actionable", and approval is the one dashboard action that applies a drafted change on the next orchestration pass). Clocking it from a state timestamp that any re-publish can refresh defeats it silently — the UI even displays the true draft date next to the wrong verdict, so the user sees "Drafted 3 days ago" on a box that still offers approval. The reverse direction refuses approval of a fresh proposal and tells the user a fresh first pass will be drafted, confusing the same flow.
Distinct from #1486 (yes/no survey answer validation, a different check in the same file) and from the freshness-family issues #1541/#1564/#1568 (feed/quota generatedAt/measuredAt gates in other modules; this is the delegation proposal TTL in tier-items.ts).
Acceptance criteria
-
proposalExpiredmeasures the proposal's age fromproposal.at(the draft time the UI displays), or — if triage pinsupdatedAtas the intended clock instead — the TTL comment, the render text, and the proposal identity documentation are updated to say so explicitly, and the redundant-clock divergence is removed or explained. - Regression tests in
pm-stack/aggregator/test/needs-user.test.tscover both divergent cases: old proposal.at + fresh updatedAt (expired; approve refused) and fresh proposal.at + old updatedAt (not expired; approve passes the expiry gate), alongside the existing equal-timestamp controls. -
checkActionAgainstItemapprove/reject behaviour matches the pinned clock in both cases. - Existing aggregator test suites still pass.
Activity
developing-today commentedon Oct 28, 2026
CODE-646
dezren39 commentedon Oct 28, 2026
🤖 Agent note · filed
30 remaining items
dezren39 commentedon Oct 28, 2026
🤖 Agent note · picked up
queue/1867-1623-proposal-expiry-clock@ 4d05b19Merge pull request #1714 from developing-today/queue/1867-1623-propos…
dezren39 commentedon Oct 28, 2026
🤖 Agent note · done