Persist review evidence and deliver audit records transactionally
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
0e48355b9f
commit
2cc32168ac
14 changed files with 1474 additions and 22 deletions
108
informed_decision/audit.py
Normal file
108
informed_decision/audit.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Deliver immutable outbox bytes to Audit Core with its idempotency contract."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .http_transport import JSONTransport, TransportError, fixed_origin
|
||||
from .store import bounded_window
|
||||
|
||||
|
||||
class AuditDeliveryError(RuntimeError):
|
||||
def __init__(self, code, permanent=False):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.permanent = permanent
|
||||
|
||||
|
||||
class AuditCoreSink:
|
||||
def __init__(self, origin, token_provider, *, transport=None, allow_internal_http=False):
|
||||
self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http)
|
||||
self.token_provider = token_provider
|
||||
self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http)
|
||||
|
||||
def _request(self, method, path, *, body=None, event_id=None):
|
||||
try:
|
||||
token = self.token_provider()
|
||||
except (OSError, ValueError):
|
||||
raise AuditDeliveryError("unauthorized", permanent=True) from None
|
||||
if not isinstance(token, str) or not token or not token.isascii() or len(token) > 8192 or any(c.isspace() for c in token):
|
||||
raise AuditDeliveryError("unauthorized", permanent=True)
|
||||
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
||||
if event_id:
|
||||
headers["Idempotency-Key"] = event_id
|
||||
try:
|
||||
status, data = self.transport.request(method, self.origin + path, headers=headers, body=body)
|
||||
except TransportError:
|
||||
raise AuditDeliveryError("unavailable") from None
|
||||
if status in (401, 403):
|
||||
raise AuditDeliveryError("unauthorized", permanent=True)
|
||||
if status == 400:
|
||||
raise AuditDeliveryError("rejected", permanent=True)
|
||||
if status == 409:
|
||||
raise AuditDeliveryError("conflict", permanent=True)
|
||||
if status >= 500:
|
||||
raise AuditDeliveryError("unavailable")
|
||||
return status, data
|
||||
|
||||
def deliver(self, event_id, envelope_json):
|
||||
status, data = self._request("POST", "/v1/events", body=envelope_json.encode(), event_id=event_id)
|
||||
if ((status, data.get("status")) not in ((202, "accepted"), (200, "duplicate"))
|
||||
or data.get("reference") != "audit:" + event_id):
|
||||
raise AuditDeliveryError("invalid_receipt")
|
||||
return data["reference"]
|
||||
|
||||
def counts(self, since, until):
|
||||
since, until = bounded_window(since, until)
|
||||
query = {"source": "informed-decision", "tenant": "tenant:platform", "since": since, "until": until}
|
||||
status, data = self._request("GET", "/v1/reconciliation?" + urlencode(query))
|
||||
try:
|
||||
same_window = bounded_window(data.get("since"), data.get("until")) == (since, until)
|
||||
except (TypeError, ValueError):
|
||||
same_window = False
|
||||
rows = data.get("counts")
|
||||
if (status != 200 or data.get("source") != query["source"] or data.get("tenant") != query["tenant"]
|
||||
or not same_window or not isinstance(rows, list)):
|
||||
raise AuditDeliveryError("invalid_receipt")
|
||||
counts = {}
|
||||
for row in rows:
|
||||
if (not isinstance(row, dict) or not isinstance(row.get("class"), str) or not row["class"]
|
||||
or row["class"] in counts or type(row.get("count")) is not int or row["count"] < 0):
|
||||
raise AuditDeliveryError("invalid_receipt")
|
||||
counts[row["class"]] = row["count"]
|
||||
return counts
|
||||
|
||||
|
||||
class OutboxWorker:
|
||||
def __init__(self, store, sink):
|
||||
self.store = store
|
||||
self.sink = sink
|
||||
|
||||
def run_once(self, *, limit=100):
|
||||
if type(limit) is not int or not 1 <= limit <= 1000:
|
||||
raise ValueError("delivery batch limit must be 1..1000")
|
||||
result = {"delivered": 0, "retrying": 0, "blocked": 0}
|
||||
for _ in range(limit):
|
||||
delivery = self.store.claim_delivery()
|
||||
if delivery is None:
|
||||
break
|
||||
event_id, lease, body = delivery
|
||||
try:
|
||||
reference = self.sink.deliver(event_id, body)
|
||||
except AuditDeliveryError as error:
|
||||
self.store.finish_delivery(event_id, lease, error=error.code, permanent=error.permanent)
|
||||
result["blocked" if error.permanent else "retrying"] += 1
|
||||
else:
|
||||
self.store.finish_delivery(event_id, lease, reference=reference)
|
||||
result["delivered"] += 1
|
||||
return result
|
||||
|
||||
def reconcile(self, since, until):
|
||||
since, until = bounded_window(since, until)
|
||||
local = self.store.counts_by_class(since, until)
|
||||
remote = self.sink.counts(since, until)
|
||||
classes = sorted(set(local) | set(remote))
|
||||
return {"source": "informed-decision", "tenant": "tenant:platform", "since": since, "until": until,
|
||||
"counts": {k: {"source": local.get(k, 0), "receiver": remote.get(k, 0)} for k in classes},
|
||||
"count_values_match": all(local.get(k, 0) == remote.get(k, 0) for k in classes),
|
||||
"source_time_basis": "occurred_at", "receiver_time_basis": "accepted_at",
|
||||
"automatic_loss_finding": False,
|
||||
"completeness_proven": False, "reconstructability_proven": False}
|
||||
|
|
@ -114,9 +114,10 @@ def record(
|
|||
|
||||
Guards, in the order a defect is most likely to be caught:
|
||||
|
||||
- ``G_NOAGENT`` humans bind, agents draft. No upstream backstop exists.
|
||||
- ``G_NOAGENT`` humans bind, agents draft; declared controls also guard upstream.
|
||||
- ``G_STEP`` the verb must be legal for this step kind.
|
||||
- ``G_PRES`` the presentation must be of this memo AND this version.
|
||||
- ``G_ACTOR`` the acting person must be the presentation's recipient.
|
||||
- ``G_ACK`` required highlights acked before any binding verb.
|
||||
- ``G_REASONS`` a return carries at least one coded reason.
|
||||
- ``G_SEALED`` binding verbs on a sealed version are illegal.
|
||||
|
|
@ -125,7 +126,7 @@ def record(
|
|||
raise DispositionRefused(
|
||||
"G_NOAGENT",
|
||||
f"{actor.kind.value} principals may draft but never bind "
|
||||
"(INTENT principle 10; approval-engine provides no upstream backstop)",
|
||||
"(INTENT principle 10)",
|
||||
)
|
||||
|
||||
if verb not in legal_verbs(memo.step_kind):
|
||||
|
|
@ -145,6 +146,9 @@ def record(
|
|||
f"{memo.version} — no silent upgrade",
|
||||
)
|
||||
|
||||
if actor.sub != presentation.principal_sub:
|
||||
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
|
||||
|
||||
if verb in BINDING_VERBS:
|
||||
if memo.sealed:
|
||||
raise DispositionRefused("G_SEALED", "binding verbs on a sealed version are illegal")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
"""The local transactional outbox and the commitment records it queues.
|
||||
"""Commitment records and the in-memory outbox used by domain tests.
|
||||
|
||||
The persistent transactional implementation is ``store.Store``; this module
|
||||
constructs the commitments used by both implementations.
|
||||
|
||||
Payload is **commitment-only**, granted for Stage 1 by `GH-DEC-2026-014`:
|
||||
hashes, principal, timestamps, acks, the co-referenced approval id. Never the
|
||||
|
|
@ -190,7 +193,7 @@ def heartbeat(event_class: EventClass) -> Commitment:
|
|||
|
||||
|
||||
class Outbox:
|
||||
"""Local, transactional. Written in the same transaction as the state change.
|
||||
"""In-memory domain-test double; use ``store.Store`` for durable atomicity.
|
||||
|
||||
Emit-after-commit is a defect. The queue is local so an `audit-core` outage
|
||||
never blocks a binding act — the same reasoning that keeps it from blocking
|
||||
|
|
|
|||
48
informed_decision/records.py
Normal file
48
informed_decision/records.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""JSON persistence of the existing domain objects; never pickle or a new memo schema."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
|
||||
from .disposition import Actor, ActorKind, Disposition, Verb
|
||||
from .memo import (BindingLevel, BindingSlice, Highlight, Identifier, Memo,
|
||||
PacketItem, Principal, Scope, StepKind)
|
||||
from .presentation import Phase, Presentation
|
||||
from .provenance import Claim, Route
|
||||
|
||||
|
||||
def dumps(value) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False, default=lambda x: sorted(x) if isinstance(x, frozenset) else asdict(x))
|
||||
|
||||
|
||||
def memo_from(data: dict) -> Memo:
|
||||
data = dict(data)
|
||||
binding = dict(data["binding"])
|
||||
principal = dict(binding["principal"])
|
||||
principal["identifiers"] = tuple(Identifier(**i) for i in principal.get("identifiers", []))
|
||||
binding["principal"] = Principal(**principal)
|
||||
binding["target"] = Scope(**binding["target"])
|
||||
data["binding"] = BindingSlice(**binding)
|
||||
data["binding_level"] = BindingLevel(data["binding_level"])
|
||||
data["step_kind"] = StepKind(data["step_kind"])
|
||||
data["packet"] = tuple(PacketItem(**p) for p in data["packet"])
|
||||
data["highlights"] = tuple(Highlight(**h) for h in data["highlights"])
|
||||
return Memo(**data)
|
||||
|
||||
|
||||
def presentation_from(data: dict) -> Presentation:
|
||||
data = dict(data)
|
||||
data["phase"] = Phase(data["phase"])
|
||||
data["acked_highlight_ids"] = frozenset(data["acked_highlight_ids"])
|
||||
for field in ("tenant", "principal_type"):
|
||||
if data[field] is not None:
|
||||
data[field] = Claim(data[field]["value"], Route(data[field]["route"]))
|
||||
return Presentation(**data)
|
||||
|
||||
|
||||
def disposition_from(data: dict) -> Disposition:
|
||||
data = dict(data)
|
||||
data["verb"] = Verb(data["verb"])
|
||||
data["actor"] = Actor(data["actor"]["sub"], ActorKind(data["actor"]["kind"]))
|
||||
data["reasons"] = tuple(data["reasons"])
|
||||
return Disposition(**data)
|
||||
459
informed_decision/store.py
Normal file
459
informed_decision/store.py
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"""Durable source-held evidence and transactional outbox for the review domain.
|
||||
|
||||
This is an internal store, not an authorization endpoint. Callers owe an
|
||||
access-engine decision before exposing its content or dispatching an entry.
|
||||
Submission state describes local delivery, never approval validity.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .approval_client import EntryResult
|
||||
from .disposition import Actor, BINDING_VERBS, DispositionRefused, Verb, record
|
||||
from .evidence import EventClass, commit_disposition, commit_presentation, commit_stance_application, heartbeat, HEARTBEAT_CLASSES
|
||||
from .memo import Memo
|
||||
from .presentation import render
|
||||
from .provenance import Claim, Route, assert_human_control_dischargeable
|
||||
from .stance import resolve
|
||||
from .records import disposition_from, dumps, memo_from, presentation_from
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
_SCHEMA = """
|
||||
CREATE TABLE documents (digest TEXT PRIMARY KEY, media_type TEXT NOT NULL, content BLOB NOT NULL);
|
||||
CREATE TABLE memos (id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(id,version));
|
||||
CREATE TABLE presentations (id TEXT PRIMARY KEY, memo_id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL,
|
||||
FOREIGN KEY(memo_id,version) REFERENCES memos(id,version));
|
||||
CREATE TABLE acknowledgments (presentation_id TEXT NOT NULL REFERENCES presentations(id),
|
||||
highlight_id TEXT NOT NULL, at TEXT NOT NULL, PRIMARY KEY(presentation_id,highlight_id));
|
||||
CREATE TABLE dispositions (id TEXT PRIMARY KEY, operation_id TEXT UNIQUE NOT NULL, request TEXT NOT NULL,
|
||||
presentation_id TEXT NOT NULL REFERENCES presentations(id), body TEXT NOT NULL);
|
||||
CREATE TABLE evidence (id TEXT PRIMARY KEY, class TEXT NOT NULL, at TEXT NOT NULL,
|
||||
envelope TEXT NOT NULL, content TEXT NOT NULL);
|
||||
CREATE TABLE outbox (id TEXT PRIMARY KEY REFERENCES evidence(id), state TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0, next_attempt REAL NOT NULL DEFAULT 0,
|
||||
lease TEXT, lease_until REAL, last_error TEXT, receiver_reference TEXT);
|
||||
CREATE TABLE submissions (disposition_id TEXT PRIMARY KEY REFERENCES dispositions(id),
|
||||
approval_id TEXT NOT NULL, subject TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'prepared',
|
||||
attempt TEXT, approved_at TEXT, UNIQUE(approval_id,subject));
|
||||
"""
|
||||
|
||||
|
||||
class StoreError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Conflict(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class EvidenceUnavailable(StoreError):
|
||||
"""Retrieval failure attributable to informed-decision, not an empty result."""
|
||||
|
||||
|
||||
def _text(value, name):
|
||||
if not isinstance(value, str) or not value or len(value) > 256:
|
||||
raise ValueError(f"invalid {name}")
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path).absolute()
|
||||
parent = self.path.parent
|
||||
info = parent.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
||||
or stat.S_IMODE(info.st_mode) != 0o700):
|
||||
raise StoreError("evidence directory must be owned by this user with mode 0700")
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
||||
except FileExistsError:
|
||||
pass
|
||||
else:
|
||||
os.close(fd)
|
||||
info = self.path.lstat()
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid()
|
||||
or stat.S_IMODE(info.st_mode) != 0o600):
|
||||
raise StoreError("evidence database must be a private, owned, non-linked 0600 file")
|
||||
with self._connection() as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version == 0:
|
||||
if db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchone():
|
||||
raise StoreError("unrecognized evidence database")
|
||||
triggers = ""
|
||||
for table in ("documents", "memos", "presentations", "acknowledgments", "dispositions", "evidence"):
|
||||
for operation in ("UPDATE", "DELETE"):
|
||||
triggers += (f"CREATE TRIGGER immutable_{table}_{operation} BEFORE {operation} ON {table} "
|
||||
"BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;\n")
|
||||
db.executescript("BEGIN IMMEDIATE;" + _SCHEMA + triggers + "PRAGMA user_version=1; COMMIT;")
|
||||
elif version != SCHEMA_VERSION:
|
||||
raise StoreError("unsupported evidence schema version")
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
db = sqlite3.connect(self.path.as_uri() + "?mode=rw", uri=True, timeout=5, isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@contextmanager
|
||||
def _transaction(self):
|
||||
with self._connection() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
def put_document(self, content: bytes, media_type="text/plain") -> str:
|
||||
if not isinstance(content, bytes) or len(content) > 16 * 1024 * 1024:
|
||||
raise ValueError("packet document must be bytes, at most 16 MiB")
|
||||
_text(media_type, "media type")
|
||||
digest = "sha256:" + hashlib.sha256(content).hexdigest()
|
||||
with self._transaction() as db:
|
||||
existing = db.execute("SELECT media_type,content FROM documents WHERE digest=?", (digest,)).fetchone()
|
||||
if existing and (existing["media_type"] != media_type or existing["content"] != content):
|
||||
raise Conflict("document digest already has different content or media type")
|
||||
if not existing:
|
||||
db.execute("INSERT INTO documents VALUES (?,?,?)", (digest, media_type, content))
|
||||
return digest
|
||||
|
||||
def save_memo(self, memo: Memo):
|
||||
_text(memo.id, "memo id")
|
||||
if type(memo.version) is not int or memo.version < 1:
|
||||
raise ValueError("memo version must be a positive integer")
|
||||
body = dumps(memo)
|
||||
if len(body.encode()) > 256 * 1024:
|
||||
raise ValueError("memo is too large")
|
||||
with self._transaction() as db:
|
||||
previous = db.execute("SELECT version,body FROM memos WHERE id=? ORDER BY version DESC LIMIT 1", (memo.id,)).fetchone()
|
||||
if previous and previous["version"] == memo.version and previous["body"] == body:
|
||||
return
|
||||
if memo.version != (previous["version"] + 1 if previous else 1):
|
||||
raise Conflict("memo version must advance by one; existing versions are immutable")
|
||||
if db.execute("SELECT 1 FROM submissions s JOIN dispositions d ON d.id=s.disposition_id "
|
||||
"JOIN presentations p ON p.id=d.presentation_id WHERE p.memo_id=? "
|
||||
"AND s.state IN ('in_flight','unresolved') LIMIT 1", (memo.id,)).fetchone():
|
||||
raise Conflict("resolve the outstanding entry attempt before revising this memo")
|
||||
for item in memo.packet:
|
||||
if not db.execute("SELECT 1 FROM documents WHERE digest=?", (item.hash,)).fetchone():
|
||||
raise EvidenceUnavailable("informed-decision does not hold the referenced packet content")
|
||||
db.execute("INSERT INTO memos VALUES (?,?,?)", (memo.id, memo.version, body))
|
||||
|
||||
def _memo(self, db, memo_id, version=None):
|
||||
if version is None:
|
||||
row = db.execute("SELECT body FROM memos WHERE id=? ORDER BY version DESC LIMIT 1", (memo_id,)).fetchone()
|
||||
else:
|
||||
row = db.execute("SELECT body FROM memos WHERE id=? AND version=?", (memo_id, version)).fetchone()
|
||||
if row is None:
|
||||
raise EvidenceUnavailable("informed-decision cannot produce the named memo version")
|
||||
return memo_from(json.loads(row["body"]))
|
||||
|
||||
def memo(self, memo_id):
|
||||
with self._connection() as db:
|
||||
return self._memo(db, memo_id)
|
||||
|
||||
def _presentation(self, db, presentation_id):
|
||||
row = db.execute("SELECT body FROM presentations WHERE id=?", (presentation_id,)).fetchone()
|
||||
if row is None:
|
||||
raise EvidenceUnavailable("informed-decision cannot produce the named presentation")
|
||||
original = presentation_from(json.loads(row["body"]))
|
||||
acks = db.execute("SELECT highlight_id FROM acknowledgments WHERE presentation_id=?", (presentation_id,))
|
||||
return replace(original, acked_highlight_ids=frozenset(r[0] for r in acks))
|
||||
|
||||
def presentation(self, presentation_id):
|
||||
with self._connection() as db:
|
||||
return self._presentation(db, presentation_id)
|
||||
|
||||
def _event(self, db, commitment, content, extra=None):
|
||||
# Construct the stable envelope once, in the same transaction as content
|
||||
# and state. Retries send these exact bytes and the same idempotency key.
|
||||
data = {**commitment.data, **(extra or {})}
|
||||
at = datetime.fromisoformat(commitment.at.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="microseconds")
|
||||
envelope = {"id": commitment.id, "type": commitment.event_class.value,
|
||||
"source": "informed-decision", "tenant": "tenant:platform",
|
||||
"subject": data.get("memo_id", "informed-decision"),
|
||||
"correlation_id": data.get("approval_id") or commitment.id,
|
||||
"occurred_at": at, "data": data}
|
||||
encoded = dumps(envelope)
|
||||
if len(encoded.encode()) > 256 * 1024:
|
||||
raise StoreError("audit commitment exceeds the receiver limit")
|
||||
db.execute("INSERT INTO evidence VALUES (?,?,?,?,?)",
|
||||
(commitment.id, commitment.event_class.value, at, encoded, dumps(content)))
|
||||
db.execute("INSERT INTO outbox(id) VALUES (?)", (commitment.id,))
|
||||
return commitment.id
|
||||
|
||||
def present(self, memo_id, *, principal_sub, tenant, principal_type, awareness=None):
|
||||
_text(principal_sub, "principal")
|
||||
if (not isinstance(tenant, Claim) or tenant.value != "tenant:platform"
|
||||
or tenant.route not in (Route.DIRECTORY, Route.REGISTRATION)
|
||||
or not isinstance(principal_type, Claim)):
|
||||
raise ValueError("platform tenant and principal provenance are required")
|
||||
with self._transaction() as db:
|
||||
memo = self._memo(db, memo_id)
|
||||
presentation = render(memo, principal_sub=principal_sub, tenant=tenant,
|
||||
principal_type=principal_type, awareness=awareness)
|
||||
content = {"memo": json.loads(dumps(memo)), "presentation": json.loads(dumps(presentation)),
|
||||
"binding_document": memo.binding_document(), "awareness_document": memo.awareness_document(awareness)}
|
||||
db.execute("INSERT INTO presentations VALUES (?,?,?,?)",
|
||||
(presentation.id, memo.id, memo.version, dumps(presentation)))
|
||||
commitment = commit_presentation(presentation, custody="informed-decision:presentations:" + presentation.id)
|
||||
self._event(db, commitment, content)
|
||||
return presentation
|
||||
|
||||
def acknowledge(self, presentation_id, actor: Actor, highlight_ids):
|
||||
ids = frozenset(highlight_ids)
|
||||
with self._transaction() as db:
|
||||
p = self._presentation(db, presentation_id)
|
||||
memo = self._memo(db, p.memo_id)
|
||||
if actor.sub != p.principal_sub:
|
||||
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
|
||||
if memo.version != p.memo_version:
|
||||
raise DispositionRefused("G_PRES", "presentation is stale")
|
||||
if ids - {h.id for h in memo.highlights}:
|
||||
raise DispositionRefused("G_ACK", "unknown highlight")
|
||||
fresh = ids - p.acked_highlight_ids
|
||||
if not fresh:
|
||||
return p
|
||||
at = _now()
|
||||
for highlight_id in sorted(fresh):
|
||||
db.execute("INSERT INTO acknowledgments VALUES (?,?,?)", (p.id, highlight_id, at))
|
||||
p = replace(p, acked_highlight_ids=p.acked_highlight_ids | fresh)
|
||||
c = commit_presentation(p, custody="informed-decision:presentations:" + p.id)
|
||||
self._event(db, c, {"presentation": json.loads(dumps(p))},
|
||||
{"event_kind": "acknowledgment", "acknowledged_at": at})
|
||||
return p
|
||||
|
||||
def record_disposition(self, presentation_id, actor, verb, *, operation_id, reasons=(), note=None):
|
||||
_text(operation_id, "operation id")
|
||||
if note is not None and (not isinstance(note, str) or len(note) > 8192):
|
||||
raise ValueError("disposition note is too large or invalid")
|
||||
if any(not isinstance(r, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", r) for r in reasons):
|
||||
raise ValueError("reasons must be bounded codes, not free text")
|
||||
request = dumps({"presentation": presentation_id, "actor": actor, "verb": verb, "reasons": reasons, "note": note})
|
||||
with self._transaction() as db:
|
||||
existing = db.execute("SELECT request,body FROM dispositions WHERE operation_id=?", (operation_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["request"] != request:
|
||||
raise Conflict("operation id already names a different disposition")
|
||||
return disposition_from(json.loads(existing["body"]))
|
||||
p = self._presentation(db, presentation_id)
|
||||
memo = self._memo(db, p.memo_id)
|
||||
d = record(memo, p, verb, actor, reasons=tuple(reasons), note=note)
|
||||
if verb in BINDING_VERBS:
|
||||
if p.principal_type is None:
|
||||
raise DispositionRefused("G_IDENTITY", "verified human provenance required")
|
||||
assert_human_control_dischargeable(p.principal_type)
|
||||
if verb is Verb.ACCEPT:
|
||||
if not memo.approval_id or not memo.approval_binding_digest:
|
||||
raise DispositionRefused("G_BINDING", "the native approval id and carried digest are required")
|
||||
if db.execute("SELECT 1 FROM submissions WHERE approval_id=? AND subject=?", (memo.approval_id, actor.sub)).fetchone():
|
||||
raise Conflict("this approver already has an intent for the approval; recover the original")
|
||||
db.execute("INSERT INTO dispositions VALUES (?,?,?,?,?)", (d.id, operation_id, request, p.id, dumps(d)))
|
||||
if verb is Verb.ACCEPT:
|
||||
db.execute("INSERT INTO submissions(disposition_id,approval_id,subject) VALUES (?,?,?)", (d.id, memo.approval_id, actor.sub))
|
||||
c = commit_disposition(d, custody="informed-decision:dispositions:" + d.id)
|
||||
self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p))},
|
||||
{"submission_state": "prepared"} if verb is Verb.ACCEPT else None)
|
||||
return d
|
||||
|
||||
def begin_submission(self, disposition_id):
|
||||
"""Reserve one external attempt, AFTER the caller's fresh policy check.
|
||||
|
||||
No network occurs in this store. A crash after this reservation stays
|
||||
unresolved; restart never silently permits another POST.
|
||||
"""
|
||||
with self._transaction() as db:
|
||||
row = db.execute("SELECT * FROM submissions WHERE disposition_id=?", (disposition_id,)).fetchone()
|
||||
if row is None or row["state"] != "prepared":
|
||||
raise Conflict("submission is absent, already attempted or unresolved")
|
||||
d = disposition_from(json.loads(db.execute("SELECT body FROM dispositions WHERE id=?", (disposition_id,)).fetchone()[0]))
|
||||
p = self._presentation(db, d.presentation_id)
|
||||
if self._memo(db, p.memo_id).version != p.memo_version:
|
||||
raise DispositionRefused("G_PRES", "presentation is stale")
|
||||
attempt = str(uuid.uuid4())
|
||||
db.execute("UPDATE submissions SET state='in_flight',attempt=? WHERE disposition_id=?", (attempt, disposition_id))
|
||||
return attempt
|
||||
|
||||
def finish_submission(self, disposition_id, attempt, result: EntryResult | None = None):
|
||||
with self._transaction() as db:
|
||||
row = db.execute("SELECT * FROM submissions WHERE disposition_id=?", (disposition_id,)).fetchone()
|
||||
if row is None or row["state"] != "in_flight" or row["attempt"] != attempt:
|
||||
raise Conflict("submission attempt does not match")
|
||||
if result is not None and (result.approval_id != row["approval_id"] or result.subject != row["subject"]):
|
||||
raise Conflict("approval entry does not match the original intent")
|
||||
if result is not None:
|
||||
at = datetime.fromisoformat(result.approved_at.replace("Z", "+00:00"))
|
||||
if at.tzinfo is None:
|
||||
raise ValueError("approval timestamp must carry its timezone")
|
||||
# An unknown duplicate may predate this presentation. Never attach it
|
||||
# to a new one. A lost response likewise cannot prove causation.
|
||||
state = "confirmed" if result is not None and not result.duplicate else "unresolved"
|
||||
db.execute("UPDATE submissions SET state=?,approved_at=? WHERE disposition_id=?",
|
||||
(state, result.approved_at if state == "confirmed" else None, disposition_id))
|
||||
d = disposition_from(json.loads(db.execute("SELECT body FROM dispositions WHERE id=?", (disposition_id,)).fetchone()[0]))
|
||||
c = commit_disposition(d, custody="informed-decision:dispositions:" + d.id)
|
||||
extra = {"event_kind": "submission_result", "submission_state": state}
|
||||
if state == "confirmed":
|
||||
extra["entry_correlation"] = list(result.correlation)
|
||||
self._event(db, c, {"disposition": json.loads(dumps(d)), **extra}, extra)
|
||||
return state
|
||||
|
||||
def submission(self, disposition_id):
|
||||
with self._connection() as db:
|
||||
row = db.execute("SELECT * FROM submissions WHERE disposition_id=?", (disposition_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def retrieve_presentation(self, presentation_id):
|
||||
"""Internal custody retrieval, not a browser export/entitlement route."""
|
||||
with self._connection() as db:
|
||||
p = self._presentation(db, presentation_id)
|
||||
memo = self._memo(db, p.memo_id, p.memo_version)
|
||||
docs = {}
|
||||
for item in memo.packet:
|
||||
row = db.execute("SELECT content FROM documents WHERE digest=?", (item.hash,)).fetchone()
|
||||
if row is None or "sha256:" + hashlib.sha256(row[0]).hexdigest() != item.hash:
|
||||
raise EvidenceUnavailable("informed-decision cannot produce intact packet content")
|
||||
docs[item.item_id] = row[0]
|
||||
return memo, p, docs
|
||||
|
||||
def evidence(self):
|
||||
with self._connection() as db:
|
||||
return [dict(r) for r in db.execute("SELECT * FROM evidence ORDER BY rowid")]
|
||||
|
||||
def record_unreachable(self, memo_id, dependency):
|
||||
if dependency not in {"access-engine", "approval-engine", "key-cape", "audit-core"}:
|
||||
raise ValueError("unknown dependency")
|
||||
with self._transaction() as db:
|
||||
memo = self._memo(db, memo_id)
|
||||
stance, state = resolve(memo.binding_level.value)
|
||||
c = commit_stance_application(memo_id=memo.id, memo_version=memo.version,
|
||||
binding_level=memo.binding_level.value, binding_level_state=state.value,
|
||||
stance=stance, dependency=dependency, custody="informed-decision:memos:" + memo.id)
|
||||
return self._event(db, c, {"memo": json.loads(dumps(memo)), "dependency": dependency})
|
||||
|
||||
def retrieve_disposition(self, disposition_id):
|
||||
with self._connection() as db:
|
||||
row = db.execute("SELECT body FROM dispositions WHERE id=?", (disposition_id,)).fetchone()
|
||||
snapshot = db.execute("SELECT content FROM evidence WHERE class=? AND "
|
||||
"json_extract(envelope,'$.data.disposition_id')=? ORDER BY rowid LIMIT 1",
|
||||
(EventClass.DISPOSITION.value, disposition_id)).fetchone()
|
||||
if row is None or snapshot is None:
|
||||
raise EvidenceUnavailable("informed-decision cannot produce the named disposition")
|
||||
d = disposition_from(json.loads(row[0]))
|
||||
original_presentation = presentation_from(json.loads(snapshot[0])["presentation"])
|
||||
memo, _, documents = self.retrieve_presentation(d.presentation_id)
|
||||
return d, original_presentation, memo, documents
|
||||
|
||||
def presentation_for_entry(self, approval_id, subject, approved_at):
|
||||
with self._connection() as db:
|
||||
row = db.execute("SELECT disposition_id FROM submissions WHERE state='confirmed' "
|
||||
"AND approval_id=? AND subject=? AND approved_at=?", (approval_id, subject, approved_at)).fetchone()
|
||||
if row is None:
|
||||
raise EvidenceUnavailable("informed-decision has no confirmed presentation correlation for this entry")
|
||||
return self.retrieve_disposition(row[0])
|
||||
|
||||
def outbox(self):
|
||||
with self._connection() as db:
|
||||
return [dict(r) for r in db.execute("SELECT * FROM outbox ORDER BY rowid")]
|
||||
|
||||
def queue_heartbeats(self, now=None):
|
||||
now = time.time() if now is None else now
|
||||
count = 0
|
||||
with self._transaction() as db:
|
||||
for event_class, interval in HEARTBEAT_CLASSES.items():
|
||||
if db.execute("SELECT 1 FROM outbox o JOIN evidence e ON e.id=o.id WHERE o.state!='delivered' "
|
||||
"AND (e.class=? OR (e.class=? AND json_extract(e.envelope,'$.data.class')=?)) LIMIT 1",
|
||||
(event_class, EventClass.HEARTBEAT.value, event_class)).fetchone():
|
||||
continue # Never let a nothing-to-report assertion mask undelivered evidence.
|
||||
recent = db.execute("SELECT MAX(at) FROM evidence WHERE class=? OR "
|
||||
"(class=? AND json_extract(envelope,'$.data.class')=?)",
|
||||
(event_class, EventClass.HEARTBEAT.value, event_class)).fetchone()[0]
|
||||
if recent and now - datetime.fromisoformat(recent.replace("Z", "+00:00")).timestamp() < interval:
|
||||
continue
|
||||
c = heartbeat(EventClass(event_class))
|
||||
c = replace(c, at=datetime.fromtimestamp(now, timezone.utc).isoformat(timespec="microseconds"))
|
||||
self._event(db, c, {"class": event_class})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def claim_delivery(self, now=None):
|
||||
now = time.time() if now is None else now
|
||||
with self._transaction() as db:
|
||||
row = db.execute("SELECT o.id,e.envelope FROM outbox o JOIN evidence e ON e.id=o.id "
|
||||
"WHERE o.state='pending' AND o.next_attempt<=? AND (o.lease IS NULL OR o.lease_until<=?) ORDER BY o.rowid LIMIT 1",
|
||||
(now, now)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
lease = str(uuid.uuid4())
|
||||
db.execute("UPDATE outbox SET lease=?,lease_until=?,attempts=attempts+1 WHERE id=?", (lease, now + 30, row["id"]))
|
||||
return row["id"], lease, row["envelope"]
|
||||
|
||||
def finish_delivery(self, event_id, lease, *, reference=None, error=None, permanent=False, now=None):
|
||||
now = time.time() if now is None else now
|
||||
with self._transaction() as db:
|
||||
row = db.execute("SELECT attempts FROM outbox WHERE id=? AND state='pending' AND lease=?", (event_id, lease)).fetchone()
|
||||
if row is None:
|
||||
raise Conflict("delivery lease does not match")
|
||||
if reference:
|
||||
db.execute("UPDATE outbox SET state='delivered',receiver_reference=?,lease=NULL,lease_until=NULL,last_error=NULL WHERE id=?", (reference, event_id))
|
||||
else:
|
||||
if error not in {"unavailable", "unauthorized", "rejected", "conflict", "invalid_receipt"}:
|
||||
raise ValueError("a bounded delivery failure code is required")
|
||||
delay = min(300, 2 ** min(row["attempts"], 8))
|
||||
db.execute("UPDATE outbox SET state=?,last_error=?,next_attempt=?,lease=NULL,lease_until=NULL WHERE id=?",
|
||||
("blocked" if permanent else "pending", error, now + delay, event_id))
|
||||
|
||||
def requeue_blocked(self, event_id):
|
||||
"""Explicit operator retry after credential/config repair; same event bytes."""
|
||||
with self._transaction() as db:
|
||||
db.execute("UPDATE outbox SET state='pending',next_attempt=0 WHERE id=? AND state='blocked'", (event_id,))
|
||||
|
||||
def counts_by_class(self, since=None, until=None):
|
||||
with self._connection() as db:
|
||||
if since is None and until is None:
|
||||
rows = db.execute("SELECT class,COUNT(*) FROM evidence GROUP BY class")
|
||||
else:
|
||||
start, end = bounded_window(since, until)
|
||||
rows = db.execute("SELECT class,COUNT(*) FROM evidence WHERE at>=? AND at<? GROUP BY class", (start, end))
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
def backup(self, destination: str | Path):
|
||||
if Path(destination).exists() or Path(destination).is_symlink():
|
||||
raise Conflict("backup destination must not already exist")
|
||||
target = Store(destination)
|
||||
with self._connection() as source, target._connection() as dest:
|
||||
source.backup(dest)
|
||||
|
||||
|
||||
def bounded_window(since, until):
|
||||
result = []
|
||||
for value in (since, until):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("an explicit bounded time window is required")
|
||||
at = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if at.tzinfo is None:
|
||||
raise ValueError("window must carry its timezone")
|
||||
result.append(at.astimezone(timezone.utc).isoformat(timespec="microseconds"))
|
||||
if result[0] >= result[1]:
|
||||
raise ValueError("window must be increasing")
|
||||
return tuple(result)
|
||||
Loading…
Add table
Add a link
Reference in a new issue