"""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 import re from dataclasses import dataclass, field, replace from enum import Enum #: approval-engine's digest format. We validate the shape and NEVER compute one. APPROVAL_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") 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.2.0" #: Co-reference to the act this memo presents. approval_id: str | None = None #: approval-engine's binding.digest over the five act fields, CARRIED here #: under GH-DEC-2026-015 (activated 2026-09-10 once approval-engine stated #: the presentation exclusion as normative and tested). #: #: It is referenced, never recomputed: this repository must not restate that #: digest from its own vocabulary. When present, the act-scope stops being #: independently canonicalized here, so the act has exactly ONE #: canonicalization — computed by the layer that owns it. approval_binding_digest: 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") if self.approval_binding_digest is not None: if not APPROVAL_DIGEST_RE.match(self.approval_binding_digest): raise ValueError( "approval_binding_digest must be approval-engine's " "sha256:<64 hex> form; it is carried, never computed here" ) if self.approval_id is None: raise ValueError( "a carried binding digest needs the approval it belongs to" ) 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, **( {"approval_binding_digest": self.approval_binding_digest} if self.approval_binding_digest is not None else {} ), "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: """The binding slice, minus whatever the carried digest already covers. Where ``approval_binding_digest`` is present, the **act-scope** is omitted: `target` is act material and is covered by that digest, so canonicalizing it again here would be the partial recomputation in a second vocabulary that GH-DEC-2026-015 exists to remove. ``principal`` is **kept**. approval-engine's `principal` is the party *on whose behalf* the approval was issued; ours is the person being bound — the approver. Different roles, so dropping ours would remove *who was shown this* from `view_hash` and gut the promise this repository exists to make. Approval Engine confirmed these distinct roles in docs/approval-claim.md (a0a6029); this field remains. """ out: dict = {"principal": self.binding.principal.as_document()} if self.approval_binding_digest is None: 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["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