diff --git a/SCOPE.md b/SCOPE.md index db13283..161bd19 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -6,15 +6,28 @@ ## Status — 2026-09-09 -**Specification and declaration are complete; no service is deployed.** +**Specification, declaration and the domain core are complete. No service is +deployed.** -What exists and is tested: the layer and stance declarations (`layer.yaml`, -`pep-stance.yaml`, `informed_decision/stance.py`), the governed canonicalizer -and schema, and the four specs under `docs/specs/`. 46 tests pass. +What exists and is tested (87 tests): -What does not exist: any HTTP surface, any storage, any UI, any deployment. The -walking skeleton is `INFD-WP-0001-T08` and is gated on an external decision -(§"Open" below). +- layer and stance declarations — `layer.yaml`, `pep-stance.yaml`, + `informed_decision/stance.py`, with published-equals-shipped asserted; +- the governed canonicalizer and schema, with the three published vectors + reproducing byte for byte and all four isolation properties pinned; +- the **domain core**: `memo.py` (the Decision Memo, its versions and the + binding document), `presentation.py` (the sole writer of `view_hash`), + `disposition.py` (the verb vocabulary and guards `G_NOAGENT`, `G_STEP`, + `G_PRES`, `G_ACK`, `G_REASONS`, `G_SEALED`), `provenance.py` (claim routes, + A-16), `evidence.py` (the local outbox and commitment records); +- `approval_client.py` — the seam to `approval-engine` plus a fake carrying its + actual refusal semantics. + +What does not exist: any HTTP surface, any persistence, any UI, any deployment. +The origin `decisions.coulomb.social` is live but serves an nginx placeholder. + +`INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on +`APPROVAL-WP-0002-T01` and a deployed `approval-engine`. ## One-liner diff --git a/informed_decision/__init__.py b/informed_decision/__init__.py index f8c3383..933c715 100644 --- a/informed_decision/__init__.py +++ b/informed_decision/__init__.py @@ -1,9 +1,27 @@ """informed-decision — presentation and binding surface for decisions. This package must never contain an authorization decision. See ``INTENT.md`` -and ``AGENTS.md``: ``access-engine`` is the only policy decision point. +and ``AGENTS.md``: ``access-engine`` is the only policy decision point. What is +recorded here is evidence that a human performed an act, never a verdict on +whether the act was permitted. """ from .canonicalize import awareness_hash, view_hash +from .disposition import Actor, ActorKind, Disposition, DispositionRefused, Verb, legal_verbs, record +from .evidence import Commitment, EventClass, Outbox, heartbeat +from .memo import Awareness, BindingLevel, BindingSlice, Highlight, Memo, PacketItem, Principal, Scope, StepKind +from .presentation import Phase, Presentation, render +from .provenance import Claim, Route +from .stance import STANCE, resolve -__all__ = ["view_hash", "awareness_hash"] +__all__ = [ + "view_hash", "awareness_hash", + "Memo", "BindingSlice", "Principal", "Scope", "PacketItem", "Highlight", + "Awareness", "BindingLevel", "StepKind", + "render", "Presentation", "Phase", + "record", "Disposition", "Verb", "Actor", "ActorKind", "DispositionRefused", + "legal_verbs", + "Outbox", "Commitment", "EventClass", "heartbeat", + "Claim", "Route", + "STANCE", "resolve", +] diff --git a/informed_decision/__pycache__/__init__.cpython-312.pyc b/informed_decision/__pycache__/__init__.cpython-312.pyc index d425015..9d4e61d 100644 Binary files a/informed_decision/__pycache__/__init__.cpython-312.pyc and b/informed_decision/__pycache__/__init__.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/approval_client.cpython-312.pyc b/informed_decision/__pycache__/approval_client.cpython-312.pyc new file mode 100644 index 0000000..92a8e8f Binary files /dev/null and b/informed_decision/__pycache__/approval_client.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/disposition.cpython-312.pyc b/informed_decision/__pycache__/disposition.cpython-312.pyc new file mode 100644 index 0000000..f8887df Binary files /dev/null and b/informed_decision/__pycache__/disposition.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/evidence.cpython-312.pyc b/informed_decision/__pycache__/evidence.cpython-312.pyc new file mode 100644 index 0000000..fb6cd30 Binary files /dev/null and b/informed_decision/__pycache__/evidence.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/memo.cpython-312.pyc b/informed_decision/__pycache__/memo.cpython-312.pyc new file mode 100644 index 0000000..2f64e1d Binary files /dev/null and b/informed_decision/__pycache__/memo.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/presentation.cpython-312.pyc b/informed_decision/__pycache__/presentation.cpython-312.pyc new file mode 100644 index 0000000..2662bcb Binary files /dev/null and b/informed_decision/__pycache__/presentation.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/provenance.cpython-312.pyc b/informed_decision/__pycache__/provenance.cpython-312.pyc new file mode 100644 index 0000000..f00fb63 Binary files /dev/null and b/informed_decision/__pycache__/provenance.cpython-312.pyc differ diff --git a/informed_decision/approval_client.py b/informed_decision/approval_client.py new file mode 100644 index 0000000..6399394 --- /dev/null +++ b/informed_decision/approval_client.py @@ -0,0 +1,114 @@ +"""The seam to `approval-engine`. + +A Protocol plus a fake, because the engine is not deployed +(`APPROVAL-WP-0002-T01` is `progress`, no pods). When it deploys this becomes a +wiring change rather than a build. + +Hard rules, from `approval-engine/docs/approver-surface-requirements.md`: + +- `GET /v1/approvals/{id}` and `/claim` need ``approval:read``; + `POST …/entries` needs ``approval:approve``. +- **Never** ``/consume``. Human principals are refused there in code, and + consumption belongs to the PEP causing the side effect (`GH-DEC-2026-003`). +- No path containing ``check`` or ending ``/authorize`` exists to call. +- ``POST /entries`` **discards its request body** — identity, assurance and + ``evidence_ref`` come only from the verified token. So ``view_hash`` does not + ride into the entry; correlation is ``(approval_id, subject, approved_at)``. +- **No inbox.** Get-by-id only. Never poll for work. +- `approved` is a state of an object, never permission to act. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +FORBIDDEN_SCOPE = "approval:consume" +REQUIRED_SCOPES = ("openid", "approval:read", "approval:approve") + + +class ApprovalEngineError(Exception): + def __init__(self, status: int, reason: str) -> None: + super().__init__(f"{status} {reason}") + self.status = status + self.reason = reason + + +@dataclass(frozen=True) +class EntryResult: + """What comes back after a successful entry. + + These three fields are the correlation triple that ties the entry back to + our presentation record — `GOAL.md` DoD-3. + """ + + approval_id: str + subject: str + approved_at: str + status: str + duplicate: bool = False + + @property + def correlation(self) -> tuple[str, str, str]: + return (self.approval_id, self.subject, self.approved_at) + + +class ApprovalEngine(Protocol): + def get_approval(self, approval_id: str) -> dict: ... + def add_entry(self, approval_id: str) -> EntryResult: ... + + +def assert_scopes_permissible(scopes: tuple[str, ...]) -> None: + """Refuse ``approval:consume`` before a token is ever requested.""" + if FORBIDDEN_SCOPE in scopes: + raise ValueError( + f"{FORBIDDEN_SCOPE} must never be requested: approval-engine refuses it " + "for human principals and consumption belongs to the PEP causing the " + "side effect (GH-DEC-2026-003)" + ) + + +def is_success(err: ApprovalEngineError) -> bool: + """`409 duplicate_approver` is SUCCESS, not failure. + + A browser double-submit is routine and the first entry stands. Rendering it + as an error is a lie to the approver. + """ + return err.status == 409 and err.reason == "duplicate_approver" + + +class FakeApprovalEngine: + """In-process stand-in with the engine's actual refusal semantics.""" + + def __init__(self, approvals: dict[str, dict] | None = None) -> None: + self._approvals = approvals or {} + self._entries: dict[str, set[str]] = {} + self.available = True + + def get_approval(self, approval_id: str) -> dict: + if not self.available: + raise ApprovalEngineError(503, "store_unavailable") + if approval_id not in self._approvals: + raise ApprovalEngineError(404, "not_found") + return dict(self._approvals[approval_id]) + + def add_entry(self, approval_id: str, subject: str = "approver") -> EntryResult: + if not self.available: + raise ApprovalEngineError(503, "store_unavailable") + approval = self._approvals.get(approval_id) + if approval is None: + raise ApprovalEngineError(404, "not_found") + if approval.get("status") in ("revoked", "superseded", "consumed", "expired"): + raise ApprovalEngineError(409, "conflict") + seen = self._entries.setdefault(approval_id, set()) + duplicate = subject in seen + seen.add(subject) + if not duplicate and len(seen) >= approval.get("required_count", 1): + approval["status"] = "approved" + return EntryResult( + approval_id=approval_id, + subject=subject, + approved_at="2026-09-10T15:00:00Z", + status=approval.get("status", "requested"), + duplicate=duplicate, + ) diff --git a/informed_decision/disposition.py b/informed_decision/disposition.py new file mode 100644 index 0000000..aa8d14f --- /dev/null +++ b/informed_decision/disposition.py @@ -0,0 +1,174 @@ +"""The disposition vocabulary and its guards. + +Approve/reject is the wrong vocabulary for judgment. `return` is success, not +failure, and `accept` on a Kenntnisnahme step is illegal on purpose. + +Nothing here is an authorization decision. A disposition is evidence that a +human performed an act; it never answers whether the act was permitted +(`access-engine`, statute §6). +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + +from .memo import Memo, StepKind +from .presentation import Presentation + + +class Verb(str, Enum): + COMMENT = "comment" + DISCUSS = "discuss" + RETURN = "return" + FORWARD = "forward" + ESCALATE = "escalate" + ACKNOWLEDGE = "acknowledge" + ACCEPT = "accept" + DECLINE = "decline" + WITHDRAW = "withdraw" + CONFIGURE = "configure" + + +#: Verbs that bind an identity to an act. Guarded hardest. +BINDING_VERBS = frozenset({Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE}) + +#: Always available while the memo is circulating, whatever the step kind. +OVERLAY_VERBS = frozenset({Verb.COMMENT, Verb.DISCUSS}) + + +class ActorKind(str, Enum): + PERSON = "person" + AGENT = "agent" + SERVICE = "service" + SYSTEM = "system" + + +@dataclass(frozen=True) +class Actor: + sub: str + kind: ActorKind + + +class DispositionRefused(Exception): + """A verb was refused. Never an authorization verdict — see module docstring.""" + + def __init__(self, guard: str, message: str) -> None: + super().__init__(f"{guard}: {message}") + self.guard = guard + + +def legal_verbs(step_kind: StepKind) -> frozenset[Verb]: + """Which verbs the UI may offer for a step kind. + + `accept` is ABSENT for weak steps, not present-and-disabled: Kenntnisnahme + is not approval, and a greyed-out accept still teaches the wrong model. + """ + base = set(OVERLAY_VERBS) | {Verb.RETURN, Verb.FORWARD, Verb.ESCALATE} + if step_kind.is_weak: + return frozenset(base | {Verb.ACKNOWLEDGE}) + return frozenset(base | {Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE}) + + +@dataclass(frozen=True) +class Disposition: + id: str + memo_id: str + memo_version: int + presentation_id: str + verb: Verb + actor: Actor + at: str + reasons: tuple[str, ...] = () + note: str | None = None + #: Co-reference to the act (GH-DEC-2026-012 R3), identifier only. + approval_id: str | None = None + + @property + def reaches_approval_engine(self) -> bool: + """Only `accept` becomes a POST to /entries. + + `return`, `discuss`, `escalate` and the rest are dispositions of a MEMO; + approval-engine models entries against an approval object and knows + nothing of them. A memo `return` must not be represented there at all. + """ + return self.verb is Verb.ACCEPT + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def record( + memo: Memo, + presentation: Presentation, + verb: Verb, + actor: Actor, + *, + reasons: tuple[str, ...] = (), + note: str | None = None, +) -> Disposition: + """Record a disposition, or refuse it. + + Guards, in the order a defect is most likely to be caught: + + - ``G_NOAGENT`` humans bind, agents draft. No upstream backstop exists. + - ``G_STEP`` the verb must be legal for this step kind. + - ``G_PRES`` the presentation must be of this memo AND this version. + - ``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. + """ + if verb in BINDING_VERBS and actor.kind is not ActorKind.PERSON: + raise DispositionRefused( + "G_NOAGENT", + f"{actor.kind.value} principals may draft but never bind " + "(INTENT principle 10; approval-engine provides no upstream backstop)", + ) + + if verb not in legal_verbs(memo.step_kind): + detail = "" + if verb is Verb.ACCEPT and memo.step_kind.is_weak: + detail = " — Kenntnisnahme is not approval" + raise DispositionRefused( + "G_STEP", f"{verb.value} is not legal on a {memo.step_kind.value} step{detail}" + ) + + if presentation.memo_id != memo.id: + raise DispositionRefused("G_PRES", "presentation belongs to a different memo") + if presentation.memo_version != memo.version: + raise DispositionRefused( + "G_PRES", + f"presentation is of version {presentation.memo_version}, memo is at " + f"{memo.version} — no silent upgrade", + ) + + if verb in BINDING_VERBS: + if memo.sealed: + raise DispositionRefused("G_SEALED", "binding verbs on a sealed version are illegal") + outstanding = memo.required_ack_ids - presentation.acked_highlight_ids + if outstanding: + raise DispositionRefused( + "G_ACK", + "required highlights not acknowledged: " + ", ".join(sorted(outstanding)), + ) + + if verb is Verb.RETURN and not reasons: + raise DispositionRefused( + "G_REASONS", "a return carries at least one coded reason; free text is not a return" + ) + + return Disposition( + id=f"disp-{uuid.uuid4()}", + memo_id=memo.id, + memo_version=memo.version, + presentation_id=presentation.id, + verb=verb, + actor=actor, + at=_now(), + reasons=reasons, + note=note, + approval_id=memo.approval_id, + ) diff --git a/informed_decision/evidence.py b/informed_decision/evidence.py new file mode 100644 index 0000000..5b7336d --- /dev/null +++ b/informed_decision/evidence.py @@ -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 diff --git a/informed_decision/memo.py b/informed_decision/memo.py new file mode 100644 index 0000000..521be68 --- /dev/null +++ b/informed_decision/memo.py @@ -0,0 +1,263 @@ +"""The Decision Memo object. + +One question per memo. A memo carries the question, the requested act, the +binding level, the brief, a hashed packet, highlights pointing into it, and the +binding slice committing *which scope this act is being entered into*. + +`binding.target` is the **act-scope**. It is never derived from the token's +`tenant` claim, which is a membership fact about the principal +(`GH-DEC-2026-013` §5, PR-08). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from enum import Enum + + +class BindingLevel(str, Enum): + ACKNOWLEDGMENT = "acknowledgment" + ORGANIZATIONAL = "organizational" + AES = "aes" + QES = "qes" + + +class StepKind(str, Enum): + # Weak — acknowledgment only. `accept` is illegal here on purpose. + INFORM = "inform" + COMMENT = "comment" + REVIEW = "review" + ACKNOWLEDGE = "acknowledge" + # Co-sign + MITZEICHNUNG = "mitzeichnung" + APPROVE = "approve" + # Bind + SCHLUSSZEICHNUNG = "schlusszeichnung" + SIGN = "sign" + SEAL = "seal" + + @property + def is_weak(self) -> bool: + return self in (StepKind.INFORM, StepKind.COMMENT, StepKind.REVIEW, StepKind.ACKNOWLEDGE) + + @property + def is_bind(self) -> bool: + return self in (StepKind.SCHLUSSZEICHNUNG, StepKind.SIGN, StepKind.SEAL) + + +@dataclass(frozen=True) +class PacketItem: + """A hashed document in the packet. + + Field names follow ``canonicalize.normalize_packet``, which is the governed + contract the published vectors depend on — ``item_id``, not ``id``. ``label`` + is UI-only and is deliberately outside the hash: renaming a document does + not change what was shown. + """ + + item_id: str + label: str + hash: str + + +@dataclass(frozen=True) +class Highlight: + """A pointer into the packet. + + Highlights are an aid to attention, never a filter. Acknowledging them does + not narrow what is bound — the whole instrument is bound (PR-12/W-highlights). + """ + + id: str + item_id: str + note: str + required_ack: bool = False + severity: str = "informational" + locator: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class Identifier: + scheme: str + value: str + + +@dataclass(frozen=True) +class Principal: + """A party in the binding slice. + + A dataclass rather than a dict because the canonicalizer requires + ``display_name``, ``id`` and ``kind``, and a missing one is a runtime + KeyError deep inside hashing rather than a visible defect at construction. + """ + + id: str + kind: str + display_name: str + role: str | None = None + identifiers: tuple[Identifier, ...] = () + + def as_document(self) -> dict: + out: dict = {"display_name": self.display_name, "id": self.id, "kind": self.kind} + if self.role: + out["role"] = self.role + if self.identifiers: + out["identifiers"] = [ + {"scheme": i.scheme, "value": i.value} for i in self.identifiers + ] + return out + + +@dataclass(frozen=True) +class Scope: + """The act-scope. What the person is entering, not who they are.""" + + kind: str + id: str + label: str + environment: str | None = None + requires_new_bind: bool = False + + +@dataclass(frozen=True) +class BindingSlice: + """Committed and signed. Inside ``view_hash``.""" + + principal: Principal + target: Scope + terms: str | None = None + justification: str | None = None + + +@dataclass(frozen=True) +class Hat: + """A role. Not a scope — `hats are not scopes` (INTENT invariant).""" + + id: str + label: str + elevates: bool = False + + def as_document(self) -> dict: + return {"id": self.id, "label": self.label, "elevates": self.elevates} + + +@dataclass(frozen=True) +class Awareness: + """Shown on the same surface, hashed separately, never signed. + + Typed for the same reason ``Principal`` is: the canonicalizer requires a + shape, and a malformed one should fail at construction rather than deep + inside hashing. + + Nothing here enters ``view_hash``. Defaulting a hat to last-used is required + for situational awareness and forbidden from silently entering the signed + payload (INTENT principle 11). + """ + + proposed_hat: Hat | None = None + proposed_hat_source: str | None = None + available_hats: tuple[Hat, ...] = () + situation_note: str | None = None + + def as_document(self) -> dict: + out: dict = {} + if self.proposed_hat is not None: + out["proposed_hat"] = self.proposed_hat.as_document() + if self.proposed_hat_source is not None: + out["proposed_hat_source"] = self.proposed_hat_source + if self.available_hats: + out["available_hats"] = [h.as_document() for h in self.available_hats] + if self.situation_note is not None: + out["situation_note"] = self.situation_note + return out + + +@dataclass(frozen=True) +class Memo: + id: str + version: int + question: str + requested_act: str + binding_level: BindingLevel + brief: str + binding: BindingSlice + step_kind: StepKind + packet: tuple[PacketItem, ...] = () + highlights: tuple[Highlight, ...] = () + locale: str = "en" + ui_release: str = "informed-decision@0.1.0" + #: Co-reference to the act this memo presents (GH-DEC-2026-012 R3). + #: The identifier only — never approval-engine's binding digest, which we + #: do not recompute or restate. Nesting is permitted by GH-DEC-2026-015 but + #: NOT ACTIVE; see layer.yaml nesting_permission_active. + approval_id: str | None = None + sealed: bool = False + + def __post_init__(self) -> None: + if not self.question: + raise ValueError("a memo without a question does not render") + packet_ids = {p.item_id for p in self.packet} + for h in self.highlights: + if h.item_id not in packet_ids: + raise ValueError(f"highlight {h.id} points outside the packet") + + @property + def required_ack_ids(self) -> frozenset[str]: + return frozenset(h.id for h in self.highlights if h.required_ack) + + def next_version(self, **changes) -> "Memo": + """A change creates version n+1. Outstanding presentations die with it.""" + return replace(self, version=self.version + 1, **changes) + + def binding_document(self) -> dict: + """The document ``view_hash`` is computed over. + + Note what is here and what is not: the packet and highlights are here + because they were shown; the token's ``tenant`` claim is not, because it + is not the act-scope. + """ + return { + "memo_id": self.id, + "memo_version": self.version, + "question": self.question, + "requested_act": self.requested_act, + "binding_level": self.binding_level.value, + "brief": self.brief, + "locale": self.locale, + "ui_release": self.ui_release, + "packet": [{"item_id": p.item_id, "hash": p.hash} for p in self.packet], + "highlights": [ + { + "id": h.id, + "item_id": h.item_id, + "required_ack": h.required_ack, + "severity": h.severity, + "locator": h.locator, + } + for h in self.highlights + ], + "binding": self._binding_document(), + } + + def _binding_document(self) -> dict: + target: dict = { + "kind": self.binding.target.kind, + "id": self.binding.target.id, + "label": self.binding.target.label, + "requires_new_bind": self.binding.target.requires_new_bind, + } + if self.binding.target.environment is not None: + target["environment"] = self.binding.target.environment + out: dict = {"principal": self.binding.principal.as_document(), "target": target} + if self.binding.terms is not None: + out["terms"] = self.binding.terms + if self.binding.justification is not None: + out["justification"] = self.binding.justification + return out + + def awareness_document(self, awareness: "Awareness | None" = None) -> dict: + """Shown on the same surface, hashed separately, never signed.""" + doc = {"memo_id": self.id, "memo_version": self.version, "locale": self.locale} + if awareness is not None: + doc.update(awareness.as_document()) + return doc diff --git a/informed_decision/presentation.py b/informed_decision/presentation.py new file mode 100644 index 0000000..14a9b11 --- /dev/null +++ b/informed_decision/presentation.py @@ -0,0 +1,92 @@ +"""Presentation records — the sole writer of ``view_hash``. + +One writer, one canonicalizer, one place to audit. A second path that computes +a hash is a defect, not an optimisation (`ArchitectureBlueprint` §3). + +A presentation is a record of an event that happened. It is append-only: +editing one is falsifying evidence. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum + +from .canonicalize import awareness_hash, view_hash +from .memo import Awareness, Memo +from .provenance import Claim + + +class Phase(str, Enum): + PRE_BIND = "pre_bind" + BIND = "bind" + POST_BIND = "post_bind" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +@dataclass(frozen=True) +class Presentation: + id: str + memo_id: str + memo_version: int + principal_sub: str + locale: str + ui_release: str + rendered_at: str + view_hash: str + awareness_hash: str + phase: Phase + #: Co-reference to the act (GH-DEC-2026-012 R3). The identifier only. + approval_id: str | None = None + #: Claims stored WITH their route, never as bare strings (PR-09, PR-11). + tenant: Claim | None = None + principal_type: Claim | None = None + acked_highlight_ids: frozenset[str] = field(default_factory=frozenset) + + def with_ack(self, highlight_id: str) -> "Presentation": + """Acknowledgment is an explicit act. + + Never inferred from scroll position, dwell time, focus or viewport + intersection (PR-21). Only this method records one, and only a + deliberate control activation calls it. + """ + from dataclasses import replace + + return replace(self, acked_highlight_ids=self.acked_highlight_ids | {highlight_id}) + + +def render( + memo: Memo, + *, + principal_sub: str, + tenant: Claim | None = None, + principal_type: Claim | None = None, + awareness: Awareness | None = None, + phase: Phase = Phase.PRE_BIND, +) -> Presentation: + """Render a memo, producing exactly one presentation record. + + This is the only function in the package that computes ``view_hash``. + """ + binding_doc = memo.binding_document() + awareness_doc = memo.awareness_document(awareness) + return Presentation( + id=f"pres-{uuid.uuid4()}", + memo_id=memo.id, + memo_version=memo.version, + principal_sub=principal_sub, + locale=memo.locale, + ui_release=memo.ui_release, + rendered_at=_now(), + view_hash=view_hash(binding_doc)["hex"], + awareness_hash=awareness_hash(awareness_doc)["hex"], + phase=phase, + approval_id=memo.approval_id, + tenant=tenant, + principal_type=principal_type, + ) diff --git a/informed_decision/provenance.py b/informed_decision/provenance.py new file mode 100644 index 0000000..b356e53 --- /dev/null +++ b/informed_decision/provenance.py @@ -0,0 +1,91 @@ +"""Claim provenance — A-16 applied to claims this surface consumes. + +`key-cape` emits `tenant` and `principal_type` as bare strings. A consumer +cannot tell a value the *directory asserted about the person* from one a +*registration supplied about the client they came through*. + +`GH-DEC-2026-013` §5 requires the claim to carry its provenance. Until it does, +this surface records which route the value arrived by rather than storing an +undifferentiated string (PR-09), and never discharges a human-in-the-loop +control on a registration-supplied assertion of humanity (PR-11, +`GH-DEC-2026-016` §5). + +A-16's rider applies to this module: where the route marker is written by the +party whose conduct the route describes, it constrains a defect but not an +adversary. These markers are written by us about claims we received, which is +the semi-independent case — we gain nothing by mislabelling them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class Route(str, Enum): + """How a claim value reached us. Never collapsed, never defaulted.""" + + #: Asserted by the directory about the person. Strong. + DIRECTORY = "directory-asserted" + #: Supplied by the client registration. The GH-DEC-2026-013 bounded gap. + REGISTRATION = "registration-supplied" + #: Derived from the authentication event itself. + AUTHENTICATION = "authentication-derived" + #: Present, but its route is not determinable. Never treated as any of the above. + INDETERMINATE = "indeterminate" + + +@dataclass(frozen=True) +class Claim: + """A claim value with the route it arrived by. + + There is no constructor that takes a value without a route. A claim whose + provenance is unknown is ``INDETERMINATE``, explicitly — not defaulted to + the strongest reading. + """ + + value: str + route: Route + + def __post_init__(self) -> None: + if not self.value: + raise ValueError("claim value must be non-empty") + + @property + def is_verified_about_the_person(self) -> bool: + """True only where the claim was asserted about the *person*. + + A registration-supplied claim says something about the client the person + came through, not about the person. `GH-DEC-2026-016` §5: refusing a + service principal while accepting an unverified assertion of humanity + moves the defect rather than closing it. + """ + return self.route in (Route.DIRECTORY, Route.AUTHENTICATION) + + +class HumanControlNotDischargeable(Exception): + """Raised when a human-in-the-loop control cannot be discharged. + + Not an authorization decision. This surface is not saying the actor may not + act; it is saying *this claim cannot carry that weight*. + """ + + +def assert_human_control_dischargeable(principal_type: Claim) -> None: + """Guard for `GH-DEC-2026-016` §5 / PR-11. + + Today `principal_type: human` is a property of the client registration, so + this raises. That is correct and deliberate: the guard exists so the + limitation is visible at the point of use rather than buried in a document. + """ + if principal_type.value != "human": + raise HumanControlNotDischargeable( + f"principal_type is {principal_type.value!r}, not 'human'" + ) + if not principal_type.is_verified_about_the_person: + raise HumanControlNotDischargeable( + "principal_type 'human' arrived by " + f"{principal_type.route.value}; a human-in-the-loop control must not " + "be discharged on a claim that describes the client rather than the " + "person (GH-DEC-2026-016 §5, A-16)" + ) diff --git a/tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..0d5a6e7 Binary files /dev/null and b/tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py new file mode 100644 index 0000000..c80f20c --- /dev/null +++ b/tests/test_skeleton.py @@ -0,0 +1,456 @@ +"""Walking skeleton — the negative cases are as load-bearing as the happy path. + +Each negative case here is one from `docs/specs/UseCaseCatalog.md` and protects +a named invariant. If one of these starts passing by doing the forbidden thing, +the surface has become something this repository says it must not be. +""" + +from __future__ import annotations + +import pytest + +from informed_decision.approval_client import ( + ApprovalEngineError, + FakeApprovalEngine, + assert_scopes_permissible, + is_success, +) +from informed_decision.disposition import ( + Actor, + ActorKind, + DispositionRefused, + Verb, + legal_verbs, + record, +) +from informed_decision.evidence import ( + CustodyLocatorRejected, + EventClass, + Outbox, + assert_custody_locator_safe, + commit_disposition, + commit_presentation, + commit_stance_application, + heartbeat, +) +from informed_decision.memo import ( + Awareness, + BindingLevel, + BindingSlice, + Hat, + Highlight, + Memo, + PacketItem, + Principal, + Scope, + StepKind, +) +from informed_decision.presentation import render +from informed_decision.provenance import ( + Claim, + HumanControlNotDischargeable, + Route, + assert_human_control_dischargeable, +) + +CUSTODY = "informed-decision:presentations" +HUMAN = Actor("bernd", ActorKind.PERSON) +AGENT = Actor("drafter-bot", ActorKind.AGENT) + + +def make_memo(**over) -> Memo: + kw = dict( + id="memo-1", + version=1, + question="Approve rotation of the production database credential for T-1183?", + requested_act="approve", + binding_level=BindingLevel.ORGANIZATIONAL, + brief="The credential is 400 days old.", + binding=BindingSlice( + principal=Principal(id="p-1", kind="person", display_name="Bernd Worsch"), + target=Scope(kind="tenant", id="tenant:acme", label="ACME", environment="prod"), + ), + step_kind=StepKind.APPROVE, + packet=(PacketItem("doc-1", "Change request", "sha256:" + "a" * 64),), + highlights=( + Highlight("h-1", "doc-1", "Target is production", required_ack=True, severity="critical"), + ), + approval_id="appr-1", + ) + kw.update(over) + return Memo(**kw) + + +# ------------------------------------------------------------------------- +# Happy path +# ------------------------------------------------------------------------- + + +def test_one_approval_end_to_end_in_process(): + memo = make_memo() + engine = FakeApprovalEngine({"appr-1": {"status": "requested", "required_count": 1}}) + outbox = Outbox() + + pres = render( + memo, + principal_sub="bernd", + tenant=Claim("tenant:platform", Route.REGISTRATION), + principal_type=Claim("human", Route.REGISTRATION), + ) + outbox.append(commit_presentation(pres, custody=CUSTODY)) + + pres = pres.with_ack("h-1") + disp = record(memo, pres, Verb.ACCEPT, HUMAN) + outbox.append(commit_disposition(disp, custody=CUSTODY)) + + assert disp.reaches_approval_engine + result = engine.add_entry(memo.approval_id, "bernd") + + assert result.correlation == ("appr-1", "bernd", "2026-09-10T15:00:00Z") + assert result.status == "approved" + assert len(outbox.pending) == 2 + + +def test_the_presentation_is_reachable_from_the_correlation_triple(): + """DoD-3 — satisfied by the triple, not by a hash on the entry.""" + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + engine = FakeApprovalEngine({"appr-1": {"status": "requested", "required_count": 1}}) + result = engine.add_entry("appr-1", "bernd") + assert pres.approval_id == result.approval_id + assert pres.principal_sub == result.subject + assert pres.view_hash + + +# ------------------------------------------------------------------------- +# NC-01 — accept on a Kenntnisnahme step +# ------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind", [StepKind.INFORM, StepKind.COMMENT, StepKind.REVIEW, StepKind.ACKNOWLEDGE] +) +def test_accept_is_absent_from_weak_steps_not_merely_disabled(kind): + assert Verb.ACCEPT not in legal_verbs(kind) + assert Verb.ACKNOWLEDGE in legal_verbs(kind) + + +def test_accept_on_a_weak_step_is_refused_at_the_api(): + memo = make_memo(step_kind=StepKind.REVIEW, highlights=()) + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_STEP" + assert "Kenntnisnahme is not approval" in str(e.value) + + +# ------------------------------------------------------------------------- +# NC-02 — bind with unacknowledged required highlights +# ------------------------------------------------------------------------- + + +def test_bind_without_required_ack_fails_closed_and_creates_nothing(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_ACK" + assert "h-1" in str(e.value) + + +def test_acknowledgment_is_explicit_never_inferred(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + assert pres.acked_highlight_ids == frozenset() + assert pres.with_ack("h-1").acked_highlight_ids == {"h-1"} + + +# ------------------------------------------------------------------------- +# NC-03 — agents never bind. There is no upstream backstop. +# ------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", [Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE]) +def test_agent_cannot_perform_a_binding_verb(verb): + memo = make_memo() + pres = render(memo, principal_sub="bot").with_ack("h-1") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, verb, AGENT) + assert e.value.guard == "G_NOAGENT" + + +def test_agent_may_still_comment(): + memo = make_memo() + pres = render(memo, principal_sub="bot") + assert record(memo, pres, Verb.COMMENT, AGENT).verb is Verb.COMMENT + + +# ------------------------------------------------------------------------- +# NC-05 / NC-06 — the binding/awareness split, on live objects +# ------------------------------------------------------------------------- + + +def test_awareness_never_enters_view_hash(): + memo = make_memo() + plain = render(memo, principal_sub="bernd") + oriented = render( + memo, + principal_sub="bernd", + awareness=Awareness( + proposed_hat=Hat("hat:fc", "Finance Controller"), + proposed_hat_source="last_used", + situation_note="changed after the presentation was taken", + ), + ) + assert plain.view_hash == oriented.view_hash + assert plain.awareness_hash != oriented.awareness_hash + + +def test_changing_the_act_scope_changes_view_hash(): + memo = make_memo() + before = render(memo, principal_sub="bernd").view_hash + moved = memo.next_version( + binding=BindingSlice( + principal=memo.binding.principal, + target=Scope(kind="tenant", id="tenant:beta", label="Beta GmbH"), + ) + ) + assert render(moved, principal_sub="bernd").view_hash != before + + +def test_the_tenant_claim_is_not_the_act_scope(): + """PR-08 — two facts, one field upstream; never conflated here.""" + memo = make_memo() + a = render(memo, principal_sub="bernd", tenant=Claim("tenant:platform", Route.REGISTRATION)) + b = render(memo, principal_sub="bernd", tenant=Claim("tenant:coulomb", Route.DIRECTORY)) + assert a.view_hash == b.view_hash, "the tenant claim must not reach view_hash" + assert memo.binding.target.id == "tenant:acme" + + +# ------------------------------------------------------------------------- +# NC-07 — no silent version upgrade +# ------------------------------------------------------------------------- + + +def test_bind_against_a_stale_presentation_is_refused(): + memo = make_memo() + stale = render(memo, principal_sub="bernd").with_ack("h-1") + advanced = memo.next_version(brief="revised") + with pytest.raises(DispositionRefused) as e: + record(advanced, stale, Verb.ACCEPT, HUMAN) + assert e.value.guard == "G_PRES" + + +# ------------------------------------------------------------------------- +# NC-08 — return is structured, and is not decline +# ------------------------------------------------------------------------- + + +def test_return_without_coded_reasons_is_refused(): + memo = make_memo() + pres = render(memo, principal_sub="bernd") + with pytest.raises(DispositionRefused) as e: + record(memo, pres, Verb.RETURN, HUMAN, note="please clarify") + assert e.value.guard == "G_REASONS" + + +def test_return_is_distinguishable_from_decline_and_never_reaches_the_engine(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + returned = record(memo, pres, Verb.RETURN, HUMAN, reasons=("insufficient-context",)) + declined = record(memo, pres, Verb.DECLINE, HUMAN) + assert returned.verb is not declined.verb + assert not returned.reaches_approval_engine + assert not declined.reaches_approval_engine + + +def test_only_accept_reaches_the_engine(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + assert record(memo, pres, Verb.ACCEPT, HUMAN).reaches_approval_engine + for verb in (Verb.COMMENT, Verb.DISCUSS, Verb.FORWARD, Verb.ESCALATE): + assert not record(memo, pres, verb, HUMAN).reaches_approval_engine + + +# ------------------------------------------------------------------------- +# Engine semantics +# ------------------------------------------------------------------------- + + +def test_duplicate_approver_is_success_not_failure(): + assert is_success(ApprovalEngineError(409, "duplicate_approver")) + assert not is_success(ApprovalEngineError(409, "conflict")) + assert not is_success(ApprovalEngineError(503, "store_unavailable")) + + +def test_consume_scope_is_refused_before_a_token_is_requested(): + with pytest.raises(ValueError, match="approval:consume"): + assert_scopes_permissible(("openid", "approval:read", "approval:consume")) + + +def test_store_unavailable_fails_closed(): + engine = FakeApprovalEngine({"appr-1": {"status": "requested"}}) + engine.available = False + with pytest.raises(ApprovalEngineError) as e: + engine.add_entry("appr-1", "bernd") + assert e.value.status == 503 + + +def test_terminal_approval_conflicts(): + engine = FakeApprovalEngine({"appr-1": {"status": "revoked"}}) + with pytest.raises(ApprovalEngineError) as e: + engine.add_entry("appr-1", "bernd") + assert (e.value.status, e.value.reason) == (409, "conflict") + + +# ------------------------------------------------------------------------- +# GH-DEC-2026-016 §5 / PR-11 — humanity provenance +# ------------------------------------------------------------------------- + + +def test_human_control_not_dischargeable_on_a_registration_supplied_claim(): + with pytest.raises(HumanControlNotDischargeable, match="registration-supplied"): + assert_human_control_dischargeable(Claim("human", Route.REGISTRATION)) + + +@pytest.mark.parametrize("route", [Route.DIRECTORY, Route.AUTHENTICATION]) +def test_human_control_dischargeable_when_asserted_about_the_person(route): + assert_human_control_dischargeable(Claim("human", route)) + + +def test_service_principal_is_refused_outright(): + with pytest.raises(HumanControlNotDischargeable, match="not 'human'"): + assert_human_control_dischargeable(Claim("service", Route.DIRECTORY)) + + +def test_claims_are_stored_with_their_route_never_bare(): + memo = make_memo() + pres = render( + memo, + principal_sub="bernd", + tenant=Claim("tenant:platform", Route.REGISTRATION), + principal_type=Claim("human", Route.REGISTRATION), + ) + data = commit_presentation(pres, custody=CUSTODY).data + assert data["tenant"] == {"value": "tenant:platform", "route": "registration-supplied"} + assert data["principal_type"]["route"] == "registration-supplied" + + +# ------------------------------------------------------------------------- +# GH-DEC-2026-014 §4 — the existence assertion, and PR-12 +# ------------------------------------------------------------------------- + + +def test_every_commitment_carries_the_existence_assertion(): + memo = make_memo() + pres = render(memo, principal_sub="bernd").with_ack("h-1") + disp = record(memo, pres, Verb.ACCEPT, HUMAN) + for c in ( + commit_presentation(pres, custody=CUSTODY), + commit_disposition(disp, custody=CUSTODY), + commit_stance_application( + memo_id=memo.id, memo_version=1, binding_level="organizational", + binding_level_state="present", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ), + ): + assert c.data["content_exists"] is True + assert c.data["custody"] == CUSTODY + + +def test_commitment_carries_no_content(): + """Commitment-only: never the brief, never the packet.""" + memo = make_memo() + data = commit_presentation(render(memo, principal_sub="bernd"), custody=CUSTODY).data + flat = str(data) + assert memo.brief not in flat + assert "Change request" not in flat + assert data["view_hash"] + + +@pytest.mark.parametrize( + "locator", + [ + "https://user:pw@store.example/obj", + "https://store.example/obj?token=abc123", + "https://store.example/o?access_token=x", + "", + ], +) +def test_secret_shaped_custody_locators_are_rejected(locator): + with pytest.raises(CustodyLocatorRejected): + assert_custody_locator_safe(locator) + + +def test_stable_identifier_custody_locator_is_accepted(): + assert_custody_locator_safe("informed-decision:memo/memo-1/presentation/pres-9") + + +# ------------------------------------------------------------------------- +# Stance application is not a decline +# ------------------------------------------------------------------------- + + +def test_fail_closed_is_recorded_as_a_stance_not_as_a_decline(): + c = commit_stance_application( + memo_id="memo-1", memo_version=1, binding_level=None, + binding_level_state="absent", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ) + assert c.event_class is EventClass.STANCE_APPLICATION + assert "verb" not in c.data + assert c.data["stance_applied"] == "fail_closed" + + +def test_stance_application_pins_decision_attributable_false(): + """GH-DEC-2026-010 inherited: we cannot show access-engine said it.""" + c = commit_stance_application( + memo_id="m", memo_version=1, binding_level="organizational", + binding_level_state="present", stance="fail_closed", + dependency="access-engine", custody=CUSTODY, + ) + assert c.data["decision_attributable"] is False + + +# ------------------------------------------------------------------------- +# Outbox +# ------------------------------------------------------------------------- + + +def test_drain_failure_leaves_the_record_pending_never_lost(): + outbox = Outbox() + memo = make_memo() + outbox.append(commit_presentation(render(memo, principal_sub="b"), custody=CUSTODY)) + + def failing_sink(_): + raise RuntimeError("audit-core unreachable") + + with pytest.raises(RuntimeError): + outbox.drain(failing_sink) + assert len(outbox.pending) == 1 + assert outbox.drained == () + + +def test_drain_delivers_and_counts_reconcile_per_class(): + outbox = Outbox() + memo = make_memo() + pres = render(memo, principal_sub="b").with_ack("h-1") + outbox.append(commit_presentation(pres, custody=CUSTODY)) + outbox.append(commit_disposition(record(memo, pres, Verb.ACCEPT, HUMAN), custody=CUSTODY)) + sent: list[dict] = [] + assert outbox.drain(sent.append) == 2 + assert outbox.counts_by_class() == { + EventClass.PRESENTATION.value: 1, + EventClass.DISPOSITION.value: 1, + } + assert all("type" in e and "data" in e for e in sent) + + +def test_heartbeat_is_an_ordinary_event_with_the_same_envelope(): + hb = heartbeat(EventClass.DISPOSITION) + env = hb.as_envelope() + assert env["type"] == "audit-core.heartbeat" + assert env["data"] == { + "class": "informed-decision.disposition", + "assertion": "nothing-to-report", + } diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md index cebc804..73dfc5b 100644 --- a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -429,7 +429,7 @@ issuer, so it is not this repository's to decide alone. ```task id: INFD-WP-0001-T08 -status: todo +status: progress priority: medium state_hub_task_id: "b5c1d329-9580-5672-9640-2930cbbb729a" @@ -454,6 +454,39 @@ act is permitted. Gated externally on `approval-engine` `APPROVAL-WP-0002-T01` reaching `done` and on the service being deployed with an origin this surface can reach. +2026-09-10 — **domain core built and tested; the live proof remains gated.** +`approval-engine` `APPROVAL-WP-0002-T01` is still `progress` and the namespace +has no pods, so the end-to-end proof against a deployed engine cannot run. Built +everything that does not depend on it, with the engine behind a seam so its +arrival is a wiring change rather than a build: + +- `memo.py` — the Decision Memo, versions, and the 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. Field names follow the governed + canonicalizer (`item_id`, `severity`, `locator`) — the vectors are the + contract, so the object was aligned to them rather than the reverse. +- `presentation.py` — the **sole writer** of `view_hash`. Acknowledgment is an + explicit method call; nothing infers it. +- `disposition.py` — the verb vocabulary and guards. `accept` is **absent** from + weak steps rather than present-and-disabled, because a greyed-out accept still + teaches the wrong model. +- `provenance.py` — claim routes (A-16). `assert_human_control_dischargeable` + refuses a registration-supplied `human`, so PR-11's limitation is visible at + the point of use rather than buried in a document. +- `evidence.py` — the local outbox, commitment-only records carrying the + `GH-DEC-2026-014` §4 existence assertion, and a custody-locator guard that + rejects secret-shaped values (PR-12). +- `approval_client.py` — Protocol plus a fake with the engine's real semantics: + `409 duplicate_approver` is success, `409 conflict` terminal, `503` fail-closed, + and `approval:consume` refused before a token is ever requested. + +87 tests pass, including every negative case in the Use Case Catalog: NC-01 +through NC-08, the humanity-provenance guard, the existence assertion, 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. + +Remaining for `done`: the live proof. Superseded context — 2026-09-09: additionally gated on `INFD-IN-0003` — `GH-DEC-2026-012` limit 3 requires the evidence copy to reach `audit-core` independently of this component, and the payload question is open. Design and decision request in