Found 2026-09-03 by the maintainer tapping a notification and reporting "that link is old". The ceremony had already been approved minutes earlier; the push notification was still sitting there looking actionable.
What actually happens
The stored entry is fine. pending.js gives each approval its own expirationTtl of APPROVAL_TTL_SECONDS = 900, deliberately matched to the keeper's ceremony window on the stated grounds that "an approval nobody can act on is not pending". So /pending stops offering it.
The delivered notification is not fine. Once the push has been shown, it belongs to the operating system, and nothing ever retracts it. It persists until the person dismisses it — long after the ceremony was approved, consumed, or lapsed. Tapping it lands on a keeper page that refuses, with no explanation of why beyond "old".
So the notification and the store disagree, and the notification is the half a person actually sees.
Why this matters more than it looks
The whole direction of .github-private#861 is that approval fatigue is the real adversary — a per-item tap is not a control if it is a button a tired person presses. A notification that outlives its ceremony trains exactly the wrong reflex: it teaches that prompts are often stale, which is the same lesson as "prompts are often noise". Every stale one makes the next real one cheaper to ignore.
There is also a near-miss shape here. A person who has learned that a prompt might be spent has to check rather than act — and the check is opening the keeper page. That is fine today because the keeper refuses cleanly. It stops being fine the moment two ceremonies are outstanding and the stale prompt is indistinguishable from the live one.
The precedent already exists one layer up
front-desk-reroll.yml solves this for its issue-shaped notification, and says why (#758):
ALWAYS, including when the wait above FAILED. Without always() a failed step skips everything after it, so an expired window left its notification issue open forever — two lapsed ceremonies on 2026-08-29 had to be closed by hand. The issue is a notification; leaving it open after the approve URL stops redeeming is worse than not opening one, because it reads as an outstanding request that anyone could still act on.
That reasoning transfers verbatim. The push notification is the same object as that issue, with worse ergonomics and no closing step.
Why this is now cheap, when it was not before
.github-private#847 built the keeper's first outbound call — notify.mjs POSTs a signed notice when an approval lands, and desk's /approval is the receiver. Before that, desk had no way to learn a ceremony's fate; polling /result was the only channel and desk does not poll.
That mechanism now exists and is one notice type away from covering this: the keeper already knows when a ceremony is approved, consumed or expired. A notice on those transitions lets desk clear the stored entry early and, more importantly, replace the delivered notification with a terminal one.
Shape, not a decision
- The keeper emits a notice on ceremony end, not only on approval. Same signed envelope, same fail-open direction — a failed notify must never affect the grant.
- desk deletes the pending entry on receipt rather than waiting out the TTL.
- The service worker re-notifies with the same tag so the OS replaces the card in place rather than stacking a second one, with terminal text (approved / expired) and no action.
Open questions worth deciding rather than assuming: whether a terminal notification should appear at all or the card should simply be withdrawn (Notification.close() via the SW), and whether "approved by you, elsewhere" and "expired unapproved" should read differently — they are different facts and this repo's own habit is to keep those distinguishable.
Related: #51 (one slot, and the TTL doing the pruning) · #37 (the push fan-out) · #65 (per-entry TTL) · .github-private#847 (the keeper's outbound notice) · .github-private#758 (the same defect, solved for the issue-shaped notification) · .github-private#861 (why fatigue is the adversary)
Found 2026-09-03 by the maintainer tapping a notification and reporting "that link is old". The ceremony had already been approved minutes earlier; the push notification was still sitting there looking actionable.
What actually happens
The stored entry is fine.
pending.jsgives each approval its ownexpirationTtlofAPPROVAL_TTL_SECONDS = 900, deliberately matched to the keeper's ceremony window on the stated grounds that "an approval nobody can act on is not pending". So/pendingstops offering it.The delivered notification is not fine. Once the push has been shown, it belongs to the operating system, and nothing ever retracts it. It persists until the person dismisses it — long after the ceremony was approved, consumed, or lapsed. Tapping it lands on a keeper page that refuses, with no explanation of why beyond "old".
So the notification and the store disagree, and the notification is the half a person actually sees.
Why this matters more than it looks
The whole direction of
.github-private#861 is that approval fatigue is the real adversary — a per-item tap is not a control if it is a button a tired person presses. A notification that outlives its ceremony trains exactly the wrong reflex: it teaches that prompts are often stale, which is the same lesson as "prompts are often noise". Every stale one makes the next real one cheaper to ignore.There is also a near-miss shape here. A person who has learned that a prompt might be spent has to check rather than act — and the check is opening the keeper page. That is fine today because the keeper refuses cleanly. It stops being fine the moment two ceremonies are outstanding and the stale prompt is indistinguishable from the live one.
The precedent already exists one layer up
front-desk-reroll.ymlsolves this for its issue-shaped notification, and says why (#758):That reasoning transfers verbatim. The push notification is the same object as that issue, with worse ergonomics and no closing step.
Why this is now cheap, when it was not before
.github-private#847 built the keeper's first outbound call —notify.mjsPOSTs a signed notice when an approval lands, and desk's/approvalis the receiver. Before that, desk had no way to learn a ceremony's fate; polling/resultwas the only channel and desk does not poll.That mechanism now exists and is one notice type away from covering this: the keeper already knows when a ceremony is approved, consumed or expired. A notice on those transitions lets desk clear the stored entry early and, more importantly, replace the delivered notification with a terminal one.
Shape, not a decision
Open questions worth deciding rather than assuming: whether a terminal notification should appear at all or the card should simply be withdrawn (
Notification.close()via the SW), and whether "approved by you, elsewhere" and "expired unapproved" should read differently — they are different facts and this repo's own habit is to keep those distinguishable.Related:
#51(one slot, and the TTL doing the pruning) ·#37(the push fan-out) ·#65(per-entry TTL) ·.github-private#847 (the keeper's outbound notice) ·.github-private#758 (the same defect, solved for the issue-shaped notification) ·.github-private#861 (why fatigue is the adversary)