Finish approval engine spine

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
tegwick 2026-09-01 23:45:48 +02:00
parent e7c210bf56
commit c3f1dfbc07
18 changed files with 526 additions and 75 deletions

View file

@ -1,4 +1,4 @@
"""HTTP surface. Introspection and mutation; never a decision; never consume."""
"""HTTP surface. Introspection and lifecycle mutation; never a decision."""
from __future__ import annotations
@ -87,8 +87,6 @@ class App:
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
approval_id = parts[2]
rest = parts[3:]
if rest == ["consume"] or path.endswith("/consume"):
return 404, {"error": "not_found", "message": "consume is not implemented"}
if not rest and method == "GET":
return 200, self.engine.get(approval_id).as_dict()
if rest == ["claim"] and method == "GET":
@ -107,7 +105,14 @@ class App:
if rest == ["supersede"] and method == "POST":
data = _read_json(environ)
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
if "check" in path or path.endswith("/authorize") or path.endswith("/consume"):
if rest == ["consume"] and method == "POST":
data = _read_json(environ)
return 200, self.engine.consume(
approval_id,
data.get("request_digest"),
decision_id=data.get("decision_id"),
)
if "check" in path or path.endswith("/authorize"):
return 404, {"error": "not_found", "message": "no such surface"}
return 404, {"error": "not_found", "message": path}

View file

@ -1,8 +1,8 @@
"""SQLite-backed approval object, closed machine, local outbox.
All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT.
There is no public consume; _cas_consume exists only so use-class emission
can be tested without guessing GH-WP-0002-T06.
Consumption follows GH-DEC-2026-003: the PEP presents the decision binding's
request digest before the protected side effect.
"""
from __future__ import annotations
@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS approvals (
expires_at TEXT NOT NULL,
required_count INTEGER NOT NULL,
superseded_by TEXT,
consumed_digest TEXT,
consumed_decision_id TEXT,
consumed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -109,12 +112,15 @@ class Approval:
expires_at: str
required_count: int
superseded_by: str | None
consumed_digest: str | None
consumed_decision_id: str | None
consumed_at: str | None
created_at: str
updated_at: str
entries: list[dict[str, Any]]
def as_dict(self) -> dict[str, Any]:
return {
result = {
"id": self.id,
"status": self.status,
"binding": {
@ -129,6 +135,17 @@ class Approval:
"created_at": self.created_at,
"updated_at": self.updated_at,
}
if self.consumed_digest:
result["consumption"] = {
"request_digest": self.consumed_digest,
**(
{"decision_id": self.consumed_decision_id}
if self.consumed_decision_id
else {}
),
"consumed_at": self.consumed_at,
}
return result
class Engine:
@ -172,6 +189,12 @@ class Engine:
conn = self._conn()
try:
conn.executescript(SCHEMA)
columns = {
row["name"] for row in conn.execute("PRAGMA table_info(approvals)").fetchall()
}
for name in ("consumed_digest", "consumed_decision_id", "consumed_at"):
if name not in columns:
conn.execute(f"ALTER TABLE approvals ADD COLUMN {name} TEXT")
conn.commit()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
@ -295,6 +318,9 @@ class Engine:
expires_at=row["expires_at"],
required_count=row["required_count"],
superseded_by=row["superseded_by"],
consumed_digest=row["consumed_digest"],
consumed_decision_id=row["consumed_decision_id"],
consumed_at=row["consumed_at"],
created_at=row["created_at"],
updated_at=row["updated_at"],
entries=entries,
@ -487,16 +513,51 @@ class Engine:
raise
return self.get(approval_id)
def _cas_consume(self, approval_id: str) -> Approval:
"""Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06."""
def consume(
self,
approval_id: str,
request_digest: str | None,
*,
decision_id: str | None = None,
) -> dict[str, Any]:
"""Atomically spend an approval for one decision-bound request.
Repeating the same request digest is an idempotent success. A different
digest against a consumed object conflicts and the caller must not act.
"""
if request_digest is None:
raise Unprocessable("request_digest is required")
if not isinstance(request_digest, str):
raise Unprocessable("request_digest must be a string")
digest = require_digest(request_digest)
if decision_id is not None and (
not isinstance(decision_id, str) or not decision_id
):
raise Unprocessable("decision_id must be a non-empty string")
conn = self._conn()
now = iso(self.now())
idempotent = False
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
if row is None:
conn.rollback()
raise NotFound(approval_id)
if row["status"] == "consumed":
if row["consumed_digest"] != digest:
conn.rollback()
raise Conflict("approval already consumed for a different request digest")
idempotent = True
stored_decision_id = row["consumed_decision_id"]
conn.commit()
return {
"approval_id": approval_id,
"status": "consumed",
"request_digest": digest,
**({"decision_id": stored_decision_id} if stored_decision_id else {}),
"consumed_at": row["consumed_at"],
"idempotent": idempotent,
}
if row["status"] != "approved":
conn.rollback()
raise Conflict(f"cannot consume from status {row['status']}")
@ -504,9 +565,10 @@ class Engine:
conn.rollback()
raise Conflict("cannot consume outside validity window")
cur = conn.execute(
"UPDATE approvals SET status='consumed', updated_at=? "
"UPDATE approvals SET status='consumed', consumed_digest=?, "
"consumed_decision_id=?, consumed_at=?, updated_at=? "
"WHERE id=? AND status='approved'",
(now, approval_id),
(digest, decision_id, now, now, approval_id),
)
if cur.rowcount != 1:
conn.rollback()
@ -516,7 +578,11 @@ class Engine:
"use",
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"]},
extra={
"binding_digest": row["binding_digest"],
"request_digest": digest,
**({"decision_id": decision_id} if decision_id else {}),
},
)
conn.commit()
except sqlite3.Error as exc:
@ -525,7 +591,14 @@ class Engine:
except Exception:
conn.rollback()
raise
return self.get(approval_id)
return {
"approval_id": approval_id,
"status": "consumed",
"request_digest": digest,
**({"decision_id": decision_id} if decision_id else {}),
"consumed_at": now,
"idempotent": idempotent,
}
def _outbox_insert(
self,