approval-engine/workplans/APPROVAL-WP-0003-reissue-expired-approvals.md
tegwick 093c51a245 chore(consistency): sync hub ids and work records [auto]
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 169987@bnt-lap001
Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999
2026-09-27 20:24:46 +02:00

4.9 KiB

id type title domain repo status flavor owner topic_slug created updated origin origin_ref state_hub_workstream_id
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 acda6e7a-81fe-50ba-a232-5aa70fc3ed93

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
state_hub_task_id: "7e95cd54-5183-5c50-8444-ae146ca9b55a"

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.