Status: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0042 (reserved system actors, audit-first discipline), thread interactions (#1740), ADR-0012/0030 (messaging + outbox)
Closes: #1743
Consumers: @objectstack/plugin-approvals (token store + redemption + pages), messaging templates, future email channel wiring
Approvers should act from an email/IM message without signing in — the biggest lever on approval latency. The button behind that experience is a URL whose bearer has no session, so the token in it must carry the entire authorization, deliberately weakened on every axis:
| axis | decision | failure it prevents |
|---|---|---|
| scope | one token = one request + one action + one approver | leaked token ≠ account takeover |
| storage | only the SHA-256 hash is stored (sys_approval_token) |
a DB leak yields no usable links |
| single-use | consumed_at set transactionally before deciding |
forwarded email replayed |
| TTL | 72 h default | months-old mail approving today's request |
| identity | token binds approver_id; the decision is audited as that approver |
anonymous decisions |
| invalidation | redemption re-checks the request is still pending and the approver still holds the slot | stale links after reassign / recall / decision |
| scanner-proof | GET never executes — it renders a confirm page whose button POSTs | mail-gateway link prefetchers approving requests |
The last row is the classic production incident: enterprise mail security (Outlook SafeLinks et al.) pre-fetches every link in a message. Any GET-executes design gets requests approved by robots.
sys_approval_token(new object — table creation only, no migrations):token_hash,request_id,action(approve/reject),approver_id,expires_at,consumed_at.- Issue (
issueActionTokens): 256-bit random raw tokens, returned once, hashes stored. Wired intoremind()— each pending approver with a concrete identity (notrole:*literals) gets their own notification carrying their own approve/reject links. (Open-time notification remains the flow author'snotifynode; templates there can adopt the same links later.) - Confirm page (
GET /api/v1/approvals/act?token=…): session-less minimal HTML rendered by the plugin on the host Hono app — request summary (flow label, record title, action) + a POST form. Invalid / expired / consumed tokens render an explanatory page with a Console deep link; the GET never mutates. - Redeem (
POST /api/v1/approvals/act): hash lookup → not consumed → not expired → request stillpending→approver_idstill inpending_approvers→ mark consumed →decide()as that approver (system context carries the bound identity). Every check failure maps to a distinct, non-enumerable result page. - URLs: relative by default (works inside Console/IM webviews);
deployments set
publicBaseUrl(plugin option) for absolute links in outbound email.
- Comment capture on the confirm page (a decision comment field is a fast follow; the page ships decision-only).
- Email channel delivery configuration — the links ride the existing messaging payloads; SMTP setup is deployment concern.
- Rate limiting beyond single-use + TTL (the token space is 2^256; brute force is not the threat — leaked links are, and those die on first use / decision / reassign / expiry).
- The remind nudge becomes genuinely actionable — one tap from the notification to a decision, with the audit trail showing the human approver (never a system actor).
- A deliberate, narrow bypass lane around session auth exists; its
entire surface is this ADR's table, and every property is enforced in
redeemActionTokenwith tests per row. - Stale-link UX is explicit: recalled/decided/reassigned requests answer with "this link is no longer valid" + a Console deep link, not an error code.