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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue