From 9c9528f5b2234aff666409d7f7ab0bf7062e577e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 29 Aug 2026 12:52:49 +0200 Subject: [PATCH] Implement the engine spine: claim, outbox, machine, API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 8 + Makefile | 11 + README.md | 8 + SCOPE.md | 49 +- approval_engine/__init__.py | 24 + approval_engine/api.py | 144 ++++ approval_engine/binding.py | 45 ++ approval_engine/cli.py | 28 + approval_engine/errors.py | 28 + approval_engine/store.py | 715 ++++++++++++++++++ cadence.yaml | 38 + docs/approval-claim.md | 120 +++ docs/emission-cadence.md | 62 ++ docs/flex-auth-handoff.md | 26 + docs/outbox-contract.md | 93 +++ examples/claim.revoked.json | 28 + examples/claim.valid.json | 28 + layer.yaml | 12 +- pyproject.toml | 16 + schemas/approval_claim.schema.json | 115 +++ tests/conftest.py | 56 ++ tests/test_api.py | 69 ++ tests/test_cadence.py | 22 + tests/test_cas.py | 70 ++ tests/test_claim_contract.py | 58 ++ tests/test_machine.py | 80 ++ tests/test_outbox.py | 72 ++ tests/test_t06_replay.py | 67 ++ ...-WP-0001-v07-alignment-and-engine-spine.md | 55 +- 29 files changed, 2121 insertions(+), 26 deletions(-) create mode 100644 Makefile create mode 100644 approval_engine/__init__.py create mode 100644 approval_engine/api.py create mode 100644 approval_engine/binding.py create mode 100644 approval_engine/cli.py create mode 100644 approval_engine/errors.py create mode 100644 approval_engine/store.py create mode 100644 cadence.yaml create mode 100644 docs/approval-claim.md create mode 100644 docs/emission-cadence.md create mode 100644 docs/flex-auth-handoff.md create mode 100644 docs/outbox-contract.md create mode 100644 examples/claim.revoked.json create mode 100644 examples/claim.valid.json create mode 100644 pyproject.toml create mode 100644 schemas/approval_claim.schema.json create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_cadence.py create mode 100644 tests/test_cas.py create mode 100644 tests/test_claim_contract.py create mode 100644 tests/test_machine.py create mode 100644 tests/test_outbox.py create mode 100644 tests/test_t06_replay.py diff --git a/.gitignore b/.gitignore index e4e0199..266aeaa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ .claude/* !.claude/rules/ !.claude/rules/*.md + +__pycache__/ +.pytest_cache/ +*.egg-info/ +.venv/ +approvals.sqlite +*.sqlite-wal +*.sqlite-shm diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a608802 --- /dev/null +++ b/Makefile @@ -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\033[0m\n"} \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +.PHONY: test help diff --git a/README.md b/README.md index 7793312..7aff5a5 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/SCOPE.md b/SCOPE.md index 03c7765..bf517e5 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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] +``` diff --git a/approval_engine/__init__.py b/approval_engine/__init__.py new file mode 100644 index 0000000..12dfc66 --- /dev/null +++ b/approval_engine/__init__.py @@ -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", +] diff --git a/approval_engine/api.py b/approval_engine/api.py new file mode 100644 index 0000000..3fa8ca0 --- /dev/null +++ b/approval_engine/api.py @@ -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 diff --git a/approval_engine/binding.py b/approval_engine/binding.py new file mode 100644 index 0000000..d1e63ec --- /dev/null +++ b/approval_engine/binding.py @@ -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 diff --git a/approval_engine/cli.py b/approval_engine/cli.py new file mode 100644 index 0000000..34acfdf --- /dev/null +++ b/approval_engine/cli.py @@ -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()) diff --git a/approval_engine/errors.py b/approval_engine/errors.py new file mode 100644 index 0000000..453e607 --- /dev/null +++ b/approval_engine/errors.py @@ -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" diff --git a/approval_engine/store.py b/approval_engine/store.py new file mode 100644 index 0000000..75d9b53 --- /dev/null +++ b/approval_engine/store.py @@ -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} diff --git a/cadence.yaml b/cadence.yaml new file mode 100644 index 0000000..5862997 --- /dev/null +++ b/cadence.yaml @@ -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 diff --git a/docs/approval-claim.md b/docs/approval-claim.md new file mode 100644 index 0000000..5172a58 --- /dev/null +++ b/docs/approval-claim.md @@ -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). diff --git a/docs/emission-cadence.md b/docs/emission-cadence.md new file mode 100644 index 0000000..c59abbb --- /dev/null +++ b/docs/emission-cadence.md @@ -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. diff --git a/docs/flex-auth-handoff.md b/docs/flex-auth-handoff.md new file mode 100644 index 0000000..4763a45 --- /dev/null +++ b/docs/flex-auth-handoff.md @@ -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. diff --git a/docs/outbox-contract.md b/docs/outbox-contract.md new file mode 100644 index 0000000..0f63caf --- /dev/null +++ b/docs/outbox-contract.md @@ -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": "", + "observed_at": "", + "tenant": "platform", + "scope": "netkingdom-approvals", + "source": "approval-engine", + "actor": "", + "action": "approval.revocation", + "resource": "approval:", + "outcome": "success", + "reason": null, + "details": { + "class": "revocation", + "approval_id": "", + "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`. diff --git a/examples/claim.revoked.json b/examples/claim.revoked.json new file mode 100644 index 0000000..6e53eb4 --- /dev/null +++ b/examples/claim.revoked.json @@ -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" +} diff --git a/examples/claim.valid.json b/examples/claim.valid.json new file mode 100644 index 0000000..2d00e86 --- /dev/null +++ b/examples/claim.valid.json @@ -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" +} diff --git a/layer.yaml b/layer.yaml index a52e1a1..bd44f08 100644 --- a/layer.yaml +++ b/layer.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..80eb72a --- /dev/null +++ b/pyproject.toml @@ -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 = ["."] diff --git a/schemas/approval_claim.schema.json b/schemas/approval_claim.schema.json new file mode 100644 index 0000000..2b54243 --- /dev/null +++ b/schemas/approval_claim.schema.json @@ -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" } + } + } + } +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fe8518f --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..aac5015 --- /dev/null +++ b/tests/test_api.py @@ -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" diff --git a/tests/test_cadence.py b/tests/test_cadence.py new file mode 100644 index 0000000..8ac9b49 --- /dev/null +++ b/tests/test_cadence.py @@ -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 diff --git a/tests/test_cas.py b/tests/test_cas.py new file mode 100644 index 0000000..f2e61b0 --- /dev/null +++ b/tests/test_cas.py @@ -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" diff --git a/tests/test_claim_contract.py b/tests/test_claim_contract.py new file mode 100644 index 0000000..e993cf7 --- /dev/null +++ b/tests/test_claim_contract.py @@ -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 diff --git a/tests/test_machine.py b/tests/test_machine.py new file mode 100644 index 0000000..e508f62 --- /dev/null +++ b/tests/test_machine.py @@ -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 diff --git a/tests/test_outbox.py b/tests/test_outbox.py new file mode 100644 index 0000000..92b3c69 --- /dev/null +++ b/tests/test_outbox.py @@ -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 diff --git a/tests/test_t06_replay.py b/tests/test_t06_replay.py new file mode 100644 index 0000000..2e58307 --- /dev/null +++ b/tests/test_t06_replay.py @@ -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 diff --git a/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md b/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md index 3e23fa1..2bf0f34 100644 --- a/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md +++ b/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md @@ -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