informed-decision/informed_decision/disposition.py

179 lines
5.6 KiB
Python
Raw Normal View History

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