Build the T08 domain core with the engine behind a seam

approval-engine APPROVAL-WP-0002-T01 is still progress and its namespace has no
pods, so the live end-to-end proof cannot run. Built everything that does not
depend on it, with the engine behind a Protocol plus a fake carrying its real
refusal semantics, so its arrival is a wiring change rather than a build.

- memo.py: the Decision Memo, versions, binding document. Principal, Scope,
  Awareness and Hat are dataclasses rather than dicts because the canonicalizer
  requires a shape and a missing key should fail at construction rather than
  deep inside hashing — which is exactly how it failed twice while building
  this. Field names follow the governed canonicalizer (item_id, severity,
  locator): the published vectors are the contract, so the object was aligned to
  them rather than the reverse.
- presentation.py: the sole writer of view_hash. One writer, one canonicalizer,
  one place to audit. Acknowledgment is an explicit method call and nothing
  infers it from scroll, dwell or focus.
- disposition.py: verbs and guards G_NOAGENT, G_STEP, G_PRES, G_ACK, G_REASONS,
  G_SEALED. accept is ABSENT from weak steps rather than present-and-disabled,
  because a greyed-out accept still teaches the wrong model. Only accept reaches
  the engine; a memo return is not represented there at all.
- provenance.py: claim routes per A-16. assert_human_control_dischargeable
  refuses a registration-supplied human, so PR-11's limitation fires at the
  point of use instead of sitting in a document.
- evidence.py: local outbox, commitment-only records carrying the
  GH-DEC-2026-014 §4 existence assertion, per-class reconciliation counts, and a
  custody-locator guard that rejects credentialed URLs (PR-12).
- approval_client.py: 409 duplicate_approver is success, 409 conflict terminal,
  503 fail-closed, approval:consume refused before a token is requested.

87 tests pass, including every negative case in the Use Case Catalog and that a
fail-closed outcome is recorded as a stance application with no verb field —
never as a decline, because the human did not make one.

T08 stays progress: the live proof is the remainder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1565372@bnt-lap001
Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
This commit is contained in:
tegwick 2026-09-10 20:38:20 +02:00
parent 4379576abd
commit 9e1f77e32b
18 changed files with 1503 additions and 10 deletions

View file

