APPROVAL-WP-0007: record the verified revoker and park the inactive plans

Schema v9 stores who revoked an approval and an optional bounded reason.
The bounded-execution carrier example is proven in process. APPROVAL-WP-0005,
0006, and 0007 are blocked because they are not current work. Task statuses
stay as they were, apart from T03 and T04, which are done in source.

Assistant: grok
Assistant-Session: 01a1b5fd-0c33-7ba1-b383-53937d525595
This commit is contained in:
tegwick 2026-11-07 14:10:01 +01:00
parent f41a9ca82c
commit 4aa0b75449
17 changed files with 599 additions and 36 deletions

View file

@ -43,7 +43,10 @@ EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
# class and says nothing about the quiet one — here that is `revocation`, which
# is the class whose silence actually matters.
HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation")
LATEST_SCHEMA_VERSION = 8
LATEST_SCHEMA_VERSION = 9
# APPROVAL-WP-0007-T03. A revocation reason is optional evidence, one line,
# bounded so a caller cannot put an unbounded blob in the local outbox.
REVOCATION_REASON_MAX = 200
# APPROVAL-WP-0007-T01: authenticator assurance levels, weakest first. A level
# outside this list is unknown, and an unknown level never meets a floor.
ASSURANCE_LEVELS = ("aal1", "aal2", "aal3")
@ -108,6 +111,12 @@ CREATE TABLE IF NOT EXISTS approvals (
human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)),
required_assurance TEXT CHECK (required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3')),
actor_consumes INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1)),
revoked_by_subject TEXT,
revoked_by_principal_type TEXT CHECK (
revoked_by_principal_type IS NULL OR revoked_by_principal_type IN ('human', 'service', 'agent')
),
revoked_by_assurance TEXT,
revocation_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -167,6 +176,10 @@ class Approval:
human_control: bool
required_assurance: str | None
actor_consumes: bool
revoked_by_subject: str | None
revoked_by_principal_type: str | None
revoked_by_assurance: str | None
revocation_reason: str | None
actor: str
principal: str
action: str
@ -208,6 +221,17 @@ class Approval:
if self.actor_consumes:
# APPROVAL-WP-0007-T02, same rule: stated only when declared.
result["binding"]["actor_consumes"] = True
if self.revoked_by_subject:
# APPROVAL-WP-0007-T03. Absent when no verified revoker was
# recorded, including legacy rows and internal revoke calls.
revoked_by = {
"subject": self.revoked_by_subject,
"principal_type": self.revoked_by_principal_type,
"assurance": json.loads(self.revoked_by_assurance),
}
if self.revocation_reason:
revoked_by["reason"] = self.revocation_reason
result["revoked_by"] = revoked_by
if self.consumed_digest:
result["consumption"] = {
"request_digest": self.consumed_digest,
@ -317,6 +341,18 @@ class Engine:
"ALTER TABLE approvals ADD COLUMN actor_consumes "
"INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))"
)
if "revoked_by_subject" not in approval_columns:
# v9, APPROVAL-WP-0007-T03. Legacy revocations stay NULL: the
# revoker was not verified into the row, and inventing one
# would manufacture evidence.
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_subject TEXT")
conn.execute(
"ALTER TABLE approvals ADD COLUMN revoked_by_principal_type TEXT CHECK ("
"revoked_by_principal_type IS NULL OR "
"revoked_by_principal_type IN ('human', 'service', 'agent'))"
)
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_assurance TEXT")
conn.execute("ALTER TABLE approvals ADD COLUMN revocation_reason TEXT")
entry_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
}
@ -561,6 +597,10 @@ class Engine:
human_control=bool(row["human_control"]),
required_assurance=row["required_assurance"],
actor_consumes=bool(row["actor_consumes"]),
revoked_by_subject=row["revoked_by_subject"],
revoked_by_principal_type=row["revoked_by_principal_type"],
revoked_by_assurance=row["revoked_by_assurance"],
revocation_reason=row["revocation_reason"],
actor=row["actor"],
principal=row["principal"],
action=row["action"],
@ -685,8 +725,58 @@ class Engine:
raise
return self.get(approval_id)
def revoke(self, approval_id: str) -> Approval:
return self._terminal(approval_id, "revoked", "revocation")
def revoke(
self,
approval_id: str,
*,
revoker: Identity | None = None,
reason: str | None = None,
) -> Approval:
"""Terminal revoke. CAS and the closed machine are unchanged.
A verified revoker is recorded when the HTTP boundary supplies one.
The revoker is not required to be the bound principal: revocation
must stay possible without the holder. An internal call with no
revoker still revokes and leaves the evidence fields empty.
"""
return self._terminal(
approval_id,
"revoked",
"revocation",
revoked_by=self._revoker_evidence(revoker, reason),
)
def _revoker_evidence(
self, revoker: Identity | None, reason: str | None
) -> dict[str, str] | None:
if reason is not None and revoker is None:
raise Unprocessable("revocation reason requires a verified revoker")
if reason is not None:
if not isinstance(reason, str) or reason != reason.strip() or not reason:
raise Unprocessable(
"revocation reason must be a non-empty string without surrounding space"
)
if len(reason) > REVOCATION_REASON_MAX or any(ord(ch) < 32 for ch in reason):
raise Unprocessable(
f"revocation reason must be a single line of at most {REVOCATION_REASON_MAX} characters"
)
if revoker is None:
return None
if revoker.principal_type not in {"human", "service", "agent"} or not revoker.subject:
raise Unprocessable("verified revoker subject and principal_type are required")
if not isinstance(revoker.assurance, dict) or not revoker.assurance:
raise Unprocessable("verified revoker assurance is required")
assurance = json.dumps(revoker.assurance, sort_keys=True)
if len(assurance) > 1024:
raise Unprocessable("verified revoker assurance is too large")
evidence = {
"subject": revoker.subject,
"principal_type": revoker.principal_type,
"assurance": assurance,
}
if reason is not None:
evidence["reason"] = reason
return evidence
def supersede(
self,
@ -809,7 +899,14 @@ class Engine:
"successor_created": created_successor,
}
def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval:
def _terminal(
self,
approval_id: str,
status: str,
event_class: str,
*,
revoked_by: dict[str, str] | None = None,
) -> Approval:
conn = self._conn()
now = iso(self.now())
try:
@ -818,10 +915,27 @@ class Engine:
if row is None:
conn.rollback()
raise NotFound(approval_id)
subject = principal_type = assurance = reason = None
extra: dict[str, Any] = {"binding_digest": row["binding_digest"]}
if revoked_by is not None:
subject = revoked_by["subject"]
principal_type = revoked_by["principal_type"]
assurance = revoked_by["assurance"]
reason = revoked_by.get("reason")
recorded = {
"subject": subject,
"principal_type": principal_type,
"assurance": json.loads(assurance),
}
if reason is not None:
recorded["reason"] = reason
extra["revoked_by"] = recorded
cur = conn.execute(
"UPDATE approvals SET status=?, updated_at=? "
"UPDATE approvals SET status=?, updated_at=?, "
"revoked_by_subject=?, revoked_by_principal_type=?, "
"revoked_by_assurance=?, revocation_reason=? "
"WHERE id=? AND status IN ('requested','approved')",
(status, now, approval_id),
(status, now, subject, principal_type, assurance, reason, approval_id),
)
if cur.rowcount != 1:
conn.rollback()
@ -831,7 +945,7 @@ class Engine:
event_class,
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"]},
extra=extra,
)
conn.commit()
except sqlite3.Error as exc: