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
This commit is contained in:
parent
ba7a797160
commit
8787e1a14a
7 changed files with 515 additions and 36 deletions
|
|
@ -26,6 +26,9 @@ _ACTOR_RE = re.compile(r"service:[a-z0-9][a-z0-9-]{1,62}")
|
|||
_ACTION_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,62}")
|
||||
|
||||
|
||||
_OUTCOME_PATH = re.compile(r"/v1/memos/([a-z0-9][a-z0-9-]{2,62})/outcome")
|
||||
|
||||
|
||||
class IntakeError(Exception):
|
||||
def __init__(self, status, code):
|
||||
self.status, self.code = status, code
|
||||
|
|
@ -72,7 +75,8 @@ def configuration(submitters_path, groups_path):
|
|||
for username, row in submitters.items():
|
||||
if (not re.fullmatch(r"system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+", username)
|
||||
or not isinstance(row, dict)
|
||||
or set(row) not in (ROW_KEYS, ROW_KEYS | {"present_existing"})
|
||||
or not ROW_KEYS <= set(row) <= ROW_KEYS | {"present_existing", "outcome_read"}
|
||||
or ("outcome_read" in row and (row["outcome_read"] is not True or "present_existing" in row))
|
||||
or not isinstance(row["submitter_id"], str)
|
||||
or not row["submitter_id"] or type(row["daily_limit"]) is not int
|
||||
or row["daily_limit"] < 1):
|
||||
|
|
@ -139,11 +143,33 @@ def verify_existing(approval, submission, *, now=None):
|
|||
|
||||
|
||||
class IntakeService:
|
||||
def __init__(self, store, authenticator, creator, *, submitters_path, groups_path, reader=None):
|
||||
"""``reader(approval_id) -> dict`` is a read-only GET used only by present_existing."""
|
||||
def __init__(self, store, authenticator, creator, *, submitters_path, groups_path, reader=None, issuer=None):
|
||||
"""``reader(approval_id) -> dict`` is a read-only GET used only by present_existing.
|
||||
|
||||
``issuer`` names the human sessions' OIDC issuer in outcome records; without it the
|
||||
outcome read is unavailable.
|
||||
"""
|
||||
self.store, self.authenticator, self.creator = store, authenticator, creator
|
||||
self.submitters_path, self.groups_path = submitters_path, groups_path
|
||||
self.reader = reader
|
||||
self.reader, self.issuer = reader, issuer
|
||||
|
||||
def outcome(self, token, memo_id):
|
||||
"""INFD-WP-0010-T04: the answer to one memo this submitter posed, for the hub's own pull.
|
||||
|
||||
Only rows with ``outcome_read: true`` (reviewed deployment data: State Hub's dispatch
|
||||
worker) may read, and only memos they posed. Read-only; no budget is spent.
|
||||
"""
|
||||
username = self.authenticator.authenticate(token)
|
||||
submitters, _ = configuration(self.submitters_path, self.groups_path)
|
||||
row = submitters.get(username)
|
||||
if row is None:
|
||||
raise IntakeError(403, "submitter_unknown")
|
||||
if row.get("outcome_read") is not True:
|
||||
raise IntakeError(403, "outcome_read_not_admitted")
|
||||
if self.issuer is None:
|
||||
raise IntakeError(503, "outcome_unavailable")
|
||||
from .outcome import outcome_for
|
||||
return outcome_for(self.store, row["submitter_id"], memo_id, issuer=self.issuer)
|
||||
|
||||
def pose(self, token, envelope):
|
||||
username = self.authenticator.authenticate(token)
|
||||
|
|
@ -286,24 +312,30 @@ class IntakeService:
|
|||
raise IntakeError(409, "approval_already_presented")
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
from .outcome import OutcomeError
|
||||
try:
|
||||
if environ.get("PATH_INFO") != "/v1/memos":
|
||||
path = environ.get("PATH_INFO", "")
|
||||
outcome = _OUTCOME_PATH.fullmatch(path)
|
||||
if path != "/v1/memos" and outcome is None:
|
||||
raise IntakeError(404, "not_found")
|
||||
if environ.get("REQUEST_METHOD") != "POST":
|
||||
if environ.get("REQUEST_METHOD") != ("GET" if outcome else "POST"):
|
||||
raise IntakeError(405, "method_not_allowed")
|
||||
authorization = environ.get("HTTP_AUTHORIZATION", "")
|
||||
if not authorization.startswith("Bearer ") or not authorization[7:] or len(authorization) > 16384:
|
||||
raise IntakeError(401, "workload_token_required")
|
||||
if environ.get("CONTENT_TYPE", "").split(";")[0] != "application/json":
|
||||
raise IntakeError(415, "json_required")
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise IntakeError(413, "submission_too_large")
|
||||
raw = environ["wsgi.input"].read(length)
|
||||
if len(raw) != length:
|
||||
raise IntakeError(400, "invalid_json")
|
||||
status, body = self.pose(authorization[7:], json.loads(raw))
|
||||
except IntakeError as exc:
|
||||
if outcome is not None:
|
||||
status, body = self.outcome(authorization[7:], outcome.group(1))
|
||||
else:
|
||||
if environ.get("CONTENT_TYPE", "").split(";")[0] != "application/json":
|
||||
raise IntakeError(415, "json_required")
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise IntakeError(413, "submission_too_large")
|
||||
raw = environ["wsgi.input"].read(length)
|
||||
if len(raw) != length:
|
||||
raise IntakeError(400, "invalid_json")
|
||||
status, body = self.pose(authorization[7:], json.loads(raw))
|
||||
except (IntakeError, OutcomeError) as exc:
|
||||
status, body = exc.status, {"error": exc.code}
|
||||
except Conflict:
|
||||
status, body = 409, {"error": "review_handoff_conflict"}
|
||||
|
|
|
|||
103
informed_decision/outcome.py
Normal file
103
informed_decision/outcome.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""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
|
||||
|
|
@ -100,4 +100,5 @@ def compose_intake(store, token_review_call, *, issuer, secret_file,
|
|||
read_provider = RequesterTokenProvider(issuer, secret_file, transport=transport, scope="approval:read")
|
||||
reader = IntakeApprovalReader(approval_origin, read_provider, allow_internal_http=True)
|
||||
return IntakeService(store, TokenReview(token_review_call), creator,
|
||||
submitters_path=submitters_path, groups_path=groups_path, reader=reader)
|
||||
submitters_path=submitters_path, groups_path=groups_path, reader=reader,
|
||||
issuer=issuer)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class PrivateWorkloadApp:
|
|||
|
||||
def __call__(self, environ, start_response):
|
||||
path = environ.get('PATH_INFO', '')
|
||||
if path == '/v1/memos' and self.intake is not None:
|
||||
if (path == '/v1/memos' or path.startswith('/v1/memos/')) and self.intake is not None:
|
||||
return self.intake(environ, start_response)
|
||||
if (path == '/v1/decisions' or path.startswith('/v1/decisions/')) and self.lifecycle is not None:
|
||||
return self.lifecycle(environ, start_response)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue