"""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, )