informed-decision/informed_decision/memo.py
tegwick 9e1f77e32b Build the T08 domain core with the engine behind a seam
approval-engine APPROVAL-WP-0002-T01 is still progress and its namespace has no
pods, so the live end-to-end proof cannot run. Built everything that does not
depend on it, with the engine behind a Protocol plus a fake carrying its real
refusal semantics, so its arrival is a wiring change rather than a build.

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

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

T08 stays progress: the live proof is the remainder.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1565372@bnt-lap001
Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
2026-09-10 20:38:20 +02:00

263 lines
8.3 KiB
Python

"""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