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:
parent
71ae259877
commit
c93628a017
6 changed files with 294 additions and 7 deletions
|
|
@ -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"}:
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue