informed-decision/informed_decision/evidence.py

243 lines
8.3 KiB
Python
Raw Permalink Normal View History

"""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.
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
2026-09-10 20:38:20 +02:00
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:
"""In-memory domain-test double; use ``store.Store`` for durable atomicity.
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
2026-09-10 20:38:20 +02:00
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