Implement the engine spine: claim, outbox, machine, API

Contracts first (T02–T04): approval claim schema with issuer, freshness,
and binding digest; local transactional outbox wire; load-bearing cadence
as heartbeat or reconciliation (layer.yaml declared).

Then the object (T06–T08): SQLite closed state machine, CAS supersession,
distinct-approver fail-closed, revocation without holder cooperation,
outbox insert in the same transaction. Tests fail the mutation when
emission fails, and revoke while the drain sink is down.

Introspection GET /v1/approvals/{id}/claim is a PIP fact, not a decision.
No public consume (T05 waits on GH-WP-0002-T06). Canon T-06 coverage for
wrong binding, expiry, revoke, and supersede.

FLEX-WP-0017 T03 is unblocked on this object; T05 remains blocked only on
consumption ordering.

Assistant: grok
Assistant-Session: 01a04ceb-2057-7e20-b0f9-c282964d5dd9
This commit is contained in:
tegwick 2026-08-29 12:52:49 +02:00
parent 624e43f554
commit 9c9528f5b2
29 changed files with 2121 additions and 26 deletions

8
.gitignore vendored
View file

@ -3,3 +3,11 @@
.claude/*
!.claude/rules/
!.claude/rules/*.md
__pycache__/
.pytest_cache/
*.egg-info/
.venv/
approvals.sqlite
*.sqlite-wal
*.sqlite-shm

11
Makefile Normal file
View file

@ -0,0 +1,11 @@
SHELL := /usr/bin/env bash
.DEFAULT_GOAL := test
test: ## Run unit tests
python3 -m pytest -q
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} \
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
.PHONY: test help

View file

@ -20,5 +20,13 @@ Flexibility here would be a defect. Graded, evidence-based progression belongs t
`maturity-engine`; the two engines are deliberate opposites.
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
Claim: [docs/approval-claim.md](docs/approval-claim.md).
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
layer model.
```bash
make test
python3 -m approval_engine.cli serve --db approvals.sqlite
```
There is no public `consume`. That waits on `GH-WP-0002-T06`.

View file

@ -93,14 +93,14 @@ the other side has not assented to.
## Current State
- Status: **seed**. The repository holds `INTENT.md`, this file, `layer.yaml`,
and a README. There is no store, no API, no outbox, no tests, no runtime.
- Layer declaration exists in this engine's own voice (INTENT frontmatter +
`layer.yaml`). Conformance checks against Tooling contact are vacuously
clean: there is no code to contact anything.
- Consumption ordering remains unresolved estate-wide (`GH-WP-0002-T06`).
- Taxonomy request-claim schema is proposed, not assigned (statute §17).
- Work: `APPROVAL-WP-0001`.
- Status: **first-cut spine**. SQLite-backed object, closed machine, local
outbox, WSGI introspection API, claim contract. Not a production deploy.
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
`cadence.yaml`. No Tooling contacts.
- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on
`GH-WP-0002-T06`).
- Taxonomy request-claim schema is still unassigned; the local claim yields.
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
## How It Fits
@ -143,9 +143,32 @@ it does not become a Railiance axis.
## Provided Capabilities
None. The spine described above is INTENT and first-cut SCOPE, not a shipped
surface. No store, no API, no claim, no outbox.
```capability
type: api
title: Approval introspection claim
description: >
GET /v1/approvals/{id}/claim returns an input claim with identifier,
canonical-binding digest, optional PDP digest, issuer, and freshness.
It does not decide whether an action is permitted.
keywords: [approval, claim, pip, digest, freshness]
```
When the first surface ships, this section gains `capability` blocks for the
introspection API, the mutation API, and the outbox. Until then, declaring
capabilities would advertise a PIP that does not exist.
```capability
type: api
title: Approval object lifecycle
description: >
Create, collect authenticated entries with distinct-approver counting,
atomically supersede, and revoke without holder cooperation. No public
consume until consumption ordering is settled.
keywords: [approval, state-machine, cas, revocation, supersession]
```
```capability
type: infrastructure
title: Local transactional outbox
description: >
Issuance, use, supersession, revocation, and heartbeat rows are inserted
in the same SQLite transaction as the mutation. Drain is asynchronous;
an audit-core outage does not block revocation.
keywords: [outbox, audit-core, emission-atomicity, heartbeat]
```

View file

@ -0,0 +1,24 @@
"""approval-engine — PIP for the approval object."""
from .binding import binding_digest, canonical_binding
from .errors import (
ApprovalError,
Conflict,
DuplicateApprover,
NotFound,
StoreUnavailable,
Unprocessable,
)
from .store import Engine
__all__ = [
"Engine",
"ApprovalError",
"Conflict",
"DuplicateApprover",
"NotFound",
"StoreUnavailable",
"Unprocessable",
"binding_digest",
"canonical_binding",
]

144
approval_engine/api.py Normal file
View file

@ -0,0 +1,144 @@
"""HTTP surface. Introspection and mutation; never a decision; never consume."""
from __future__ import annotations
import json
from typing import Any, Callable
from .errors import ApprovalError
from .store import Engine
FORBIDDEN_DECISION_KEYS = frozenset({"effect", "decision", "allow", "deny"})
def _read_json(environ: dict[str, Any]) -> dict[str, Any]:
length = int(environ.get("CONTENT_LENGTH") or 0)
if length == 0:
return {}
raw = environ["wsgi.input"].read(length)
if not raw:
return {}
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApprovalError("invalid json") from exc
if not isinstance(data, dict):
raise ApprovalError("json object required")
return data
def _assert_not_decision(payload: Any) -> None:
if isinstance(payload, dict):
bad = FORBIDDEN_DECISION_KEYS & set(payload)
if bad:
raise RuntimeError(f"decision-shaped keys leaked: {sorted(bad)}")
for value in payload.values():
_assert_not_decision(value)
elif isinstance(payload, list):
for item in payload:
_assert_not_decision(item)
class App:
def __init__(self, engine: Engine) -> None:
self.engine = engine
def __call__(self, environ: dict[str, Any], start_response: Callable) -> list[bytes]:
method = environ.get("REQUEST_METHOD", "GET").upper()
path = environ.get("PATH_INFO") or "/"
try:
status, body = self.dispatch(method, path, environ)
except ApprovalError as exc:
status, body = exc.http_status, {"error": exc.reason_code, "message": str(exc)}
except ValueError as exc:
status, body = 422, {"error": "unprocessable", "message": str(exc)}
_assert_not_decision(body)
payload = json.dumps(body, sort_keys=True).encode("utf-8")
start_response(
f"{status} {'OK' if status < 400 else 'ERROR'}",
[
("Content-Type", "application/json"),
("Content-Length", str(len(payload))),
],
)
return [payload]
def dispatch(self, method: str, path: str, environ: dict[str, Any]) -> tuple[int, dict[str, Any]]:
if path in ("/readyz", "/v1/readyz") and method == "GET":
self.engine.outbox_stats()
return 200, {"status": "ok", "store": "ok"}
if path == "/v1/cadence" and method == "GET":
return 200, self.engine.transition_counts() | {"form": "heartbeat-or-reconciliation"}
if path == "/v1/outbox/stats" and method == "GET":
return 200, self.engine.outbox_stats()
if path == "/v1/heartbeat" and method == "POST":
return 200, self.engine.emit_heartbeat()
if path == "/v1/approvals" and method == "POST":
data = _read_json(environ)
obj = self.engine.create(
data.get("binding") or {},
data.get("validity") or {},
int(data.get("required_count") or 1),
pdp_digest=data.get("pdp_digest"),
approval_id=data.get("id"),
)
return 201, obj.as_dict()
parts = path.strip("/").split("/")
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":
return 200, self.engine.claim(approval_id)
if rest == ["entries"] and method == "POST":
data = _read_json(environ)
obj = self.engine.add_entry(
approval_id,
data.get("subject_id") or "",
assurance=data.get("assurance"),
evidence_ref=data.get("evidence_ref"),
)
return 200, obj.as_dict()
if rest == ["revoke"] and method == "POST":
return 200, self.engine.revoke(approval_id).as_dict()
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"):
return 404, {"error": "not_found", "message": "no such surface"}
return 404, {"error": "not_found", "message": path}
def call(app: App, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
"""In-process WSGI helper for tests."""
raw = json.dumps(body or {}).encode("utf-8") if body is not None else b""
environ = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
"wsgi.input": _Bytes(raw),
"CONTENT_LENGTH": str(len(raw)) if body is not None else "0",
"QUERY_STRING": "",
}
status_headers: list[tuple[str, list]] = []
def start_response(status: str, headers: list[tuple[str, str]]) -> None:
status_headers.append((status, headers))
result = b"".join(app(environ, start_response))
code = int(status_headers[0][0].split()[0])
return code, json.loads(result.decode("utf-8"))
class _Bytes:
def __init__(self, data: bytes) -> None:
self._data = data
def read(self, n: int = -1) -> bytes:
if n < 0:
out, self._data = self._data, b""
return out
out, self._data = self._data[:n], self._data[n:]
return out

View file

@ -0,0 +1,45 @@
"""Canonical binding and native digest.
The native digest is SHA-256 over sorted-key JSON of five fields. It is not
Go json.Marshal of a flex-auth CheckRequest that value, when known at
issue, is stored separately as pdp_digest.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
BINDING_FIELDS = ("action", "actor", "principal", "purpose", "target")
def canonical_binding(binding: dict[str, Any]) -> dict[str, Any]:
missing = [k for k in BINDING_FIELDS if k not in binding or binding[k] in (None, "")]
if missing:
raise ValueError(f"binding missing {missing}")
if not isinstance(binding["target"], dict):
raise ValueError("binding.target must be an object")
return {k: binding[k] for k in BINDING_FIELDS}
def canonical_json(value: Any) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
def binding_digest(binding: dict[str, Any]) -> str:
payload = canonical_json(canonical_binding(binding))
return "sha256:" + hashlib.sha256(payload).hexdigest()
def require_digest(value: str | None) -> str | None:
if value is None:
return None
if not DIGEST_RE.match(value):
raise ValueError("digest must match sha256:<64 lowercase hex>")
return value

28
approval_engine/cli.py Normal file
View file

@ -0,0 +1,28 @@
from __future__ import annotations
import argparse
from wsgiref.simple_server import make_server
from .api import App
from .store import Engine
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="approval-engine")
sub = parser.add_subparsers(dest="cmd", required=True)
serve = sub.add_parser("serve", help="serve the introspection API")
serve.add_argument("--db", default="approvals.sqlite")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8787)
args = parser.parse_args(argv)
if args.cmd == "serve":
engine = Engine(args.db)
httpd = make_server(args.host, args.port, App(engine))
print(f"approval-engine on http://{args.host}:{args.port} db={args.db}")
httpd.serve_forever()
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main())

28
approval_engine/errors.py Normal file
View file

@ -0,0 +1,28 @@
class ApprovalError(Exception):
http_status = 400
reason_code = "error"
class NotFound(ApprovalError):
http_status = 404
reason_code = "not_found"
class Conflict(ApprovalError):
http_status = 409
reason_code = "conflict"
class DuplicateApprover(ApprovalError):
http_status = 409
reason_code = "duplicate_approver"
class Unprocessable(ApprovalError):
http_status = 422
reason_code = "unprocessable"
class StoreUnavailable(ApprovalError):
http_status = 503
reason_code = "store_unavailable"

715
approval_engine/store.py Normal file
View file

@ -0,0 +1,715 @@
"""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.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from uuid import uuid4
from .binding import binding_digest, canonical_binding, require_digest
from .errors import (
Conflict,
DuplicateApprover,
NotFound,
StoreUnavailable,
Unprocessable,
)
ISSUER = "approval-engine"
CLAIM_SCHEMA = "0.1"
YIELDS_TO = "net-kingdom taxonomy request-claim schema (statute §17; unassigned)"
DEFAULT_FRESHNESS_TTL = 30
AUDIT_SCHEMA = "audit-core.event.v1alpha1"
SOURCE = "approval-engine"
SCOPE = "netkingdom-approvals"
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
SCHEMA = """
CREATE TABLE IF NOT EXISTS approvals (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
binding_json TEXT NOT NULL,
binding_digest TEXT NOT NULL,
pdp_digest TEXT,
actor TEXT NOT NULL,
principal TEXT NOT NULL,
action TEXT NOT NULL,
purpose TEXT NOT NULL,
target_json TEXT NOT NULL,
not_before TEXT NOT NULL,
expires_at TEXT NOT NULL,
required_count INTEGER NOT NULL,
superseded_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
approval_id TEXT NOT NULL,
subject_id TEXT NOT NULL,
approved_at TEXT NOT NULL,
assurance TEXT,
evidence_ref TEXT,
PRIMARY KEY (approval_id, subject_id)
);
CREATE TABLE IF NOT EXISTS outbox (
event_id TEXT PRIMARY KEY,
class TEXT NOT NULL,
approval_id TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
drained_at TEXT
);
"""
ACTIVE = ("requested", "approved")
MUTABLE = ("requested", "approved")
def utc_now(clock: Callable[[], datetime] | None = None) -> datetime:
if clock is not None:
now = clock()
else:
now = datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return now.astimezone(timezone.utc).replace(microsecond=0)
def iso(ts: datetime) -> str:
return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat()
def parse_iso(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@dataclass
class Approval:
id: str
status: str
binding: dict[str, Any]
binding_digest: str
pdp_digest: str | None
actor: str
principal: str
action: str
purpose: str
target: dict[str, Any]
not_before: str
expires_at: str
required_count: int
superseded_by: str | None
created_at: str
updated_at: str
entries: list[dict[str, Any]]
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"status": self.status,
"binding": {
**self.binding,
"digest": self.binding_digest,
**({"pdp_digest": self.pdp_digest} if self.pdp_digest else {}),
},
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
"required_count": self.required_count,
"entries": self.entries,
"superseded_by": self.superseded_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class Engine:
def __init__(
self,
path: str | Path = ":memory:",
*,
clock: Callable[[], datetime] | None = None,
freshness_ttl: int = DEFAULT_FRESHNESS_TTL,
fail_outbox: bool = False,
tenant: str = "platform",
) -> None:
self.path = str(path)
self.clock = clock
self.freshness_ttl = freshness_ttl
self.fail_outbox = fail_outbox
self.tenant = tenant
self._local = threading.local()
self._init_schema()
def _connect(self) -> sqlite3.Connection:
try:
conn = sqlite3.connect(self.path, timeout=5, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
if self.path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
return conn
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
def _conn(self) -> sqlite3.Connection:
conn = getattr(self._local, "conn", None)
if conn is None:
conn = self._connect()
self._local.conn = conn
return conn
def _init_schema(self) -> None:
conn = self._conn()
try:
conn.executescript(SCHEMA)
conn.commit()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
def close(self) -> None:
conn = getattr(self._local, "conn", None)
if conn is not None:
conn.close()
self._local.conn = None
def now(self) -> datetime:
return utc_now(self.clock)
def create(
self,
binding: dict[str, Any],
validity: dict[str, str],
required_count: int = 1,
*,
pdp_digest: str | None = None,
approval_id: str | None = None,
) -> Approval:
if required_count < 1:
raise Unprocessable("required_count must be >= 1")
canon = canonical_binding(binding)
digest = binding_digest(canon)
pdp = require_digest(pdp_digest)
not_before = validity.get("not_before") or iso(self.now())
expires_at = validity.get("expires_at")
if not expires_at:
raise Unprocessable("validity.expires_at is required")
if parse_iso(expires_at) <= parse_iso(not_before):
raise Unprocessable("expires_at must be after not_before")
aid = approval_id or str(uuid4())
now = iso(self.now())
conn = self._conn()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"""INSERT INTO approvals (
id, status, binding_json, binding_digest, pdp_digest,
actor, principal, action, purpose, target_json,
not_before, expires_at, required_count, superseded_by,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
aid,
"requested",
json.dumps(canon, sort_keys=True),
digest,
pdp,
canon["actor"],
canon["principal"],
canon["action"],
canon["purpose"],
json.dumps(canon["target"], sort_keys=True),
not_before,
expires_at,
required_count,
None,
now,
now,
),
)
conn.commit()
except sqlite3.IntegrityError as exc:
conn.rollback()
raise Conflict("approval id already exists") from exc
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
raise
return self.get(aid)
def get(self, approval_id: str) -> Approval:
conn = self._conn()
try:
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
if row is None:
raise NotFound(approval_id)
return self._hydrate(row, persist_expiry=True)
def _hydrate(self, row: sqlite3.Row, persist_expiry: bool) -> Approval:
entries = [
{
"subject_id": e["subject_id"],
"approved_at": e["approved_at"],
"assurance": e["assurance"],
"evidence_ref": e["evidence_ref"],
}
for e in self._conn()
.execute(
"SELECT subject_id, approved_at, assurance, evidence_ref "
"FROM entries WHERE approval_id=? ORDER BY approved_at",
(row["id"],),
)
.fetchall()
]
status = row["status"]
if persist_expiry and status in ACTIVE:
now = self.now()
if now >= parse_iso(row["expires_at"]):
status = self._expire(row["id"], status)
binding = json.loads(row["binding_json"])
return Approval(
id=row["id"],
status=status,
binding=binding,
binding_digest=row["binding_digest"],
pdp_digest=row["pdp_digest"],
actor=row["actor"],
principal=row["principal"],
action=row["action"],
purpose=row["purpose"],
target=json.loads(row["target_json"]),
not_before=row["not_before"],
expires_at=row["expires_at"],
required_count=row["required_count"],
superseded_by=row["superseded_by"],
created_at=row["created_at"],
updated_at=row["updated_at"],
entries=entries,
)
def _expire(self, approval_id: str, from_status: str) -> str:
conn = self._conn()
now = iso(self.now())
try:
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute(
"UPDATE approvals SET status='expired', updated_at=? "
"WHERE id=? AND status=?",
(now, approval_id, from_status),
)
conn.commit()
return "expired" if cur.rowcount == 1 else from_status
except sqlite3.Error:
conn.rollback()
return from_status
def add_entry(
self,
approval_id: str,
subject_id: str,
*,
assurance: str | None = None,
evidence_ref: str | None = None,
) -> Approval:
if not subject_id:
raise Unprocessable("subject_id is required")
conn = self._conn()
now = iso(self.now())
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"] not in MUTABLE:
conn.rollback()
raise Conflict(f"cannot add entries in status {row['status']}")
try:
conn.execute(
"INSERT INTO entries (approval_id, subject_id, approved_at, assurance, evidence_ref) "
"VALUES (?,?,?,?,?)",
(approval_id, subject_id, now, assurance, evidence_ref),
)
except sqlite3.IntegrityError as exc:
conn.rollback()
raise DuplicateApprover(subject_id) from exc
count = conn.execute(
"SELECT COUNT(*) FROM entries WHERE approval_id=?", (approval_id,)
).fetchone()[0]
became_approved = False
if row["status"] == "requested" and count >= row["required_count"]:
conn.execute(
"UPDATE approvals SET status='approved', updated_at=? WHERE id=? AND status='requested'",
(now, approval_id),
)
became_approved = True
else:
conn.execute(
"UPDATE approvals SET updated_at=? WHERE id=?", (now, approval_id)
)
if became_approved:
self._outbox_insert(
conn,
"issuance",
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"], "required_count": row["required_count"]},
)
conn.commit()
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
raise
return self.get(approval_id)
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]:
successor_id = successor_id or str(uuid4())
conn = self._conn()
now = iso(self.now())
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"] not in MUTABLE:
conn.rollback()
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),
)
if cur.rowcount != 1:
conn.rollback()
raise Conflict("supersession lost the compare-and-swap")
existing = conn.execute("SELECT id FROM approvals WHERE id=?", (successor_id,)).fetchone()
created_successor = False
if existing is None:
conn.execute(
"""INSERT INTO approvals (
id, status, binding_json, binding_digest, pdp_digest,
actor, principal, action, purpose, target_json,
not_before, expires_at, required_count, superseded_by,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
successor_id,
"requested",
row["binding_json"],
row["binding_digest"],
row["pdp_digest"],
row["actor"],
row["principal"],
row["action"],
row["purpose"],
row["target_json"],
row["not_before"],
row["expires_at"],
row["required_count"],
None,
now,
now,
),
)
created_successor = True
self._outbox_insert(
conn,
"supersession",
approval_id,
actor=row["actor"],
extra={
"binding_digest": row["binding_digest"],
"superseded_by": successor_id,
},
)
conn.commit()
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
raise
return {
"superseded": self.get(approval_id).as_dict(),
"successor_id": successor_id,
"successor_created": created_successor,
}
def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval:
conn = self._conn()
now = iso(self.now())
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)
cur = conn.execute(
"UPDATE approvals SET status=?, updated_at=? "
"WHERE id=? AND status IN ('requested','approved')",
(status, now, approval_id),
)
if cur.rowcount != 1:
conn.rollback()
raise Conflict(f"cannot {status} from status {row['status']}")
self._outbox_insert(
conn,
event_class,
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"]},
)
conn.commit()
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
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."""
conn = self._conn()
now = iso(self.now())
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"] != "approved":
conn.rollback()
raise Conflict(f"cannot consume from status {row['status']}")
if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]):
conn.rollback()
raise Conflict("cannot consume outside validity window")
cur = conn.execute(
"UPDATE approvals SET status='consumed', updated_at=? "
"WHERE id=? AND status='approved'",
(now, approval_id),
)
if cur.rowcount != 1:
conn.rollback()
raise Conflict("consumption lost the compare-and-swap")
self._outbox_insert(
conn,
"use",
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"]},
)
conn.commit()
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
raise
return self.get(approval_id)
def _outbox_insert(
self,
conn: sqlite3.Connection,
event_class: str,
approval_id: str | None,
*,
actor: str | None,
extra: dict[str, Any] | None = None,
) -> str:
if self.fail_outbox:
raise StoreUnavailable("outbox insert failed")
if event_class not in EVENT_CLASSES:
raise Unprocessable(f"unknown event class {event_class}")
event_id = str(uuid4())
created = iso(self.now())
resource = f"approval:{approval_id}" if approval_id else "approval-engine:heartbeat"
details = {"class": event_class, **(extra or {})}
if approval_id:
details["approval_id"] = approval_id
payload = {
"schema_version": AUDIT_SCHEMA,
"event_id": event_id,
"observed_at": created,
"tenant": self.tenant,
"scope": SCOPE,
"source": SOURCE,
"actor": actor,
"action": f"approval.{event_class}",
"resource": resource,
"outcome": "success",
"reason": None,
"details": details,
}
conn.execute(
"INSERT INTO outbox (event_id, class, approval_id, payload_json, created_at, drained_at) "
"VALUES (?,?,?,?,?,NULL)",
(event_id, event_class, approval_id, json.dumps(payload, sort_keys=True), created),
)
return event_id
def claim(self, approval_id: str) -> dict[str, Any]:
obj = self.get(approval_id)
observed = self.now()
not_after = observed + timedelta(seconds=self.freshness_ttl)
state, valid_now, consumed, reason = self._evaluate(obj, observed)
binding = {
**canonical_binding(obj.binding),
"digest": obj.binding_digest,
}
if obj.pdp_digest:
binding["pdp_digest"] = obj.pdp_digest
return {
"schema_version": CLAIM_SCHEMA,
"kind": "approval-claim",
"yields_to": YIELDS_TO,
"issuer": ISSUER,
"approval_id": obj.id,
"state": state,
"valid_now": valid_now,
"consumed": consumed,
"binding": binding,
"freshness": {
"observed_at": iso(observed),
"ttl_seconds": self.freshness_ttl,
"not_after": iso(not_after),
},
"validity": {"not_before": obj.not_before, "expires_at": obj.expires_at},
"reason_code": reason,
}
def _evaluate(self, obj: Approval, now: datetime) -> tuple[str, bool, bool, str]:
consumed = obj.status == "consumed"
if obj.status == "revoked":
return "revoked", False, consumed, "revoked"
if obj.status == "superseded":
return "superseded", False, consumed, "superseded"
if obj.status == "expired" or now >= parse_iso(obj.expires_at):
return "expired", False, consumed, "expired"
if obj.status == "consumed":
return "consumed", False, True, "consumed"
if obj.status == "requested":
return "requested", False, False, "requested"
if obj.status == "approved":
if now < parse_iso(obj.not_before):
return "approved", False, False, "not_yet_valid"
if len({e["subject_id"] for e in obj.entries}) < obj.required_count:
return "approved", False, False, "insufficient_approvers"
return "valid", True, False, "ok"
return obj.status, False, consumed, obj.status
def emit_heartbeat(self) -> dict[str, Any]:
conn = self._conn()
counts = self.transition_counts()
counts["heartbeat"] += 1
try:
conn.execute("BEGIN IMMEDIATE")
event_id = self._outbox_insert(
conn,
"heartbeat",
None,
actor=None,
extra={"assertion": "nothing-to-report", "counts": counts},
)
conn.commit()
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
except Exception:
conn.rollback()
raise
return {"event_id": event_id, "assertion": "nothing-to-report", "counts": counts}
def transition_counts(self) -> dict[str, int]:
conn = self._conn()
try:
rows = conn.execute("SELECT class, COUNT(*) AS n FROM outbox GROUP BY class").fetchall()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
counts = {c: 0 for c in EVENT_CLASSES}
for row in rows:
counts[row["class"]] = row["n"]
return counts
def outbox_stats(self) -> dict[str, Any]:
conn = self._conn()
now = self.now()
try:
rows = conn.execute(
"SELECT event_id, class, created_at, drained_at FROM outbox"
).fetchall()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
pending = [r for r in rows if r["drained_at"] is None]
ages = [(now - parse_iso(r["created_at"])).total_seconds() for r in pending]
return {
"total": len(rows),
"pending": len(pending),
"max_age_seconds": max(ages) if ages else 0,
"counts": self.transition_counts(),
}
def undrained(self) -> list[dict[str, Any]]:
conn = self._conn()
try:
rows = conn.execute(
"SELECT event_id, class, approval_id, payload_json, created_at "
"FROM outbox WHERE drained_at IS NULL ORDER BY created_at"
).fetchall()
except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc
return [
{
"event_id": r["event_id"],
"class": r["class"],
"approval_id": r["approval_id"],
"payload": json.loads(r["payload_json"]),
"created_at": r["created_at"],
}
for r in rows
]
def drain(self, sink: Callable[[dict[str, Any]], None]) -> dict[str, int]:
"""Deliver undrained payloads. Sink failure leaves the row pending.
Object mutations are already committed; this must not roll them back.
"""
delivered = 0
failed = 0
conn = self._conn()
for item in self.undrained():
try:
sink(item["payload"])
except Exception:
failed += 1
continue
try:
conn.execute(
"UPDATE outbox SET drained_at=? WHERE event_id=? AND drained_at IS NULL",
(iso(self.now()), item["event_id"]),
)
conn.commit()
delivered += 1
except sqlite3.Error as exc:
conn.rollback()
raise StoreUnavailable(str(exc)) from exc
return {"delivered": delivered, "failed": failed}

38
cadence.yaml Normal file
View file

@ -0,0 +1,38 @@
# Source-side emission cadence for load-bearing approval evidence.
# Statute §9.6; detection surface GH-WP-0002-T04.
schema_version: "0.1"
source: approval-engine
kind: load-bearing
form: heartbeat-or-reconciliation
rate_monitoring: forbidden
heartbeat:
class: heartbeat
interval: 24h
assertion: nothing-to-report
missing: finding
reconciliation:
compare:
local: committed outbox counts per class
remote: "audit-core event counts where source=approval-engine"
divergence: finding
undrained_local: lag-not-divergence
lag_bound:
outbox_depth: 100
outbox_age: 1h
exceed: finding
classes:
issuance:
action: approval.issuance
use:
action: approval.use
note: "Internal CAS only until GH-WP-0002-T06."
supersession:
action: approval.supersession
revocation:
action: approval.revocation
heartbeat:
action: approval.heartbeat

120
docs/approval-claim.md Normal file
View file

@ -0,0 +1,120 @@
# Approval claim contract
**Schema:** [`../schemas/approval_claim.schema.json`](../schemas/approval_claim.schema.json)
**Version:** 0.1
**Issuer:** `approval-engine`
**Consumer:** `access-engine` (`flex-auth` until the governed rename)
This is the input claim `access-engine` consumes under statute §6.2. It is a
fact about an approval object. It is **not a decision**. An implementer can
satisfy this document without reading this engine's source.
Yields to the Taxonomy request-claim schema (statute §17) when that artifact
exists and is assented. This local shape is not permanent.
## Fetch
```text
GET /v1/approvals/{id}/claim
```
Fail-closed when this engine's store is unavailable (HTTP 503). An `audit-core`
outage does not affect this read.
There is no `/v1/check`, no `/authorize`, and no field named `effect`,
`decision`, `allow`, or `deny`. If a response contains those, it is out of
contract.
## What the claim carries
| Field | Why |
| --- | --- |
| `approval_id` | Reconstructable from the decision record. |
| `binding.digest` | Distinguishes *approved* from *approved for this exact request*. |
| `binding.pdp_digest` | Optional. When recorded at issue, compare to `NewDecisionBinding.request_digest`. |
| `issuer` | Always `approval-engine`. |
| `freshness` | So the PDP can state a deadline for this input class (§9.7.2), not a single fiction covering every source. |
| `valid_now` | Current-state predicate. Not permission. |
| `reason_code` | Why `valid_now` is false, when it is. |
`valid_now` is true only when all of:
1. enough distinct authenticated approvers have been recorded;
2. now is inside `validity.not_before``validity.expires_at`;
3. the object is not consumed, superseded, revoked, or expired.
Holding a claim with `valid_now: true` is not authority to act. It is one
input the decision point weighs.
## Canonical binding digest
The native digest is:
```text
sha256: + hex( SHA-256( canonical_json({action, actor, principal, purpose, target}) ) )
```
`canonical_json` is UTF-8 JSON with sorted keys at every object level and no
insignificant whitespace (`separators=(',', ':')`). `target` is an object; its
keys are sorted too.
Wrong-action, wrong-target, and wrong-scope are distinguishable because they
change that JSON and therefore the digest. A decision rendered against
approval A for request R cannot be replayed for request R' if the consumer
compares digests.
### Mapping from a flex-auth CheckRequest
| Claim binding | CheckRequest |
| --- | --- |
| `action` | `action` |
| `target` | `resource` (object) |
| `actor` | `subject.id` |
| `principal` | `subject.attributes.principal` if present, else `subject.id` |
| `purpose` | `context.purpose` |
Go's `json.Marshal` of a `CheckRequest` is **not** this canonical JSON (field
order and `omitempty` differ). Do not hash a CheckRequest with this function
and expect it to equal `NewDecisionBinding.request_digest`.
When the issuer recorded the PDP digest at issue time, it is in
`binding.pdp_digest`. **Prefer that comparison** at decision time:
```text
claim.binding.pdp_digest == decision.binding.request_digest
```
and still require `claim.approval_id` to match the approval named on the
request. Native `binding.digest` remains the identifier of *this engine's*
binding, and is what T-06 uses when no PDP digest was recorded.
## Freshness
Default TTL is **30 seconds** from `observed_at`. `not_after` is
`observed_at + ttl_seconds`. A PDP that needs a different deadline for the
approval input class states it in its own provenance; it must not invent
freshness this engine did not publish.
A claim used after `freshness.not_after` is stale. Stale is not the same as
`valid_now: false` — the object may still be valid; the *observation* is old.
Re-fetch.
## Required verification (consumer)
A production consumer of this claim, before treating it as an input, checks:
1. The claim resolved from this engine; an outage fails the action closed.
2. `issuer` is `approval-engine`.
3. `valid_now` is true and `consumed` is false.
4. `binding.digest` equals the digest of the binding the consumer computed
from the proposed action, **or** `binding.pdp_digest` equals the
`NewDecisionBinding` digest of that request.
5. `freshness.not_after` is still in the future.
6. `reason_code` is `ok`.
Local fixtures, workplan ids, and prose are not this claim.
## Examples
See [`../examples/claim.valid.json`](../examples/claim.valid.json) and
[`../examples/claim.revoked.json`](../examples/claim.revoked.json).

62
docs/emission-cadence.md Normal file
View file

@ -0,0 +1,62 @@
# Emission cadence — source declaration
Statute §9.6: approval evidence is **load-bearing** and **low-volume**. Rate
monitoring is the wrong form. A handful of revocations a month has no rate to
drop below; suppression is indistinguishable from a quiet month.
This file is the source side of `GH-WP-0002-T04`. That task is the detection
surface; this declaration is what it reads. Machine-readable copy:
[`../cadence.yaml`](../cadence.yaml).
## Form
**Heartbeat, plus reconciliation counts.** Not a rate.
### Heartbeat
A signed positive claim: *nothing to report*, together with per-class
transition counts since the previous heartbeat (or since process start on
the first). The claim can itself go missing, which is the point — silence
becomes a missing positive rather than a quiet month.
| Field | Value |
| --- | --- |
| Class | `heartbeat` |
| Interval | 24 hours (SHOULD also be emittable on demand) |
| Assertion | `nothing-to-report` |
| Counts | `{issuance, use, supersession, revocation}` of committed outbox rows |
| Missing heartbeat | **finding** |
### Reconciliation
Compare this engine's committed outbox counts per class to `audit-core`'s
accepted event counts for `source=approval-engine` and the corresponding
`action`. Divergence is a **finding**, not a log line.
| This engine | `audit-core` `action` |
| --- | --- |
| `issuance` | `approval.issuance` |
| `use` | `approval.use` |
| `supersession` | `approval.supersession` |
| `revocation` | `approval.revocation` |
| `heartbeat` | `approval.heartbeat` |
Undrained local rows are this engine's lag, not yet a divergence. A row
with `drained_at` set that `audit-core` does not hold is the omission
case §9.6 names.
## What is a finding
- No heartbeat arrives for more than one interval.
- `audit-core` count for a class is less than this engine's drained count
for that class.
- Outbox depth or age exceeds the lag bound in `cadence.yaml` (drain stuck).
None of these are rate drops. None of these are informational logs.
## Residual
Atomicity prevents accidental omission (crash between mutation and emit).
Cadence and reconciliation **detect** adversarial omission after the fact.
Nothing in the model prevents a compromised source from suppressing. That
residual is stated, not closed.

26
docs/flex-auth-handoff.md Normal file
View file

@ -0,0 +1,26 @@
# Handoff for `access-engine` / `FLEX-WP-0017`
`FLEX-WP-0017` T03 waited on a durable object that was not the State Hub
`/decisions/{uuid}` shape. That object is this engine.
## What exists
- Claim contract: [`approval-claim.md`](approval-claim.md)
- Introspection: `GET /v1/approvals/{id}/claim`
- Object: `POST /v1/approvals`, entries, revoke, supersede
- Native `binding.digest` plus optional `binding.pdp_digest` for
`NewDecisionBinding.request_digest`
T03 is unblocked on the **object**, not on a hub substitute. Validate the
claim before privileged production actions. Fail closed if this engine is
unreachable.
## What does not exist yet
Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is
no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that
contract, not on a missing object or a missing digest.
Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong
target, wrong action, later time, revoked, superseded). Consume-side replay
is out of scope until T05.

93
docs/outbox-contract.md Normal file
View file

@ -0,0 +1,93 @@
# Local transactional outbox contract
Coordinates with `GH-WP-0002-T02`. Boundary and locality are in `INTENT.md`
and statute §9.4; this is the wire.
An implementer **cannot** satisfy this contract by emitting synchronously to
`audit-core` inside the state-change transaction. That path is atomic and is
forbidden: an audit outage would become an inability to revoke.
## Rule
Every issuance, use, supersession, and revocation **inserts an outbox row in
the same transaction** that mutates the approval object. The durable queue
lives in this engine's own store. Drain is asynchronous, at-least-once.
`audit-core` dedupes on `event_id`; a replay does not fork the chain.
If the outbox insert cannot be committed, the mutation does not commit.
Emit-after-commit is a defect.
Heartbeat rows (see [`emission-cadence.md`](emission-cadence.md)) use the
same table and the same at-least-once drain. They are not coupled to an
object mutation.
## Event classes
| Class | When | `audit-core` `action` |
| --- | --- | --- |
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
| `use` | object becomes `consumed` (internal CAS; not a public API until `GH-WP-0002-T06`) | `approval.use` |
| `supersession` | object becomes `superseded` | `approval.supersession` |
| `revocation` | object becomes `revoked` | `approval.revocation` |
| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` |
Expiry is a clock crossing, persisted on observation, and is **not** an
emitted class. The validity window is already on the object.
## Outbox row
| Field | Type | Notes |
| --- | --- | --- |
| `event_id` | UUID | Stable. Drain retries reuse it. |
| `class` | enum above | |
| `approval_id` | UUID or null | Null only for heartbeat. |
| `payload` | object | The `audit-core.event.v1alpha1` record, ready to POST. |
| `created_at` | RFC 3339 UTC | |
| `drained_at` | RFC 3339 UTC or null | Set after a successful audit-core ack. |
## Payload (audit-core v1alpha1)
```json
{
"schema_version": "audit-core.event.v1alpha1",
"event_id": "<same as outbox.event_id>",
"observed_at": "<created_at>",
"tenant": "platform",
"scope": "netkingdom-approvals",
"source": "approval-engine",
"actor": "<actor or null for heartbeat>",
"action": "approval.revocation",
"resource": "approval:<approval_id>",
"outcome": "success",
"reason": null,
"details": {
"class": "revocation",
"approval_id": "<uuid>",
"binding_digest": "sha256:…",
"superseded_by": null
}
}
```
No secret values. `details` may add non-secret identifiers; it must not add a
validity verdict for consumers to branch on. `audit-core` MUST NOT expose an
approval-validity query; this payload does not invite one.
## Drain
1. Select undrained rows, oldest first.
2. POST each payload to `audit-core`.
3. On success, set `drained_at`.
4. On failure, leave the row; retry later. **Do not** roll back the object
mutation — it already committed with the row.
An `audit-core` outage therefore cannot block a revocation. This engine's
own store being unavailable can, and should: the change could not have been
recorded anyway.
## Forbidden shapes
- `audit-core` HTTP (or any client) inside `BEGIN``COMMIT` of a mutation.
- Best-effort publish after commit with no row.
- A second, non-local queue as the durability mechanism.
- Deduping in this engine instead of relying on `event_id` at `audit-core`.

View file

@ -0,0 +1,28 @@
{
"schema_version": "0.1",
"kind": "approval-claim",
"yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)",
"issuer": "approval-engine",
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
"state": "revoked",
"valid_now": false,
"consumed": false,
"binding": {
"action": "secrets.kv.destroy",
"target": {"id": "lane-openbao-root", "stage": "prod"},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"freshness": {
"observed_at": "2026-08-29T12:05:00+00:00",
"ttl_seconds": 30,
"not_after": "2026-08-29T12:05:30+00:00"
},
"validity": {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00"
},
"reason_code": "revoked"
}

28
examples/claim.valid.json Normal file
View file

@ -0,0 +1,28 @@
{
"schema_version": "0.1",
"kind": "approval-claim",
"yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)",
"issuer": "approval-engine",
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
"state": "valid",
"valid_now": true,
"consumed": false,
"binding": {
"action": "secrets.kv.destroy",
"target": {"id": "lane-openbao-root", "stage": "prod"},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"freshness": {
"observed_at": "2026-08-29T12:00:00+00:00",
"ttl_seconds": 30,
"not_after": "2026-08-29T12:00:30+00:00"
},
"validity": {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00"
},
"reason_code": "ok"
}

View file

@ -39,10 +39,9 @@ approval_validity_query: owned # current-state introspection, not a verdi
# audit-core's PostgreSQL custody).
tooling_contacts: []
# §11: record non-Tooling clients so the check is total. None exist: there
# is no runtime. Listed targets are the intended Engine APIs and the
# uncatalogued hub write, to be filled in as code appears rather than
# discovered later as silence.
# §11: record non-Tooling clients so the check is total. The SQLite store is
# this engine's own operational store, not a §4 Tooling row. Drain is a
# callback; no audit-core client is compiled in.
non_tooling_clients: []
intended_non_tooling_clients:
@ -64,9 +63,10 @@ intended_non_tooling_clients:
# low-volume load-bearing classes is reconciliation or a heartbeat.
evidence:
kind: load-bearing
atomicity: local-outbox # required; not yet implemented
atomicity: local-outbox
cadence_form: heartbeat-or-reconciliation
cadence_status: undeclared # APPROVAL-WP-0001-T04
cadence_status: declared
cadence: cadence.yaml
residual: adversarial-omission-at-compromised-source
custody: same-bound-as-every-other-source # §16 decided: no stronger archive

16
pyproject.toml Normal file
View file

@ -0,0 +1,16 @@
[project]
name = "approval-engine"
version = "0.1.0"
description = "PIP for the approval object: durable, authenticated, consumable, atomically supersedable."
readme = "README.md"
requires-python = ">=3.11"
[project.optional-dependencies]
dev = ["pytest"]
[project.scripts]
approval-engine = "approval_engine.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

View file

@ -0,0 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://approval-engine.netkingdom/schemas/approval_claim.schema.json",
"title": "ApprovalClaim",
"description": "Input claim that access-engine consumes. This is a fact about an approval object, not a decision. Yields to the Taxonomy request-claim schema (statute §17) when that artifact exists and is assented; do not treat this local shape as permanent.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"kind",
"issuer",
"approval_id",
"state",
"valid_now",
"consumed",
"binding",
"freshness",
"validity",
"reason_code"
],
"properties": {
"schema_version": { "const": "0.1" },
"kind": { "const": "approval-claim" },
"yields_to": {
"type": "string",
"description": "Taxonomy artifact this contract yields to. Informational; consumers must not branch on it."
},
"issuer": { "const": "approval-engine" },
"approval_id": { "type": "string", "format": "uuid" },
"state": {
"type": "string",
"enum": [
"requested",
"approved",
"valid",
"consumed",
"superseded",
"revoked",
"expired"
]
},
"valid_now": {
"type": "boolean",
"description": "True only when the object is approved, inside its validity window, and not consumed, superseded, revoked, or expired. Not a permission."
},
"consumed": { "type": "boolean" },
"binding": { "$ref": "#/$defs/binding" },
"freshness": { "$ref": "#/$defs/freshness" },
"validity": { "$ref": "#/$defs/validity" },
"reason_code": {
"type": "string",
"enum": [
"ok",
"requested",
"not_yet_valid",
"insufficient_approvers",
"expired",
"revoked",
"superseded",
"consumed"
]
}
},
"not": {
"anyOf": [
{ "required": ["effect"] },
{ "required": ["decision"] },
{ "required": ["allow"] },
{ "required": ["deny"] }
]
},
"$defs": {
"binding": {
"type": "object",
"additionalProperties": false,
"required": ["action", "target", "actor", "principal", "purpose", "digest"],
"properties": {
"action": { "type": "string", "minLength": 1 },
"target": { "type": "object" },
"actor": { "type": "string", "minLength": 1 },
"principal": { "type": "string", "minLength": 1 },
"purpose": { "type": "string", "minLength": 1 },
"digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$",
"description": "SHA-256 over the canonical JSON of action, actor, principal, purpose, target (sorted keys, RFC 8259). Distinguishes approved from approved-for-this-exact-request."
},
"pdp_digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$",
"description": "Optional. The flex-auth NewDecisionBinding request_digest recorded at issue time. When present, access-engine MUST compare this to the digest it already computes, not re-derive our native digest as a substitute."
}
}
},
"freshness": {
"type": "object",
"additionalProperties": false,
"required": ["observed_at", "ttl_seconds", "not_after"],
"properties": {
"observed_at": { "type": "string", "format": "date-time" },
"ttl_seconds": { "type": "integer", "minimum": 1 },
"not_after": { "type": "string", "format": "date-time" }
}
},
"validity": {
"type": "object",
"additionalProperties": false,
"required": ["not_before", "expires_at"],
"properties": {
"not_before": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" }
}
}
}
}

56
tests/conftest.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime, timezone
import pytest
from approval_engine.api import App
from approval_engine.store import Engine
FROZEN = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)
@pytest.fixture
def now():
return FROZEN
@pytest.fixture
def engine(now):
eng = Engine(":memory:", clock=lambda: now)
yield eng
eng.close()
@pytest.fixture
def app(engine):
return App(engine)
def binding(**overrides):
base = {
"action": "secrets.kv.destroy",
"target": {"id": "lane-openbao-root", "stage": "prod"},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
}
base.update(overrides)
return base
def validity():
return {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00",
}
def approve(engine, required=1, extra_binding=None, pdp_digest=None):
obj = engine.create(
extra_binding or binding(),
validity(),
required_count=required,
pdp_digest=pdp_digest,
)
for i in range(required):
obj = engine.add_entry(obj.id, f"user:approver-{i}")
return obj

69
tests/test_api.py Normal file
View file

@ -0,0 +1,69 @@
from approval_engine.api import call
from tests.conftest import binding, validity
def test_readyz(app):
status, body = call(app, "GET", "/v1/readyz")
assert status == 200
assert body["status"] == "ok"
def test_create_entry_claim_roundtrip(app):
status, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity(), "required_count": 1},
)
assert status == 201
aid = created["id"]
status, _ = call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
assert status == 200
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["kind"] == "approval-claim"
assert claim["valid_now"] is True
assert "effect" not in claim
assert "decision" not in claim
def test_no_check_or_authorize_or_consume(app):
for path in (
"/v1/check",
"/authorize",
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
"/v1/approvals/abc/consume",
):
status, body = call(app, "POST", path, {})
assert status == 404
assert "consume is not implemented" in body.get("message", "") or body["error"] == "not_found"
def test_claim_after_revoke(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
call(app, "POST", f"/v1/approvals/{aid}/revoke", {})
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["valid_now"] is False
assert claim["reason_code"] == "revoked"
def test_store_unavailable_is_503():
from approval_engine.api import App
from approval_engine.errors import StoreUnavailable
from approval_engine.store import Engine
class Dead(Engine):
def outbox_stats(self):
raise StoreUnavailable("down")
status, body = call(App(Dead.__new__(Dead)), "GET", "/v1/readyz")
assert status == 503
assert body["error"] == "store_unavailable"

22
tests/test_cadence.py Normal file
View file

@ -0,0 +1,22 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_layer_yaml_cadence_declared():
text = (ROOT / "layer.yaml").read_text()
assert "cadence_status: declared" in text
assert "cadence_form: heartbeat-or-reconciliation" in text
assert "cadence: cadence.yaml" in text
assert "cadence_status: undeclared" not in text
def test_cadence_yaml_forbids_rate_monitoring():
text = (ROOT / "cadence.yaml").read_text()
assert "kind: load-bearing" in text
assert "form: heartbeat-or-reconciliation" in text
assert "rate_monitoring: forbidden" in text
assert "missing: finding" in text
assert "divergence: finding" in text
for cls in ("issuance", "use", "supersession", "revocation", "heartbeat"):
assert f"{cls}:" in text

70
tests/test_cas.py Normal file
View file

@ -0,0 +1,70 @@
import tempfile
import threading
from pathlib import Path
from approval_engine.errors import Conflict
from approval_engine.store import Engine
from tests.conftest import approve
def test_second_supersession_loses(engine):
obj = approve(engine)
first = engine.supersede(obj.id)
assert engine.get(obj.id).status == "superseded"
assert first["successor_id"]
try:
engine.supersede(obj.id)
raise AssertionError("second supersession must conflict")
except Conflict:
pass
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "superseded"
def test_concurrent_supersessions_one_winner():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "a.sqlite"
setup = Engine(path)
obj = approve(setup)
setup.close()
winners: list[str] = []
errors: list[str] = []
barrier = threading.Barrier(2)
def race():
eng = Engine(path)
barrier.wait()
try:
result = eng.supersede(obj.id)
winners.append(result["successor_id"])
except Conflict as exc:
errors.append(str(exc))
finally:
eng.close()
threads = [threading.Thread(target=race) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(winners) == 1
assert len(errors) == 1
check = Engine(path)
assert check.get(obj.id).status == "superseded"
check.close()
def test_internal_consume_cas_once(engine):
obj = approve(engine)
engine._cas_consume(obj.id)
try:
engine._cas_consume(obj.id)
raise AssertionError("double consume must conflict")
except Conflict:
pass
claim = engine.claim(obj.id)
assert claim["consumed"] is True
assert claim["valid_now"] is False
assert claim["reason_code"] == "consumed"

View file

@ -0,0 +1,58 @@
import json
from pathlib import Path
from approval_engine.binding import binding_digest
from tests.conftest import approve, binding
ROOT = Path(__file__).resolve().parents[1]
def test_schema_forbids_decision_keys():
schema = json.loads((ROOT / "schemas/approval_claim.schema.json").read_text())
assert "not" in schema
kinds = [item["required"][0] for item in schema["not"]["anyOf"]]
assert set(kinds) >= {"effect", "decision", "allow", "deny"}
def test_claim_has_issuer_digest_freshness(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
assert claim["kind"] == "approval-claim"
assert claim["issuer"] == "approval-engine"
assert claim["approval_id"] == obj.id
assert claim["valid_now"] is True
assert claim["reason_code"] == "ok"
assert claim["binding"]["digest"] == binding_digest(binding())
assert claim["freshness"]["ttl_seconds"] == 30
assert claim["freshness"]["not_after"] == "2026-08-29T12:00:30+00:00"
assert "effect" not in claim
assert "decision" not in claim
assert "yields_to" in claim
def test_wrong_target_changes_digest():
a = binding()
b = binding(target={"id": "other-lane", "stage": "prod"})
assert binding_digest(a) != binding_digest(b)
def test_wrong_action_changes_digest():
assert binding_digest(binding()) != binding_digest(binding(action="secrets.kv.read"))
def test_pdp_digest_is_recorded_not_recomputed(engine):
pdp = "sha256:" + "ab" * 32
obj = approve(engine, pdp_digest=pdp)
claim = engine.claim(obj.id)
assert claim["binding"]["pdp_digest"] == pdp
assert claim["binding"]["digest"] == binding_digest(binding())
assert claim["binding"]["digest"] != pdp
def test_examples_are_claim_shaped():
for name in ("claim.valid.json", "claim.revoked.json"):
data = json.loads((ROOT / "examples" / name).read_text())
assert data["kind"] == "approval-claim"
assert data["issuer"] == "approval-engine"
for forbidden in ("effect", "decision", "allow", "deny"):
assert forbidden not in data

80
tests/test_machine.py Normal file
View file

@ -0,0 +1,80 @@
from datetime import datetime, timezone
from approval_engine.errors import Conflict, DuplicateApprover
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def test_requested_until_threshold(engine):
obj = engine.create(binding(), validity(), required_count=2)
assert obj.status == "requested"
obj = engine.add_entry(obj.id, "user:alice")
assert obj.status == "requested"
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "requested"
def test_threshold_approves_and_valid_now(engine):
obj = approve(engine, required=2)
assert obj.status == "approved"
assert len(obj.entries) == 2
claim = engine.claim(obj.id)
assert claim["state"] == "valid"
assert claim["valid_now"] is True
def test_duplicate_approver_fails_closed(engine):
obj = engine.create(binding(), validity(), required_count=2)
engine.add_entry(obj.id, "user:alice")
try:
engine.add_entry(obj.id, "user:alice")
raise AssertionError("duplicate must fail")
except DuplicateApprover:
pass
obj = engine.get(obj.id)
assert len(obj.entries) == 1
assert obj.status == "requested"
def test_revoke_without_holder_and_next_claim(engine):
obj = approve(engine)
engine.revoke(obj.id)
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["state"] == "revoked"
assert claim["reason_code"] == "revoked"
def test_expiry_on_observation():
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
eng = Engine(":memory:", clock=lambda: jumping["t"])
obj = eng.create(binding(), validity(), required_count=1)
obj = eng.add_entry(obj.id, "user:alice")
assert eng.claim(obj.id)["valid_now"] is True
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
claim = eng.claim(obj.id)
assert claim["state"] == "expired"
assert claim["valid_now"] is False
eng.close()
def test_not_yet_valid():
future = datetime(2026, 8, 29, 10, 0, tzinfo=timezone.utc)
eng = Engine(":memory:", clock=lambda: future)
obj = eng.create(binding(), validity(), required_count=1)
obj = eng.add_entry(obj.id, "user:alice")
claim = eng.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "not_yet_valid"
eng.close()
def test_cannot_revoke_twice(engine):
obj = approve(engine)
engine.revoke(obj.id)
try:
engine.revoke(obj.id)
raise AssertionError("second revoke must conflict")
except Conflict:
pass

72
tests/test_outbox.py Normal file
View file

@ -0,0 +1,72 @@
from approval_engine.errors import StoreUnavailable
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def test_issuance_queued_in_same_commit(engine):
obj = approve(engine)
pending = engine.undrained()
classes = [p["class"] for p in pending]
assert classes == ["issuance"]
assert pending[0]["approval_id"] == obj.id
payload = pending[0]["payload"]
assert payload["source"] == "approval-engine"
assert payload["action"] == "approval.issuance"
assert payload["event_id"] == pending[0]["event_id"]
def test_failed_outbox_rolls_back_mutation():
eng = Engine(":memory:", fail_outbox=True)
obj = eng.create(binding(), validity(), required_count=1)
try:
eng.add_entry(obj.id, "user:alice")
raise AssertionError("must fail")
except StoreUnavailable:
pass
obj = eng.get(obj.id)
assert obj.status == "requested"
assert obj.entries == []
assert eng.undrained() == []
eng.close()
def test_revoke_succeeds_when_drain_sink_is_down(engine):
obj = approve(engine)
engine.revoke(obj.id)
assert engine.get(obj.id).status == "revoked"
def down(_payload):
raise ConnectionError("audit-core unreachable")
result = engine.drain(down)
assert result["failed"] >= 1
assert engine.get(obj.id).status == "revoked"
assert any(p["class"] == "revocation" for p in engine.undrained())
def test_drain_marks_delivered(engine):
approve(engine)
sink: list[dict] = []
result = engine.drain(sink.append)
assert result["delivered"] == 1
assert result["failed"] == 0
assert engine.undrained() == []
assert sink[0]["action"] == "approval.issuance"
def test_heartbeat_is_positive_claim(engine):
approve(engine)
beat = engine.emit_heartbeat()
assert beat["assertion"] == "nothing-to-report"
assert beat["counts"]["issuance"] == 1
assert beat["counts"]["heartbeat"] == 1
pending = [p for p in engine.undrained() if p["class"] == "heartbeat"]
assert len(pending) == 1
assert pending[0]["payload"]["details"]["assertion"] == "nothing-to-report"
def test_revocation_event_class(engine):
obj = approve(engine)
engine.revoke(obj.id)
classes = [p["class"] for p in engine.undrained()]
assert "revocation" in classes

67
tests/test_t06_replay.py Normal file
View file

@ -0,0 +1,67 @@
"""Canon T-06 — Approval Replay.
Reuse a previously valid approval artifact for a different target, parameter
set, or later time. Pass: parameter binding, expiry, or replay protection
rejects the request.
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers
the object and claim side: wrong binding, expiry, revocation, supersession.
"""
from datetime import datetime, timezone
from approval_engine.binding import binding_digest
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def _consumer_accepts(claim, proposed_binding) -> bool:
if claim["issuer"] != "approval-engine":
return False
if not claim["valid_now"]:
return False
if claim["consumed"]:
return False
if claim["reason_code"] != "ok":
return False
return claim["binding"]["digest"] == binding_digest(proposed_binding)
def test_t06_wrong_target_rejected(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
assert _consumer_accepts(claim, binding()) is True
other = binding(target={"id": "some-other-lane", "stage": "prod"})
assert _consumer_accepts(claim, other) is False
def test_t06_wrong_action_rejected(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
other = binding(action="secrets.kv.read")
assert _consumer_accepts(claim, other) is False
def test_t06_later_time_expired():
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
eng = Engine(":memory:", clock=lambda: jumping["t"])
obj = approve(eng)
claim = eng.claim(obj.id)
assert _consumer_accepts(claim, binding()) is True
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
claim = eng.claim(obj.id)
assert claim["state"] == "expired"
assert _consumer_accepts(claim, binding()) is False
eng.close()
def test_t06_revoked_rejected(engine):
obj = approve(engine)
engine.revoke(obj.id)
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
def test_t06_superseded_rejected(engine):
obj = approve(engine)
engine.supersede(obj.id)
assert _consumer_accepts(engine.claim(obj.id), binding()) is False

View file

@ -56,11 +56,16 @@ its three tasks are already discharged by this file, `AGENTS.md`, and
```task
id: APPROVAL-WP-0001-T02
status: todo
status: done
priority: high
state_hub_task_id: "ca2ec789-b760-525a-9960-95d15760191e"
```
2026-08-29: `schemas/approval_claim.schema.json`, `docs/approval-claim.md`,
examples. Native digest is sorted-key JSON of action/actor/principal/purpose/target;
optional `pdp_digest` records `NewDecisionBinding.request_digest` at issue.
Yields to Taxonomy §17.
Specify the input claim `access-engine` consumes: approval identifier,
canonical-binding digest over the same binding the PDP already computes,
issuer (this engine), and freshness. Schema and examples in-repo.
@ -78,11 +83,17 @@ class.
```task
id: APPROVAL-WP-0001-T03
status: todo
status: done
priority: high
state_hub_task_id: "a2eb2e8f-9b49-598e-b4a1-fb93e65f1bf3"
```
2026-08-29: `docs/outbox-contract.md`. Classes issuance/use/supersession/revocation/heartbeat.
Payload is `audit-core.event.v1alpha1`. Synchronous `audit-core` inside the
mutation transaction is named forbidden.
Event classes (issuance, use, supersession, revocation, and the heartbeat
class T04 needs), same transaction as the object mutation, queue local to
this engine, at-least-once into the outbox (`audit-core` dedupes on event
@ -98,11 +109,17 @@ cannot satisfy it by emitting synchronously to `audit-core`.
```task
id: APPROVAL-WP-0001-T04
status: todo
status: done
priority: high
state_hub_task_id: "fce0df25-0106-5c0a-8187-87b956ca6220"
```
2026-08-29: `cadence.yaml` + `docs/emission-cadence.md`. `layer.yaml`
`evidence.cadence_status: declared`. Missing heartbeat and count divergence
are findings. Rate monitoring forbidden.
Approval evidence is load-bearing and low-volume. Rate monitoring is the
wrong form (statute §9.6). Publish the source-side declaration: expected
classes, the heartbeat (*nothing to report*, signed, itself able to go
@ -139,11 +156,17 @@ consumption from a decision record, no demo that "just consumes on allow".
```task
id: APPROVAL-WP-0001-T06
status: todo
status: done
priority: high
state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
```
2026-08-29: SQLite object + closed machine in `approval_engine/store.py`.
CAS supersession (concurrent test), distinct-approver fail-closed, revocation
without holder cooperation. `_cas_consume` is unexported.
Depends on T02 and T03. Implement the object and the machine in SCOPE:
identifiers; bindings (action, target, actor, principal, purpose, validity
window, approvers); authenticated entries; distinct-approver counting;
@ -161,11 +184,16 @@ closed on duplicates; revocation is effective at the next introspection.
```task
id: APPROVAL-WP-0001-T07
status: todo
status: done
priority: high
state_hub_task_id: "ea51499d-ced5-50fa-b852-396719a8c0f4"
```
2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped
keys and `/v1/check` / `/authorize` / `/consume`. Store unavailable → 503.
Depends on T02 and T06. An API that answers INTENT's question and returns
the T02 claim. No endpoint answers "may this actor do X". Fail-closed when
this engine's own store is unavailable.
@ -178,11 +206,17 @@ forbid a decision-shaped response.
```task
id: APPROVAL-WP-0001-T08
status: todo
status: done
priority: high
state_hub_task_id: "b9267011-71b3-5138-a3f9-5256374a6b3b"
```
2026-08-29: Outbox insert in the same IMMEDIATE transaction. Test fails the
transaction when emission fails (no leftover mutation). Revoke succeeds while
the drain sink is down; the revocation row stays undrained.
Depends on T03 and T06. Every issuance, use, supersession, and revocation
inserts the outbox row in the same transaction. Drain is asynchronous.
`audit-core` outage does not block a revocation.
@ -196,11 +230,18 @@ does not.
```task
id: APPROVAL-WP-0001-T09
status: todo
status: done
priority: medium
state_hub_task_id: "b4946568-5dda-5bd7-9941-83a5f0137700"
```
2026-08-29: `tests/test_t06_replay.py` encodes Canon T-06 for wrong target,
wrong action, later time, revoked, superseded. Consume-side replay stays out.
Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this
object; T05 remains blocked only on consumption ordering.
Depends on T07, T08, and T05 (the last only for the consume-side replay
cases). Point Canon `T-06 — Approval Replay` at a live implementation.
Hand `access-engine` / `secrets-engine` a claim they can validate before