264 lines
8.3 KiB
Python
264 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
|