informed-decision/informed_decision/disposition.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

174 lines
5.5 KiB
Python

"""The disposition vocabulary and its guards.
Approve/reject is the wrong vocabulary for judgment. `return` is success, not
failure, and `accept` on a Kenntnisnahme step is illegal on purpose.
Nothing here is an authorization decision. A disposition is evidence that a
human performed an act; it never answers whether the act was permitted
(`access-engine`, statute §6).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from .memo import Memo, StepKind
from .presentation import Presentation
class Verb(str, Enum):
COMMENT = "comment"
DISCUSS = "discuss"
RETURN = "return"
FORWARD = "forward"
ESCALATE = "escalate"
ACKNOWLEDGE = "acknowledge"
ACCEPT = "accept"
DECLINE = "decline"
WITHDRAW = "withdraw"
CONFIGURE = "configure"
#: Verbs that bind an identity to an act. Guarded hardest.
BINDING_VERBS = frozenset({Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE})
#: Always available while the memo is circulating, whatever the step kind.
OVERLAY_VERBS = frozenset({Verb.COMMENT, Verb.DISCUSS})
class ActorKind(str, Enum):
PERSON = "person"
AGENT = "agent"
SERVICE = "service"
SYSTEM = "system"
@dataclass(frozen=True)
class Actor:
sub: str
kind: ActorKind
class DispositionRefused(Exception):
"""A verb was refused. Never an authorization verdict — see module docstring."""
def __init__(self, guard: str, message: str) -> None:
super().__init__(f"{guard}: {message}")
self.guard = guard
def legal_verbs(step_kind: StepKind) -> frozenset[Verb]:
"""Which verbs the UI may offer for a step kind.
`accept` is ABSENT for weak steps, not present-and-disabled: Kenntnisnahme
is not approval, and a greyed-out accept still teaches the wrong model.
"""
base = set(OVERLAY_VERBS) | {Verb.RETURN, Verb.FORWARD, Verb.ESCALATE}
if step_kind.is_weak:
return frozenset(base | {Verb.ACKNOWLEDGE})
return frozenset(base | {Verb.ACCEPT, Verb.DECLINE, Verb.ACKNOWLEDGE})
@dataclass(frozen=True)
class Disposition:
id: str
memo_id: str
memo_version: int
presentation_id: str
verb: Verb
actor: Actor
at: str
reasons: tuple[str, ...] = ()
note: str | None = None
#: Co-reference to the act (GH-DEC-2026-012 R3), identifier only.
approval_id: str | None = None
@property
def reaches_approval_engine(self) -> bool:
"""Only `accept` becomes a POST to /entries.
`return`, `discuss`, `escalate` and the rest are dispositions of a MEMO;
approval-engine models entries against an approval object and knows
nothing of them. A memo `return` must not be represented there at all.
"""
return self.verb is Verb.ACCEPT
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def record(
memo: Memo,
presentation: Presentation,
verb: Verb,
actor: Actor,
*,
reasons: tuple[str, ...] = (),
note: str | None = None,
) -> Disposition:
"""Record a disposition, or refuse it.
Guards, in the order a defect is most likely to be caught:
- ``G_NOAGENT`` humans bind, agents draft. No upstream backstop exists.
- ``G_STEP`` the verb must be legal for this step kind.
- ``G_PRES`` the presentation must be of this memo AND this version.
- ``G_ACK`` required highlights acked before any binding verb.
- ``G_REASONS`` a return carries at least one coded reason.
- ``G_SEALED`` binding verbs on a sealed version are illegal.
"""
if verb in BINDING_VERBS and actor.kind is not ActorKind.PERSON:
raise DispositionRefused(
"G_NOAGENT",
f"{actor.kind.value} principals may draft but never bind "
"(INTENT principle 10; approval-engine provides no upstream backstop)",
)
if verb not in legal_verbs(memo.step_kind):
detail = ""
if verb is Verb.ACCEPT and memo.step_kind.is_weak:
detail = " — Kenntnisnahme is not approval"
raise DispositionRefused(
"G_STEP", f"{verb.value} is not legal on a {memo.step_kind.value} step{detail}"
)
if presentation.memo_id != memo.id:
raise DispositionRefused("G_PRES", "presentation belongs to a different memo")
if presentation.memo_version != memo.version:
raise DispositionRefused(
"G_PRES",
f"presentation is of version {presentation.memo_version}, memo is at "
f"{memo.version} — no silent upgrade",
)
if verb in BINDING_VERBS:
if memo.sealed:
raise DispositionRefused("G_SEALED", "binding verbs on a sealed version are illegal")
outstanding = memo.required_ack_ids - presentation.acked_highlight_ids
if outstanding:
raise DispositionRefused(
"G_ACK",
"required highlights not acknowledged: " + ", ".join(sorted(outstanding)),
)
if verb is Verb.RETURN and not reasons:
raise DispositionRefused(
"G_REASONS", "a return carries at least one coded reason; free text is not a return"
)
return Disposition(
id=f"disp-{uuid.uuid4()}",
memo_id=memo.id,
memo_version=memo.version,
presentation_id=presentation.id,
verb=verb,
actor=actor,
at=_now(),
reasons=reasons,
note=note,
approval_id=memo.approval_id,
)