informed-decision/informed_decision/memo.py
tegwick 83849b75d4 Connect policy-gated browser review and audit runtime
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-11 00:31:03 +02:00

304 lines
10 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
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