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
This commit is contained in:
tegwick 2026-09-27 20:23:56 +02:00
parent 71ae259877
commit c93628a017
6 changed files with 294 additions and 7 deletions

View file

@ -155,7 +155,9 @@ class App:
if rest == ["supersede"] and method == "POST":
self.identity(environ, "approval:supersede")
data = _read_json(environ)
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
return 200, self.engine.supersede(
approval_id, data.get("successor_id"), data.get("validity")
)
if rest == ["consume"] and method == "POST":
identity = self.identity(environ, "approval:consume")
if identity.principal_type not in {"service", "agent"}:

View file

@ -588,8 +588,37 @@ class Engine:
def revoke(self, approval_id: str) -> Approval:
return self._terminal(approval_id, "revoked", "revocation")
def supersede(self, approval_id: str, successor_id: str | None = None) -> dict[str, Any]:
def supersede(
self,
approval_id: str,
successor_id: str | None = None,
validity: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Link a successor approval to `approval_id` and retire it.
From `requested`/`approved` (MUTABLE), this is ordinary supersession:
the successor inherits the original's validity window unchanged.
From `expired`, this is reissuance: an already-expired approval
cannot be reopened in place (`expired` stays a terminal state, per
SCOPE.md's closed state machine), but it can be linked to a fresh
successor that carries a *new* `validity` window -- required
explicitly here so a caller cannot silently mint a successor that is
already expired too. The original's status becomes `superseded`
(not left as a dead-end `expired`), `superseded_by` points at the
successor, and the full row remains queryable: linked, not deleted
(informed-decision's own hard rule against inferring or hiding
approval history applies equally here).
"""
successor_id = successor_id or str(uuid4())
reissue_not_before = reissue_expires_at = None
if validity is not None:
reissue_not_before = validity.get("not_before") or iso(self.now())
reissue_expires_at = validity.get("expires_at")
if not reissue_expires_at:
raise Unprocessable("validity.expires_at is required to reissue")
if parse_iso(reissue_expires_at) <= parse_iso(reissue_not_before):
raise Unprocessable("expires_at must be after not_before")
conn = self._conn()
now = iso(self.now())
try:
@ -598,13 +627,16 @@ class Engine:
if row is None:
conn.rollback()
raise NotFound(approval_id)
if row["status"] not in MUTABLE:
reissuing_expired = row["status"] == "expired" and validity is not None
if row["status"] not in MUTABLE and not reissuing_expired:
conn.rollback()
if row["status"] == "expired":
raise Conflict("reissuing an expired approval requires a fresh validity window")
raise Conflict(f"cannot supersede in status {row['status']}")
cur = conn.execute(
"UPDATE approvals SET status='superseded', superseded_by=?, updated_at=? "
"WHERE id=? AND status IN ('requested','approved')",
(successor_id, now, approval_id),
"WHERE id=? AND status=?",
(successor_id, now, approval_id, row["status"]),
)
if cur.rowcount != 1:
conn.rollback()
@ -634,8 +666,8 @@ class Engine:
row["action"],
row["purpose"],
row["target_json"],
row["not_before"],
row["expires_at"],
reissue_not_before if validity is not None else row["not_before"],
reissue_expires_at if validity is not None else row["expires_at"],
row["required_count"],
None,
row["pdp_path"],