informed-decision/informed_decision/approval_client.py

115 lines
4.2 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 seam to `approval-engine`.
A Protocol plus a fake, because the engine is not deployed
(`APPROVAL-WP-0002-T01` is `progress`, no pods). When it deploys this becomes a
wiring change rather than a build.
Hard rules, from `approval-engine/docs/approver-surface-requirements.md`:
- `GET /v1/approvals/{id}` and `/claim` need ``approval:read``;
`POST /entries` needs ``approval:approve``.
- **Never** ``/consume``. Human principals are refused there in code, and
consumption belongs to the PEP causing the side effect (`GH-DEC-2026-003`).
- No path containing ``check`` or ending ``/authorize`` exists to call.
- ``POST /entries`` **discards its request body** identity, assurance and
``evidence_ref`` come only from the verified token. So ``view_hash`` does not
ride into the entry; correlation is ``(approval_id, subject, approved_at)``.
- **No inbox.** Get-by-id only. Never poll for work.
- `approved` is a state of an object, never permission to act.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
FORBIDDEN_SCOPE = "approval:consume"
REQUIRED_SCOPES = ("openid", "approval:read", "approval:approve")
class ApprovalEngineError(Exception):
def __init__(self, status: int, reason: str) -> None:
super().__init__(f"{status} {reason}")
self.status = status
self.reason = reason
@dataclass(frozen=True)
class EntryResult:
"""What comes back after a successful entry.
These three fields are the correlation triple that ties the entry back to
our presentation record `GOAL.md` DoD-3.
"""
approval_id: str
subject: str
approved_at: str
status: str
duplicate: bool = False
@property
def correlation(self) -> tuple[str, str, str]:
return (self.approval_id, self.subject, self.approved_at)
class ApprovalEngine(Protocol):
def get_approval(self, approval_id: str) -> dict: ...
def add_entry(self, approval_id: str) -> EntryResult: ...
def assert_scopes_permissible(scopes: tuple[str, ...]) -> None:
"""Refuse ``approval:consume`` before a token is ever requested."""
if FORBIDDEN_SCOPE in scopes:
raise ValueError(
f"{FORBIDDEN_SCOPE} must never be requested: approval-engine refuses it "
"for human principals and consumption belongs to the PEP causing the "
"side effect (GH-DEC-2026-003)"
)
def is_success(err: ApprovalEngineError) -> bool:
"""`409 duplicate_approver` is SUCCESS, not failure.
A browser double-submit is routine and the first entry stands. Rendering it
as an error is a lie to the approver.
"""
return err.status == 409 and err.reason == "duplicate_approver"
class FakeApprovalEngine:
"""In-process stand-in with the engine's actual refusal semantics."""
def __init__(self, approvals: dict[str, dict] | None = None) -> None:
self._approvals = approvals or {}
self._entries: dict[str, set[str]] = {}
self.available = True
def get_approval(self, approval_id: str) -> dict:
if not self.available:
raise ApprovalEngineError(503, "store_unavailable")
if approval_id not in self._approvals:
raise ApprovalEngineError(404, "not_found")
return dict(self._approvals[approval_id])
def add_entry(self, approval_id: str, subject: str = "approver") -> EntryResult:
if not self.available:
raise ApprovalEngineError(503, "store_unavailable")
approval = self._approvals.get(approval_id)
if approval is None:
raise ApprovalEngineError(404, "not_found")
if approval.get("status") in ("revoked", "superseded", "consumed", "expired"):
raise ApprovalEngineError(409, "conflict")
seen = self._entries.setdefault(approval_id, set())
duplicate = subject in seen
seen.add(subject)
if not duplicate and len(seen) >= approval.get("required_count", 1):
approval["status"] = "approved"
return EntryResult(
approval_id=approval_id,
subject=subject,
approved_at="2026-09-10T15:00:00Z",
status=approval.get("status", "requested"),
duplicate=duplicate,
)