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
174
informed_decision/disposition.py
Normal file
174
informed_decision/disposition.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue