informed-decision/informed_decision/disposition.py
tegwick 2cc32168ac Persist review evidence and deliver audit records transactionally
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 23:27:19 +02:00

178 lines
5.6 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; declared controls also guard upstream.
- ``G_STEP`` the verb must be legal for this step kind.
- ``G_PRES`` the presentation must be of this memo AND this version.
- ``G_ACTOR`` the acting person must be the presentation's recipient.
- ``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)",
)
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 actor.sub != presentation.principal_sub:
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
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,
)