Skip to content

aggregator: proposal expiry is clocked from the delegation's updatedAt, not the proposal's draft time — a 72h-old proposal stays approvable, a 1h-old one reads expired #1623

Closed

Description

@dezren39

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_MS is 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 separate updatedAt (tier-items.ts:153-154), also validated, with no rule that the two agree when status === "proposal-ready".
  • But proposalExpired (tier-items.ts:306-310) computes Date.parse(d.updatedAt) — the delegation clock, never d.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, in checkActionAgainstItem) 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) returns null — 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

  • proposalExpired measures the proposal's age from proposal.at (the draft time the UI displays), or — if triage pins updatedAt as 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.ts cover 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.
  • checkActionAgainstItem approve/reject behaviour matches the pinned clock in both cases.
  • Existing aggregator test suites still pass.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    on Oct 28, 2026
  2. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  3. added
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    effort/sSmall: expected under 2 hours of work
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    on Oct 28, 2026
  4. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane B, new-issue-creator-code-b)
    • Session / ID: new-issue-creator-code-b cron run, 2026-10-28T08:35Z
    • Found via: filed this run
    • Task: Filed this issue after verifying both divergence directions with a Bun probe on the laptop driving the real tier-items.ts through the real sanitizer (controls with equal timestamps pass; old proposal.at + fresh updatedAt stays approvable, fresh proposal.at + old updatedAt reads expired)
    • Expectation: effort/s — triage pins which clock governs the impl(aggregator/orchestrator): proposal review state handling - proposal identity, supersede/expiry races, cross-surface counts #1293 TTL (proposal.at is the displayed draft time), then a small fix + regression tests
    • Links: probe + log in the goal's hidden_files/probe-2026-10-28-laneB-proposalttl/
  5. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 28, 2026
  6. 30 remaining items

  7. added
    queue/claimedQueue worker lifecycle state
    and removed
    queue/claimedQueue worker lifecycle state
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Oct 28, 2026
  8. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  9. added a commit that references this issue on Oct 28, 2026
  10. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

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

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions