approval-engine/workplans/APPROVAL-WP-0003-reissue-expired-approvals.md
tegwick c93628a017 Let supersede() reissue an expired approval given a fresh window
expired stays a terminal exit from valid (state machine unchanged),
but supersede() now also accepts it as a source status when the
caller supplies a validity window, linking a new successor approval
to the expired original instead of requiring a caller to mint an
unlinked replacement from scratch. Without a fresh window, an expired
source still conflicts exactly as before.

The original's status becomes superseded (not left as a bare dead-end
expired) and superseded_by points at the successor, so the link is
documented rather than hidden -- informed-decision's overview already
renders this automatically, no changes needed there.

Prompted by the operator missing infd-20260927-b01's approval window
during today's key-cape login outage (APPROVAL-WP-0003).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 169987@bnt-lap001
Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999
2026-09-27 20:23:56 +02:00

4.8 KiB

id type title domain repo status flavor owner topic_slug created updated origin origin_ref
APPROVAL-WP-0003 workplan Reissue: link a successor to an expired approval via supersede infotech approval-engine finished implementation claude netkingdom 2026-09-27 2026-09-27 residual informed-decision/INFD-IN-0005

APPROVAL-WP-0003 — Reissue expired approvals

Why

Operator (Bernd) missed approving infd-20260927-b01 (informed-decision) due to the key-cape login bugs fixed same-day. Asked, as a follow-up: an easy way for the approval's originator to bring back a decision that ran out of its timeframe, with the reissue linked to the original so the original stays documented rather than disappearing into a bare expired dead end.

expired is a deliberately terminal exit from valid (SCOPE.md "In Scope") — the closed state machine does not reopen it in place, and nothing here changes that. What this adds is a link: a fresh, separately-approvable successor approval tied to the expired one, using the supersession mechanism that already exists for exactly this kind of "retire this, point at that" relationship (supersede, previously usable only from requested/approved).

What changed

id: APPROVAL-WP-0003-T01
status: done
priority: high

Engine.supersede(approval_id, successor_id=None, validity=None) (approval_engine/store.py):

  • Unchanged behaviour: called from requested/approved with no validity, the successor inherits the original's window verbatim — the existing correcting-a-binding-while-still-open use case is untouched (test_ordinary_supersession_of_an_active_approval_still_copies_the_window).
  • New: called from expired with a validity dict (not_before optional, expires_at required, same validation as create()), it links a successor that carries the new window instead of the stale one. Without validity, an expired source still conflicts exactly as before — a caller cannot silently mint a successor that inherits an already-past window, and cannot accidentally reissue-by-omission.
  • The original's status becomes superseded (not left as bare expired) and superseded_by points at the successor — queryable forever, never deleted. Every other terminal status (revoked, consumed, superseded itself) still refuses supersede outright, validity or not: only expired gets this relaxation.
  • The compare-and-swap on the UPDATE now matches the exact status just read (AND status=? bound to the read row) instead of a hardcoded IN ('requested','approved'), which is what let the same code path serve both cases without a second SQL statement.

approval_engine/api.py: POST /v1/approvals/{id}/supersede now passes a validity field from the request body through to supersede, under the same approval:supersede scope — no new endpoint or scope, since reissue is a supersession from a different source status, not a new operation.

Tests: tests/test_reissue.py (7 cases: link+retire, missing validity conflicts, expires_at/not_before ordering, other terminal statuses still refuse even with validity, and the no-regression case above) and two HTTP-level cases in tests/test_api.py. All confirmed to fail against the prior code and pass after. Full suite: 169 passed (was 160).

What did not need to change

  • flex-auth: unrelated — its policies pin approval_id/binding_digest per admitted record; a reissued successor is a new approval_id and needs its own admission when a memo is cut for it, same as any new approval.
  • informed-decision: its overview already groups any non-open engine status as closed and renders the raw engine_status text (informed_decision/review.py:193, ui.py:151-152). Once an originator reissues, the memo's approval flips from expired to superseded and the overview reflects that automatically — no code change needed there. Linking the old memo to the new memo in the UI (so a reviewer can click through) is a separate, smaller follow-up if wanted, tracked as a residual below — informed-decision would need to resolve superseded_by to a memo id, which isn't a 1:1 mapping today (a fresh memo version is cut for the new approval_id by whoever originates it, same as the existing T03-renewal pattern in informed-decision/docs/evidence/2026-09-16-t03-renewed-review-handoff.json).

Residual

Not built here, deliberately out of scope for this pass:

  • A convenience client/CLI for providers to call supersede with a fresh window in one step (today it's a direct HTTP call any authenticated approval:supersede caller can already make — no new capability is missing, just no wrapper script).
  • informed-decision surfacing a "reissued as" link on the overview row pointing at the new memo.