INFD: frictionless decision intake design and workplan (CUST-WP-0075)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
parent
a7ec5a5f87
commit
a973d3656a
7 changed files with 1314 additions and 0 deletions
99
informed_decision/hub_writeback.py
Normal file
99
informed_decision/hub_writeback.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""State Hub decision write-back (INFD-WP-0005, FrictionlessDecisionIntake §8).
|
||||
|
||||
When a human's terminal disposition (accept or decline) answers a memo that
|
||||
serves a hub decision with ``resolves: on-answer``, resolve that decision with
|
||||
the memo id in the rationale. ``POST /decisions/{id}/resolve`` is the hub's
|
||||
sanctioned write; it reflects an act already taken here and decides nothing.
|
||||
|
||||
Off unless ``INFD_HUB_WRITEBACK=1``: when off, the payload is built and
|
||||
returned as ``skipped`` so the evidence exists, but nothing is sent. The
|
||||
transport is injected; this module opens no socket by itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Mapping
|
||||
|
||||
FLAG = "INFD_HUB_WRITEBACK"
|
||||
RESOLVING_VERBS = frozenset({"accept", "decline"})
|
||||
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
#: (method, url, json body) -> (status, parsed json or {})
|
||||
Transport = Callable[[str, str, dict], tuple[int, dict]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Answer:
|
||||
"""A human's recorded terminal disposition on one memo."""
|
||||
|
||||
memo_id: str
|
||||
memo_version: int
|
||||
verb: str
|
||||
disposition_id: str
|
||||
decided_at: str
|
||||
reviewer_display: str
|
||||
approval_id: str | None
|
||||
approval_binding_digest: str | None
|
||||
view_hash: str
|
||||
actor_kind: str = "person"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WritebackResult:
|
||||
status: str # not_applicable | skipped | resolved | already_resolved | conflict | failed
|
||||
decision_id: str | None
|
||||
payload: dict | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def enabled(env: Mapping[str, str] | None = None) -> bool:
|
||||
return (os.environ if env is None else env).get(FLAG) == "1"
|
||||
|
||||
|
||||
def build_resolution(answer: Answer) -> dict:
|
||||
if answer.actor_kind != "person":
|
||||
raise ValueError("only a human disposition can resolve a hub decision")
|
||||
if answer.verb not in RESOLVING_VERBS:
|
||||
raise ValueError(f"verb {answer.verb!r} does not resolve a decision")
|
||||
rationale = (
|
||||
f"{answer.verb} in informed-decision memo {answer.memo_id} v{answer.memo_version}; "
|
||||
f"approval {answer.approval_id or 'none'}; binding {answer.approval_binding_digest or 'none'}; "
|
||||
f"view_hash {answer.view_hash}; disposition {answer.disposition_id} at {answer.decided_at}"
|
||||
)
|
||||
return {
|
||||
"decided_by": f"{answer.reviewer_display} via informed-decision",
|
||||
"rationale": rationale,
|
||||
"write_log": True,
|
||||
}
|
||||
|
||||
|
||||
def write_back(answer: Answer, serves: Mapping | None, *, base_url: str,
|
||||
transport: Transport, env: Mapping[str, str] | None = None) -> WritebackResult:
|
||||
if not serves or serves.get("resolves") != "on-answer" or answer.verb not in RESOLVING_VERBS:
|
||||
return WritebackResult("not_applicable", (serves or {}).get("hub_decision_id"))
|
||||
decision_id = serves.get("hub_decision_id", "")
|
||||
if not _UUID_RE.match(decision_id or ""):
|
||||
raise ValueError("hub_decision_id must be a lowercase UUID")
|
||||
payload = build_resolution(answer)
|
||||
if not enabled(env):
|
||||
return WritebackResult("skipped", decision_id, payload, f"{FLAG} is not 1")
|
||||
url = f"{base_url.rstrip('/')}/decisions/{decision_id}/resolve"
|
||||
try:
|
||||
status, body = transport("POST", url, payload)
|
||||
except OSError as exc:
|
||||
return WritebackResult("failed", decision_id, payload, type(exc).__name__)
|
||||
if 200 <= status < 300:
|
||||
return WritebackResult("resolved", decision_id, payload)
|
||||
if status == 409:
|
||||
# Already resolved: ours if the hub rationale names this memo.
|
||||
try:
|
||||
_, current = transport("GET", f"{base_url.rstrip('/')}/decisions/{decision_id}", {})
|
||||
except OSError as exc:
|
||||
return WritebackResult("failed", decision_id, payload, type(exc).__name__)
|
||||
if f"memo {answer.memo_id} " in (current.get("rationale") or ""):
|
||||
return WritebackResult("already_resolved", decision_id, payload)
|
||||
return WritebackResult("conflict", decision_id, payload, json.dumps(body)[:200])
|
||||
return WritebackResult("failed", decision_id, payload, f"http_{status}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue