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:
parent
624e43f554
commit
9c9528f5b2
29 changed files with 2121 additions and 26 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -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
11
Makefile
Normal 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
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
49
SCOPE.md
49
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]
|
||||
```
|
||||
|
|
|
|||
24
approval_engine/__init__.py
Normal file
24
approval_engine/__init__.py
Normal 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
144
approval_engine/api.py
Normal 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
|
||||
45
approval_engine/binding.py
Normal file
45
approval_engine/binding.py
Normal 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
28
approval_engine/cli.py
Normal 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
28
approval_engine/errors.py
Normal 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
715
approval_engine/store.py
Normal 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
38
cadence.yaml
Normal 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
120
docs/approval-claim.md
Normal 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
62
docs/emission-cadence.md
Normal 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
26
docs/flex-auth-handoff.md
Normal 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
93
docs/outbox-contract.md
Normal 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`.
|
||||
28
examples/claim.revoked.json
Normal file
28
examples/claim.revoked.json
Normal 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
28
examples/claim.valid.json
Normal 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"
|
||||
}
|
||||
12
layer.yaml
12
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
|
||||
|
||||
|
|
|
|||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal 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 = ["."]
|
||||
115
schemas/approval_claim.schema.json
Normal file
115
schemas/approval_claim.schema.json
Normal 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
56
tests/conftest.py
Normal 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
69
tests/test_api.py
Normal 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
22
tests/test_cadence.py
Normal 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
70
tests/test_cas.py
Normal 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"
|
||||
58
tests/test_claim_contract.py
Normal file
58
tests/test_claim_contract.py
Normal 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
80
tests/test_machine.py
Normal 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
72
tests/test_outbox.py
Normal 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
67
tests/test_t06_replay.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue