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
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue