Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pm-stack/aggregator/src/tier-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
32 changes: 32 additions & 0 deletions pm-stack/aggregator/test/needs-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).updatedAt = updatedAt;
((raw.delegation as Record<string, unknown>).proposal as Record<string, unknown>).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)", () => {
Expand Down
Loading