@ -0,0 +1,239 @@
"""The local transactional outbox and the commitment records it queues.
Payload is **commitment-only**, granted for Stage 1 by `GH-DEC-2026-014`:
hashes, principal, timestamps, acks, the co-referenced approval id. Never the
brief, never the packet.
What that does and does not establish is not a detail see
`docs/specs/EvidenceModel.md` §8d:
- It satisfies **non-alteration**. It does **not** satisfy
**reconstructability**, and must never be described as doing so.
- It moves *integrity* out of our control and leaves *availability* entirely
inside it. The party that can withhold the content is the party the evidence
is about.
Which is why every record carries the §4 existence assertion.
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from .disposition import Disposition
from .presentation import Presentation
class EventClass(str, Enum):
"""One source, distinct type values per class (`audit-core`, `AUDIT-IN-0003`)."""
PRESENTATION = "informed-decision.presentation"
DISPOSITION = "informed-decision.disposition"
STANCE_APPLICATION = "informed-decision.stance_application"
HEARTBEAT = "audit-core.heartbeat"
#: Heartbeat gap per class, seconds. Declared per class rather than per source:
#: a per-source heartbeat from a mixed-volume emitter is satisfied by its
#: chattiest class and says nothing about the quiet, security-relevant one.
HEARTBEAT_CLASSES: dict[str, int] = {
EventClass.PRESENTATION.value: 86400,
EventClass.DISPOSITION.value: 86400,
EventClass.STANCE_APPLICATION.value: 86400,
}
class CustodyLocatorRejected(Exception):
"""The custody locator looked secret-shaped (PR-12).
`audit-core` applies ``secret_policy: redact``, which scans ``data``. A
credentialed URL is redacted out and the existence declaration arrives
without its pointer visibly (``details.redaction.paths`` records it), but
the declaration is then useless while *looking* complete. Build-breaking,
not a warning.
"""
#: Anything carrying userinfo, a query string, or a token-shaped segment.
_SECRET_SHAPED = re.compile(
r"(://[^/@\s]*@)|([?&](token|key|secret|sig|password|access[_-]?token)=)|(\bBearer\b)",
re.IGNORECASE,
)
def assert_custody_locator_safe(locator: str) -> None:
if not locator:
raise CustodyLocatorRejected("custody locator must be non-empty")
if _SECRET_SHAPED.search(locator):
raise CustodyLocatorRejected(
f"custody locator {locator!r} is secret-shaped; use a stable "
"identifier the custodian resolves, never a credentialed URL (PR-12)"
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
@dataclass(frozen=True)
class Commitment:
"""One emitted record. `data` is stored verbatim by `audit-core` and chained."""
id: str
event_class: EventClass
at: str
data: dict
def as_envelope(self) -> dict:
return {"type": self.event_class.value, "data": self.data}
def _existence(custody: str) -> dict:
"""The `GH-DEC-2026-014` §4 assertion.
A commitment with no assertion that something is being committed to is
indistinguishable from a commitment to nothing. Failure to produce at
retrieval is then a conformance failure attributable to the custodian
which is us.
"""
assert_custody_locator_safe(custody)
return {"content_exists": True, "custody": custody}
def commit_presentation(p: Presentation, *, custody: str) -> Commitment:
data = {
"memo_id": p.memo_id,
"memo_version": p.memo_version,
"presentation_id": p.id,
"principal_sub": p.principal_sub,
"locale": p.locale,
"ui_release": p.ui_release,
"rendered_at": p.rendered_at,
"view_hash": p.view_hash,
"awareness_hash": p.awareness_hash,
"phase": p.phase.value,
"acked_highlight_ids": sorted(p.acked_highlight_ids),
"approval_id": p.approval_id,
}
# Claims travel with their route (PR-09/PR-11), never as bare strings.
if p.tenant is not None:
data["tenant"] = {"value": p.tenant.value, "route": p.tenant.route.value}
if p.principal_type is not None:
data["principal_type"] = {
"value": p.principal_type.value,
"route": p.principal_type.route.value,
}
data.update(_existence(custody))
return Commitment(f"ev-{uuid.uuid4()}", EventClass.PRESENTATION, _now(), data)
def commit_disposition(d: Disposition, *, custody: str) -> Commitment:
data = {
"memo_id": d.memo_id,
"memo_version": d.memo_version,
"presentation_id": d.presentation_id,
"disposition_id": d.id,
"verb": d.verb.value,
"actor_sub": d.actor.sub,
"actor_kind": d.actor.kind.value,
"at": d.at,
"reasons": list(d.reasons),
"approval_id": d.approval_id,
}
data.update(_existence(custody))
return Commitment(f"ev-{uuid.uuid4()}", EventClass.DISPOSITION, _now(), data)
def commit_stance_application(
*, memo_id: str, memo_version: int, binding_level: str | None,
binding_level_state: str, stance: str, dependency: str, custody: str,
) -> Commitment:
"""A fail-closed outcome is recorded as a STANCE APPLICATION.
Never as a decline: no disposition exists, because the human did not make
one. Conflating them would put a refusal in the record that no person
authored.
"""
data = {
"memo_id": memo_id,
"memo_version": memo_version,
"binding_level": binding_level,
"binding_level_state": binding_level_state,
"stance_applied": stance,
"unreachable_dependency": dependency,
# GH-DEC-2026-010: we can show a decision was obtained and what it said;
# we cannot show access-engine said it. Pinned false until FLEX-WP-0024.
"decision_attributable": False,
"at": _now(),
}
data.update(_existence(custody))
return Commitment(f"ev-{uuid.uuid4()}", EventClass.STANCE_APPLICATION, _now(), data)
def heartbeat(event_class: EventClass) -> Commitment:
"""An ORDINARY event — same envelope, same chain.
Deliberately so: a heartbeat stored outside the chain would be the one
record that could be back-dated.
"""
return Commitment(
f"ev-{uuid.uuid4()}",
EventClass.HEARTBEAT,
_now(),
{"class": event_class.value, "assertion": "nothing-to-report"},
)
class Outbox:
"""Local, transactional. Written in the same transaction as the state change.
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
a revocation upstream.
This in-memory implementation stands in for the transactional store; the
property it must preserve is that :meth:`append` cannot succeed while the
state change fails, or vice versa.
"""
def __init__(self) -> None:
self._pending: list[Commitment] = []
self._drained: list[Commitment] = []
def append(self, commitment: Commitment) -> None:
self._pending.append(commitment)
@property
def pending(self) -> tuple[Commitment, ...]:
return tuple(self._pending)
@property
def drained(self) -> tuple[Commitment, ...]:
return tuple(self._drained)
def drain(self, sink) -> int:
"""Deliver to `audit-core`. Failure leaves the record pending, never lost."""
sent = 0
while self._pending:
c = self._pending[0]
sink(c.as_envelope())
self._drained.append(self._pending.pop(0))
sent += 1
return sent
def counts_by_class(self) -> dict[str, int]:
"""Our side of reconciliation, keyed by class.
Bounded: where the emitter is compromised this count is suppressed
alongside the event, and reconciliation agrees with it. Covers loss,
outage, drain failure and accident not adversarial omission by us.
"""
counts: dict[str, int] = {}
for c in [*self._drained, *self._pending]:
counts[c.event_class.value] = counts.get(c.event_class.value, 0) + 1
return counts