informed-decision/informed_decision/outcome.py
tegwick 8787e1a14a INFD-WP-0010: record founder decisions 2026-11-01; outcome read for State Hub's pull (T04)
Founder decisions relayed 2026-11-01: GH-DEC-2026-026 accepted as drafted
(unblocks S4 doctrine; S4 now waits only on access-engine policy), and the
hub pulls decision outcomes through its STATE-WP-0096 dispatch worker.

S3 re-scoped: GET /v1/memos/{id}/outcome on the private listener returns
informed-decision.decision-outcome v1 for memos the caller posed, only for
a submitter row with outcome_read: true. Read-only and value-free.
T05 (enable after STATE-WP-0101) and T06 (retire push write-back) added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-11-01 00:37:59 +01:00

103 lines
5.9 KiB
Python

"""Decision outcome read for a submitter's own memos (INFD-WP-0010-T04, founder decision 2026-11-01).
State Hub's dispatch worker (STATE-WP-0096) pulls the human's answer to a memo it posed and
carries it to the hub; informed-decision holds no hub credential. This module builds the
``informed-decision.decision-outcome`` v1 record (state-hub
``docs/decision-services-alignment-answer.md`` section 5.2) from evidence already recorded here.
Read-only. Value-free: identifiers, digests, the verb, the deciding subject and its assurance.
No packet content, brief, note, reasons, display name or token ever leaves through this record.
It is a projection of evidence; nothing may execute on the strength of it.
"""
from datetime import datetime, timezone
import json
from .evidence import EventClass
from .store import EvidenceUnavailable
DISPOSITION = EventClass.DISPOSITION.value
KIND = "informed-decision.decision-outcome"
VERSION = 1
TERMINAL = ("accept", "decline")
MAX_RECORD = 8 * 1024
class OutcomeError(Exception):
def __init__(self, status, code):
self.status, self.code = status, code
super().__init__(code)
def _assurance(db, disposition_id):
"""The assurance the PDP evaluated for this act, from its recorded policy observation."""
row = db.execute("SELECT content FROM evidence WHERE class=? "
"AND json_extract(envelope,'$.data.disposition_id')=? ORDER BY rowid LIMIT 1",
(DISPOSITION, disposition_id)).fetchone()
policy_id = json.loads(row["content"]).get("policy_observation_id") if row else None
observation = db.execute("SELECT request,outcome FROM policy_observations WHERE id=?",
(policy_id,)).fetchone() if policy_id else None
if observation is None or observation["outcome"] != "allow":
return None
attributes = json.loads(observation["request"]).get("subject", {}).get("attributes", {})
assurance = attributes.get("assurance")
if (not isinstance(assurance, dict) or not isinstance(assurance.get("level"), str)
or type(assurance.get("at")) is not int or assurance["at"] <= 0):
return None
auth_time = datetime.fromtimestamp(assurance["at"], timezone.utc).isoformat(timespec="seconds")
return {"aal": assurance["level"], "auth_time": auth_time.replace("+00:00", "Z")}
def outcome_for(store, submitter_id, memo_id, *, issuer):
"""(status, body) for one memo. 404 hides memos this submitter did not pose."""
with store._connection() as db:
intake = db.execute("SELECT metadata FROM intake_ledger WHERE submitter=? AND memo_id=? AND state='ready'",
(submitter_id, memo_id)).fetchone()
if intake is None:
raise OutcomeError(404, "memo_not_found")
metadata = json.loads(intake["metadata"])
serves = metadata.get("serves")
if metadata.get("profile") == "present_existing" or not serves or serves.get("resolves") != "on-answer":
raise OutcomeError(409, "memo_does_not_resolve")
# The first terminal human answer on any revision is the answer (resolves: on-answer).
rows = db.execute("SELECT d.id,d.body FROM dispositions d JOIN presentations p ON p.id=d.presentation_id "
"WHERE p.memo_id=? ORDER BY d.rowid", (memo_id,)).fetchall()
answer = None
for row in rows:
body = json.loads(row["body"])
if body["verb"] in TERMINAL and body["actor"]["kind"] == "person":
answer = body
break
if answer is None:
return 202, {"kind": KIND, "version": VERSION, "memo_id": memo_id, "status": "pending"}
presentation = json.loads(db.execute("SELECT body FROM presentations WHERE id=?",
(answer["presentation_id"],)).fetchone()["body"])
assurance = _assurance(db, answer["id"])
if assurance is None:
raise OutcomeError(409, "assurance_unrecorded")
record = {"kind": KIND, "version": VERSION, "status": "decided",
"hub_decision_id": serves["hub_decision_id"],
"memo_id": memo_id, "memo_revision": answer["memo_version"],
"verb": answer["verb"],
"subject": {"issuer": issuer, "sub": answer["actor"]["sub"]},
"assurance": assurance, "decided_at": answer["at"],
"view_hash": presentation["view_hash"], "presentation_id": answer["presentation_id"],
"approval_id": answer.get("approval_id")}
if answer["verb"] == "accept":
submission = db.execute("SELECT state,approved_at FROM submissions WHERE disposition_id=?",
(answer["id"],)).fetchone()
if submission is None or submission["state"] in ("prepared", "in_flight"):
# The human accepted; the approval entry is not yet confirmed. Not an answer yet.
return 202, {"kind": KIND, "version": VERSION, "memo_id": memo_id, "status": "pending"}
if submission["state"] != "confirmed":
raise OutcomeError(409, "approval_entry_unresolved")
# approval-engine entries carry no id; (approval_id, subject, approved_at) is the correlation.
record["approval_entry"] = {"approved_at": submission["approved_at"]}
audit = db.execute("SELECT o.receiver_reference FROM evidence e JOIN outbox o ON o.id=e.id "
"WHERE e.class=? AND json_extract(e.envelope,'$.data.disposition_id')=? "
"AND o.state='delivered' ORDER BY e.rowid LIMIT 1", (DISPOSITION, answer["id"])).fetchone()
if audit is not None and audit["receiver_reference"]:
record["audit_ref"] = audit["receiver_reference"]
if len(json.dumps(record).encode()) > MAX_RECORD:
raise EvidenceUnavailable("outcome record exceeds its bound")
return 200, record