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
27
SCOPE.md
27
SCOPE.md
|
|
@ -6,15 +6,28 @@
|
||||||
|
|
||||||
## Status — 2026-09-09
|
## 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`,
|
What exists and is tested (87 tests):
|
||||||
`pep-stance.yaml`, `informed_decision/stance.py`), the governed canonicalizer
|
|
||||||
and schema, and the four specs under `docs/specs/`. 46 tests pass.
|
|
||||||
|
|
||||||
What does not exist: any HTTP surface, any storage, any UI, any deployment. The
|
- layer and stance declarations — `layer.yaml`, `pep-stance.yaml`,
|
||||||
walking skeleton is `INFD-WP-0001-T08` and is gated on an external decision
|
`informed_decision/stance.py`, with published-equals-shipped asserted;
|
||||||
(§"Open" below).
|
- 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
|
## One-liner
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
"""informed-decision — presentation and binding surface for decisions.
|
"""informed-decision — presentation and binding surface for decisions.
|
||||||
|
|
||||||
This package must never contain an authorization decision. See ``INTENT.md``
|
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 .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",
|
||||||
|
]
|
||||||
|
|
|
||||||
Binary file not shown.
BIN
informed_decision/__pycache__/approval_client.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/approval_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/disposition.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/disposition.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/evidence.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/evidence.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/memo.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/memo.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/presentation.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/presentation.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/provenance.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/provenance.cpython-312.pyc
Normal file
Binary file not shown.
114
informed_decision/approval_client.py
Normal file
114
informed_decision/approval_client.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
174
informed_decision/disposition.py
Normal file
174
informed_decision/disposition.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
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
|
||||||
263
informed_decision/memo.py
Normal file
263
informed_decision/memo.py
Normal file
|
|
@ -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
|
||||||
92
informed_decision/presentation.py
Normal file
92
informed_decision/presentation.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
91
informed_decision/provenance.py
Normal file
91
informed_decision/provenance.py
Normal file
|
|
@ -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)"
|
||||||
|
)
|
||||||
BIN
tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc
Normal file
BIN
tests/__pycache__/test_skeleton.cpython-312-pytest-7.4.4.pyc
Normal file
Binary file not shown.
456
tests/test_skeleton.py
Normal file
456
tests/test_skeleton.py
Normal file
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -429,7 +429,7 @@ issuer, so it is not this repository's to decide alone.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: INFD-WP-0001-T08
|
id: INFD-WP-0001-T08
|
||||||
status: todo
|
status: progress
|
||||||
|
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "b5c1d329-9580-5672-9640-2930cbbb729a"
|
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
|
Gated externally on `approval-engine` `APPROVAL-WP-0002-T01` reaching `done` and
|
||||||
on the service being deployed with an origin this surface can reach.
|
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
|
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
|
requires the evidence copy to reach `audit-core` independently of this
|
||||||
component, and the payload question is open. Design and decision request in
|
component, and the payload question is open. Design and decision request in
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue