From 4d05b191cf755cbb4505b75eec164055918df132 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 10:49:22 -0500 Subject: [PATCH] aggregator: clock proposal expiry from proposal.at, not delegation updatedAt (#1623) --- pm-stack/aggregator/src/tier-items.ts | 8 ++++-- pm-stack/aggregator/test/needs-user.test.ts | 32 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pm-stack/aggregator/src/tier-items.ts b/pm-stack/aggregator/src/tier-items.ts index 4d793195f..f15661819 100644 --- a/pm-stack/aggregator/src/tier-items.ts +++ b/pm-stack/aggregator/src/tier-items.ts @@ -302,10 +302,14 @@ export function effectiveNeedsOrder(records: DashAction[]): { order: NeedsOrder; } /** #1293: a proposal-ready delegation past its TTL is expired — it - * renders as expired and the item is decidable again. */ + * renders as expired and the item is decidable again. The clock is the + * proposal's own draft time (`proposal.at`, #1623): `updatedAt` moves on + * any delegation write, so clocking from it would let churn resurrect an + * old proposal indefinitely (and read a fresh proposal as expired when + * the delegation record itself is old). */ export function proposalExpired(d: TierItemDelegation | undefined, nowMs: number): boolean { if (!d || d.status !== "proposal-ready") return false; - const at = Date.parse(d.updatedAt); + const at = Date.parse(d.proposal?.at ?? ""); return !Number.isNaN(at) && nowMs - at > PROPOSAL_TTL_MS; } diff --git a/pm-stack/aggregator/test/needs-user.test.ts b/pm-stack/aggregator/test/needs-user.test.ts index dee025a7f..3e4d6a06c 100644 --- a/pm-stack/aggregator/test/needs-user.test.ts +++ b/pm-stack/aggregator/test/needs-user.test.ts @@ -399,6 +399,38 @@ describe("proposal identity + expiry (#1293)", () => { }); expect(checkActionAgainstItem(approve, item, later)).toContain("expired"); }); + + /** An item whose proposal draft time and delegation updatedAt diverge (#1623). */ + function divergentItem(proposalAt: string, updatedAt: string): TierItem { + const raw = extendedRaw(); + (raw.delegation as Record).updatedAt = updatedAt; + ((raw.delegation as Record).proposal as Record).at = proposalAt; + return sanitizeTierItemExtensions(raw as unknown as TierItem); + } + + test("expiry is clocked from proposal.at, not delegation updatedAt (#1623)", () => { + const now = Date.parse("2026-10-30T04:00:00.000Z"); + + // Proposal drafted 74h ago; delegation churned 1h ago. The proposal is + // expired, and approving it (with the live identity triple) is refused. + const churned = divergentItem("2026-10-27T02:00:00.000Z", "2026-10-30T03:00:00.000Z"); + expect(proposalExpired(churned.delegation, now)).toBe(true); + const approveStale = actionInput({ + verb: "approve-proposal", + payload: { proposalRunId: "orch-2026-10-27T02:00:00Z", proposalUpdatedAt: "2026-10-30T03:00:00.000Z" }, + }); + expect(checkActionAgainstItem(approveStale, churned, now)).toContain("expired"); + + // Proposal drafted 1h ago on an old delegation record. Not expired, and + // approving it passes the expiry gate. + const fresh = divergentItem("2026-10-30T03:00:00.000Z", "2026-10-27T02:00:00.000Z"); + expect(proposalExpired(fresh.delegation, now)).toBe(false); + const approveFresh = actionInput({ + verb: "approve-proposal", + payload: { proposalRunId: "orch-2026-10-27T02:00:00Z", proposalUpdatedAt: "2026-10-27T02:00:00.000Z" }, + }); + expect(checkActionAgainstItem(approveFresh, fresh, now)).toBeNull(); + }); }); describe("actions boundary additions (#1290)", () => {