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:
parent
4379576abd
commit
9e1f77e32b
18 changed files with 1503 additions and 10 deletions
239
informed_decision/evidence.py
Normal file
239
informed_decision/evidence.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue