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
|
|
@ -5,13 +5,29 @@
|
|||
trigger `docs/proposals/2026-10-31-nk56-t17-founder-approval-path.md`, hub message `6811164f`.
|
||||
**Workplan:** `workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md`.
|
||||
|
||||
## Founder decisions, 2026-11-01
|
||||
|
||||
Relayed by the net-kingdom coordinator on 2026-11-01 (quoted as relayed):
|
||||
|
||||
1. **gate-house GH-DEC-2026-026 accepted as drafted:** decision classes C0–C3; a decision is at least C2 when
|
||||
anyone relies on it as authority for an act its standing mandate doesn't cover; C3 reserved; the actor that
|
||||
files a C2 approval may never approve it; agents never approve at C1+. This answers open question 1 and removes
|
||||
S4's doctrine dependency. S4's only remaining external blocker is access-engine policy work (below).
|
||||
2. **State Hub write-back:** the hub's existing STATE-WP-0096 dispatch worker fetches decision outcomes from
|
||||
informed-decision (informed-decision gets no hub credential); state-hub needs an outcome read, record
|
||||
`informed-decision.decision-outcome` v1 (fields: state-hub `docs/decision-services-alignment-answer.md`
|
||||
§5.2). Switch informed-decision's hub write-back on once STATE-WP-0101 lands. This answers open question 2
|
||||
and re-scopes S3 (below).
|
||||
3. access-engine (review policy for requester `service:nk-bind-executor`) and key-cape (`approval:read` for
|
||||
`informed-decision-sitting-requester`) are being asked by separate workers (open questions 3 and 4).
|
||||
|
||||
## Summary
|
||||
|
||||
| Item | Answer | Status |
|
||||
| --- | --- | --- |
|
||||
| S1 `present_existing` | **Accepted, adapted** as a submitter *profile* on the existing `/v1/memos` route | Implemented locally and tested; not deployed. Two owner dependencies outside this repository before live use (below) |
|
||||
| S4 C1 single-screen confirm | **Accepted in principle** as a lighter memo profile on the same memo model | Not built. Needs gate-house class doctrine (S5) and an access-engine policy action first |
|
||||
| S3 hub write-back | **Accepted.** The write-back already exists (`hub_writeback.py`, INFD-WP-0005) behind `INFD_HUB_WRITEBACK=1` | Enablement is a founder decision; structured links need state-hub's decision-link fields |
|
||||
| S4 C1 single-screen confirm | **Accepted in principle** as a lighter memo profile on the same memo model | Not built. Doctrine settled 2026-11-01; waits only on an access-engine policy action |
|
||||
| S3 hub write-back | **Accepted, as a pull** (founder decision 2026-11-01) | Outcome read implemented locally and tested (WP-0010-T04); enablement waits on STATE-WP-0101 (T05) |
|
||||
|
||||
The founder's direction (one surface, weight proportional to risk, a clear split with approval-engine) fits
|
||||
`INTENT.md` directly: one memo model at every level is the thesis, and "never own approval state" makes the
|
||||
|
|
@ -136,7 +152,11 @@ confirming with net-kingdom.
|
|||
- `view_hash` and disposition as today, and no approval-engine object;
|
||||
- it resolves the hub decision it serves (S3).
|
||||
|
||||
This is what `INTENT.md` calls the lower depth levels (L1/L2). It is not built because three things must exist first:
|
||||
This is what `INTENT.md` calls the lower depth levels (L1/L2). **Update 2026-11-01:** items 1 and 3 below are
|
||||
answered by GH-DEC-2026-026 (accepted as drafted). A C1 confirm is never authority for an act outside a standing
|
||||
mandate (such reliance makes the decision C2), so it needs no approval object and does not bind for execution.
|
||||
Item 2, access-engine policy work, is the only remaining blocker. S4 is not being implemented now.
|
||||
It was not built because three things had to exist first:
|
||||
|
||||
1. gate-house doctrine for the C0–C3 classes and the rule that anything executed on the strength of a decision is
|
||||
C2 (review S5). informed-decision must not decide which class a decision has;
|
||||
|
|
@ -146,7 +166,36 @@ This is what `INTENT.md` calls the lower depth levels (L1/L2). It is not built b
|
|||
|
||||
## S3: hub write-back
|
||||
|
||||
**Accepted.** It exists already: an intake envelope's `serves: {hub_decision_id, resolves: on-answer}` queues a
|
||||
### Outcome read (founder decision 2026-11-01, implemented locally)
|
||||
|
||||
The hub pulls. `GET /v1/memos/{memo_id}/outcome` on the private workload listener:
|
||||
|
||||
- **Who:** TokenReview with audience `informed-decision-intake` (the worker's existing token), and a submitter row
|
||||
with `outcome_read: true`. Only State Hub's row (`system:serviceaccount:state-hub:state-hub`) is meant to carry
|
||||
it; the flag cannot be combined with `present_existing`. Not yet set in shipped deployment data (T05).
|
||||
- **What:** only memos that submitter posed (anything else is 404 `memo_not_found`, including other submitters'
|
||||
memos), serving a hub decision with `resolves: on-answer` (else 409 `memo_does_not_resolve`).
|
||||
- **When:** the first terminal human disposition (accept or decline) on any revision is the answer. An accept is
|
||||
reported only once its approval-engine entry is confirmed; before that the answer is 202 `pending`, and a lost
|
||||
entry response is 409 `approval_entry_unresolved` (needs the existing recovery, never a guess).
|
||||
- **Record** (`informed-decision.decision-outcome` v1, `status: decided`): `hub_decision_id`, `memo_id`,
|
||||
`memo_revision`, `verb`, `subject {issuer, sub}`, `assurance {aal, auth_time}` (from the policy observation
|
||||
recorded for that act; 409 `assurance_unrecorded` if absent), `decided_at`, `view_hash`, `presentation_id`,
|
||||
`approval_id`, and for an accept `approval_entry {approved_at}`; `audit_ref` once audit-core has receipted the
|
||||
disposition. Bounded at 8 KiB.
|
||||
- **Deliberate differences from state-hub §5.2**, for state-hub to confirm:
|
||||
- `approval_entry_id`: approval-engine entries have no id. The record carries `approval_entry.approved_at`; with
|
||||
`approval_id` and `subject.sub` it is approval-engine's correlation triple.
|
||||
- `option`: not emitted until S4 adds options.
|
||||
- `display_name` and `note`: not emitted (value-free; the memo's principal display name is the review group's,
|
||||
not the human's). The hub fills `decided_by` from `subject`.
|
||||
- **Read-only:** no budget spent, no row written, no outbound call.
|
||||
|
||||
`INFD_HUB_WRITEBACK` (the push below) stays off under decision 2 and is retired after the pull is live (T06).
|
||||
|
||||
### Push write-back (INFD-WP-0005, superseded)
|
||||
|
||||
It exists already: an intake envelope's `serves: {hub_decision_id, resolves: on-answer}` queues a
|
||||
resolution atomically with a terminal human disposition (accept or decline). The worker posts
|
||||
`/decisions/{id}/resolve` with `decided_by: "<reviewer> via informed-decision"` and a rationale naming the memo,
|
||||
the version, the approval, the binding digest, the `view_hash` and the disposition. Remaining work:
|
||||
|
|
@ -159,8 +208,10 @@ the version, the approval, the binding digest, the `view_hash` and the dispositi
|
|||
|
||||
## Open questions
|
||||
|
||||
1. **Founder:** are C0–C3 and "executed means C2" the right cut? This gates S4.
|
||||
2. **Founder:** enable hub write-back in production now (S3)?
|
||||
1. ~~**Founder:** are C0–C3 and "executed means C2" the right cut? This gates S4.~~ Answered 2026-11-01:
|
||||
GH-DEC-2026-026 accepted as drafted.
|
||||
2. ~~**Founder:** enable hub write-back in production now (S3)?~~ Answered 2026-11-01: hub pulls; switch on once
|
||||
STATE-WP-0101 lands.
|
||||
3. **access-engine owner:** policy version admitting present-existing requesters (blocker 1). Should aal2 be required
|
||||
per memo class in policy, or by approval-engine's `required_assurance` once APPROVAL-WP-0007-T01 enforces it?
|
||||
4. **key-cape owner:** add `approval:read` to the sitting requester client (blocker 2), or register a separate
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
227
tests/test_decision_outcome.py
Normal file
227
tests/test_decision_outcome.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Decision outcome read for State Hub's own memos (INFD-WP-0010-T04).
|
||||
|
||||
Founder decision 2026-11-01: the hub's STATE-WP-0096 dispatch worker pulls outcomes;
|
||||
informed-decision holds no hub credential. Synthetic fixtures only.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.disposition import Actor, ActorKind, Verb
|
||||
from informed_decision.intake_service import AUDIENCE, IntakeService, TokenReview
|
||||
from informed_decision.policy import build_request
|
||||
from informed_decision.provenance import Claim, Route
|
||||
from informed_decision.store import Store
|
||||
from informed_decision.workload import PrivateWorkloadApp
|
||||
from test_intake import envelope
|
||||
from test_intake_service import request
|
||||
|
||||
HUB = 'system:serviceaccount:state-hub:state-hub'
|
||||
OTHER = 'system:serviceaccount:other:poser'
|
||||
ISSUER = 'https://kc.coulomb.social'
|
||||
APPROVAL = '12345678-1234-1234-1234-123456789abc'
|
||||
DECISION = '62aa95e4-3ccc-4867-8806-5e3db005b483'
|
||||
HUMAN = Actor('keycape-human', ActorKind.PERSON)
|
||||
AT = 1_793_000_000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path):
|
||||
private = tmp_path / 'private'
|
||||
private.mkdir(mode=0o700)
|
||||
store = Store(private / 'review.sqlite')
|
||||
submitters, groups = tmp_path / 'submitters.json', tmp_path / 'groups.json'
|
||||
rows = {HUB: {'submitter_id': 'state-hub', 'memo_classes': ['credential-lane'],
|
||||
'review_groups': ['net-kingdom-admins'], 'daily_limit': 5, 'outcome_read': True},
|
||||
OTHER: {'submitter_id': 'other', 'memo_classes': ['credential-lane'],
|
||||
'review_groups': ['net-kingdom-admins'], 'daily_limit': 5}}
|
||||
submitters.write_text(json.dumps(rows))
|
||||
groups.write_text(json.dumps({'net-kingdom-admins': {'subject': HUMAN.sub, 'display_name': 'Reviewer'}}))
|
||||
caller = {'username': HUB}
|
||||
|
||||
def token_review(body):
|
||||
assert body['spec']['audiences'] == [AUDIENCE]
|
||||
return {'status': {'authenticated': True, 'audiences': [AUDIENCE],
|
||||
'user': {'username': caller['username'], 'uid': 'workload-uid'}}}
|
||||
|
||||
def create(body):
|
||||
return {'id': APPROVAL, 'status': 'requested',
|
||||
'binding': {**body['binding'], 'human_control': True, 'digest': 'sha256:' + 'a' * 64}}
|
||||
service = IntakeService(store, TokenReview(token_review), create, submitters_path=submitters,
|
||||
groups_path=groups, issuer=ISSUER)
|
||||
return SimpleNamespace(service=service, store=store, caller=caller, submitters=submitters)
|
||||
|
||||
|
||||
def get(app, memo_id, method='GET', token='projected-token'):
|
||||
environ = {'PATH_INFO': f'/v1/memos/{memo_id}/outcome', 'REQUEST_METHOD': method,
|
||||
'HTTP_AUTHORIZATION': 'Bearer ' + token, 'wsgi.input': io.BytesIO(b'')}
|
||||
response = []
|
||||
body = b''.join(app(environ, lambda status, headers: response.append((status, headers))))
|
||||
return int(response[0][0].split()[0]), json.loads(body)
|
||||
|
||||
|
||||
def pose(s, resolves='on-answer', memo_id='hub-memo-1'):
|
||||
env = envelope()
|
||||
env['serves'] = {'hub_decision_id': DECISION, 'resolves': resolves}
|
||||
env['memo']['id'] = env['idempotency_key'] = memo_id
|
||||
status, receipt = request(s.service, env)
|
||||
assert status == 201
|
||||
return receipt['memo_id']
|
||||
|
||||
|
||||
def answer(s, memo_id, verb, *, with_policy=True, note=None):
|
||||
store = s.store
|
||||
p = store.present(memo_id, principal_sub=HUMAN.sub, tenant=Claim('tenant:platform', Route.REGISTRATION),
|
||||
principal_type=Claim('human', Route.AUTHENTICATION))
|
||||
store.acknowledge(p.id, HUMAN, store.memo(memo_id).required_ack_ids)
|
||||
policy_id = None
|
||||
if with_policy:
|
||||
memo = store.memo(memo_id)
|
||||
session = SimpleNamespace(subject=HUMAN.sub, tenant=Claim('tenant:platform', Route.REGISTRATION),
|
||||
principal_type=Claim('human', Route.AUTHENTICATION), roles=(), groups=(),
|
||||
assurance={'level': 'aal2', 'mfa': True, 'source': 'key-cape',
|
||||
'methods': ['pwd', 'otp'], 'at': AT})
|
||||
req = build_request(session, memo, verb.value, 'v13', intake_context=store.intake_context(memo))
|
||||
policy_id = store.observe_policy(SimpleNamespace(request_json=json.dumps(req), decision_json='{}',
|
||||
outcome='allow', usable_until=time.time() + 60, response_sha256='sha256:' + '0' * 64))
|
||||
reasons = ('wrong_scope',) if verb is Verb.DECLINE else ()
|
||||
d = store.record_disposition(p.id, HUMAN, verb, operation_id=f'{verb.value}-once', reasons=reasons,
|
||||
note=note, policy_id=policy_id)
|
||||
return p, d
|
||||
|
||||
|
||||
def test_pending_before_any_answer(setup):
|
||||
memo_id = pose(setup)
|
||||
assert get(setup.service, memo_id) == (202, {'kind': 'informed-decision.decision-outcome', 'version': 1,
|
||||
'memo_id': memo_id, 'status': 'pending'})
|
||||
|
||||
|
||||
def test_decline_is_an_answer_and_value_free(setup):
|
||||
memo_id = pose(setup)
|
||||
p, d = answer(setup, memo_id, Verb.DECLINE, note='packet secret-looking text stays here')
|
||||
status, record = get(setup.service, memo_id)
|
||||
assert status == 200
|
||||
assert record == {
|
||||
'kind': 'informed-decision.decision-outcome', 'version': 1, 'status': 'decided',
|
||||
'hub_decision_id': DECISION, 'memo_id': memo_id, 'memo_revision': 1, 'verb': 'decline',
|
||||
'subject': {'issuer': ISSUER, 'sub': HUMAN.sub},
|
||||
'assurance': {'aal': 'aal2', 'auth_time': '2026-10-26T07:33:20Z'},
|
||||
'decided_at': d.at, 'view_hash': p.view_hash, 'presentation_id': p.id, 'approval_id': APPROVAL}
|
||||
raw = json.dumps(record)
|
||||
for forbidden in ('note', 'reasons', 'wrong_scope', 'secret-looking', 'Reviewer', 'brief', 'packet', 'token'):
|
||||
assert forbidden not in raw
|
||||
assert len(raw.encode()) <= 8 * 1024
|
||||
|
||||
|
||||
def test_accept_waits_for_confirmed_entry(setup):
|
||||
memo_id = pose(setup)
|
||||
_, d = answer(setup, memo_id, Verb.ACCEPT)
|
||||
assert get(setup.service, memo_id)[1]['status'] == 'pending' # prepared
|
||||
attempt = setup.store.begin_submission(d.id)
|
||||
assert get(setup.service, memo_id)[1]['status'] == 'pending' # in flight
|
||||
from informed_decision.approval_client import EntryResult
|
||||
setup.store.finish_submission(d.id, attempt, EntryResult(APPROVAL, HUMAN.sub, '2026-11-01T10:00:00Z', 'approved'))
|
||||
status, record = get(setup.service, memo_id)
|
||||
assert status == 200 and record['verb'] == 'accept'
|
||||
assert record['approval_id'] == APPROVAL
|
||||
assert record['approval_entry'] == {'approved_at': '2026-11-01T10:00:00Z'}
|
||||
|
||||
|
||||
def test_unresolved_entry_is_not_an_answer(setup):
|
||||
memo_id = pose(setup)
|
||||
_, d = answer(setup, memo_id, Verb.ACCEPT)
|
||||
setup.store.finish_submission(d.id, setup.store.begin_submission(d.id)) # lost response
|
||||
assert get(setup.service, memo_id) == (409, {'error': 'approval_entry_unresolved'})
|
||||
|
||||
|
||||
def test_audit_reference_when_delivered(setup):
|
||||
memo_id = pose(setup)
|
||||
_, d = answer(setup, memo_id, Verb.DECLINE)
|
||||
with setup.store._connection() as db:
|
||||
event = db.execute("SELECT id FROM evidence WHERE json_extract(envelope,'$.data.disposition_id')=?",
|
||||
(d.id,)).fetchone()[0]
|
||||
db.execute("UPDATE outbox SET state='delivered',receiver_reference='audit-rec-1' WHERE id=?", (event,))
|
||||
assert get(setup.service, memo_id)[1]['audit_ref'] == 'audit-rec-1'
|
||||
|
||||
|
||||
def test_non_terminal_verbs_do_not_answer(setup):
|
||||
memo_id = pose(setup)
|
||||
p = setup.store.present(memo_id, principal_sub=HUMAN.sub, tenant=Claim('tenant:platform', Route.REGISTRATION),
|
||||
principal_type=Claim('human', Route.AUTHENTICATION))
|
||||
setup.store.record_disposition(p.id, HUMAN, Verb.DISCUSS, operation_id='talk', note='question')
|
||||
assert get(setup.service, memo_id)[1]['status'] == 'pending'
|
||||
|
||||
|
||||
def test_missing_assurance_refuses(setup):
|
||||
memo_id = pose(setup)
|
||||
answer(setup, memo_id, Verb.DECLINE, with_policy=False)
|
||||
assert get(setup.service, memo_id) == (409, {'error': 'assurance_unrecorded'})
|
||||
|
||||
|
||||
def test_only_own_memos_and_no_existence_oracle(setup):
|
||||
memo_id = pose(setup)
|
||||
assert get(setup.service, 'never-posed') == (404, {'error': 'memo_not_found'})
|
||||
# Another submitter's memo looks exactly like an unknown one to the hub.
|
||||
setup.caller['username'] = OTHER
|
||||
other_id = pose(setup, memo_id='other-memo-1')
|
||||
setup.caller['username'] = HUB
|
||||
assert get(setup.service, other_id) == (404, {'error': 'memo_not_found'})
|
||||
assert get(setup.service, memo_id)[0] == 202
|
||||
|
||||
|
||||
def test_only_admitted_reader(setup):
|
||||
setup.caller['username'] = OTHER
|
||||
memo_id = pose(setup, memo_id='other-memo-1')
|
||||
assert get(setup.service, memo_id) == (403, {'error': 'outcome_read_not_admitted'})
|
||||
setup.caller['username'] = 'system:serviceaccount:x:unknown'
|
||||
assert get(setup.service, memo_id) == (403, {'error': 'submitter_unknown'})
|
||||
|
||||
|
||||
def test_memo_that_does_not_resolve(setup):
|
||||
memo_id = pose(setup, resolves='never')
|
||||
assert get(setup.service, memo_id) == (409, {'error': 'memo_does_not_resolve'})
|
||||
|
||||
|
||||
def test_method_token_and_path_guards(setup):
|
||||
memo_id = pose(setup)
|
||||
assert get(setup.service, memo_id, method='POST') == (405, {'error': 'method_not_allowed'})
|
||||
assert get(setup.service, memo_id, token='')[0] == 401
|
||||
assert get(setup.service, 'Bad_ID')[0] == 404
|
||||
app = PrivateWorkloadApp(intake=setup.service)
|
||||
assert get(app, memo_id)[0] == 202
|
||||
|
||||
|
||||
def test_no_issuer_means_unavailable(setup):
|
||||
memo_id = pose(setup)
|
||||
setup.service.issuer = None
|
||||
assert get(setup.service, memo_id) == (503, {'error': 'outcome_unavailable'})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('row', [
|
||||
{'outcome_read': False},
|
||||
{'outcome_read': 'yes'},
|
||||
{'outcome_read': True, 'present_existing': {'approval_actor': 'service:nk-bind-executor',
|
||||
'actions': ['bounded-execution']}},
|
||||
])
|
||||
def test_registry_flag_is_exact(setup, row):
|
||||
data = json.loads(setup.submitters.read_text())
|
||||
data[HUB].update(row)
|
||||
setup.submitters.write_text(json.dumps(data))
|
||||
assert get(setup.service, 'hub-memo-1') == (503, {'error': 'intake_configuration_unavailable'})
|
||||
|
||||
|
||||
def test_read_is_side_effect_free(setup):
|
||||
memo_id = pose(setup)
|
||||
answer(setup, memo_id, Verb.DECLINE)
|
||||
with setup.store._connection() as db:
|
||||
before = [db.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0]
|
||||
for t in ('intake_ledger', 'intake_deliveries', 'evidence', 'outbox', 'policy_observations')]
|
||||
for _ in range(3):
|
||||
assert get(setup.service, memo_id)[0] == 200
|
||||
with setup.store._connection() as db:
|
||||
after = [db.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0]
|
||||
for t in ('intake_ledger', 'intake_deliveries', 'evidence', 'outbox', 'policy_observations')]
|
||||
assert before == after
|
||||
|
|
@ -4,17 +4,19 @@ type: workplan
|
|||
title: "Present existing approvals, proportionate C1 confirm and hub write-back"
|
||||
domain: infotech
|
||||
repo: informed-decision
|
||||
status: proposed
|
||||
status: active
|
||||
owner: claude-code
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-31"
|
||||
updated: "2026-10-31"
|
||||
updated: "2026-11-01"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
related:
|
||||
- NK-WP-0056
|
||||
- INFD-WP-0005
|
||||
- APPROVAL-WP-0007
|
||||
- STATE-WP-0096
|
||||
- STATE-WP-0101
|
||||
state_hub_workstream_id: "d1363acf-35c2-56ce-be1b-ecebd15b5253"
|
||||
---
|
||||
|
||||
|
|
@ -26,6 +28,30 @@ surface. It should be light for simple decisions and complete for risky ones, an
|
|||
should be explicit: approval-engine holds the object, created by the actor who uses it, and informed-decision
|
||||
presents it and records the human's act.
|
||||
|
||||
## Founder decisions, 2026-11-01
|
||||
|
||||
Relayed by the net-kingdom coordinator on 2026-11-01 (quoted as relayed):
|
||||
|
||||
1. **gate-house GH-DEC-2026-026 accepted as drafted.** Decision classes C0–C3; a decision is at least C2 when
|
||||
anyone relies on it as authority for an act its standing mandate doesn't cover; C3 reserved; the actor that
|
||||
files a C2 approval may never approve it; agents never approve at C1+.
|
||||
*Unblocks:* S4's doctrine dependency (T03) and the founder's answer on class cuts. Consequences here:
|
||||
informed-decision still does not decide a class (the poser declares it, gate-house owns the doctrine); a C1
|
||||
confirm is never authority for an unmandated act, because such reliance makes the decision C2 by definition,
|
||||
so a C1 confirm needs no approval object; only human dispositions bind (already enforced: agent and service
|
||||
actors cannot resolve). S1 already satisfies "filer never approves": the executor files the approval as
|
||||
`service:nk-bind-executor` and only a human enters it.
|
||||
2. **State Hub write-back is a pull.** State Hub's existing STATE-WP-0096 dispatch worker fetches decision
|
||||
outcomes from informed-decision; informed-decision gets no hub credential. state-hub needs an outcome read
|
||||
returning `informed-decision.decision-outcome` v1 (fields: state-hub
|
||||
`docs/decision-services-alignment-answer.md` §5.2). informed-decision's hub write-back is switched on once
|
||||
STATE-WP-0101 lands.
|
||||
*Unblocks / re-scopes:* S3 (T04 re-scoped to the outcome read; T05 enablement; T06 retirement of the push
|
||||
path). `INFD_HUB_WRITEBACK` (push) stays off: it has no credential to use under this decision.
|
||||
3. access-engine (review policy for requester `service:nk-bind-executor`) and key-cape (`approval:read` for
|
||||
`informed-decision-sitting-requester`) are being asked by separate workers. Affects T02 and T03; nothing
|
||||
changes here until they answer.
|
||||
|
||||
## S1: present_existing intake profile
|
||||
|
||||
```task
|
||||
|
|
@ -79,26 +105,65 @@ state_hub_task_id: "0b3750f5-47c3-5874-bebb-7ebab385a3df"
|
|||
|
||||
A lighter memo profile with one question, one packet item, one required highlight and options. It needs a fresh
|
||||
aal1 session and creates no approval object. It records view_hash and disposition and resolves the served hub
|
||||
decision. Waits on gate-house doctrine for decision classes C0–C3 (review S5), an access-engine policy action for
|
||||
an approval-less confirm, and the founder's answer on class cuts.
|
||||
decision (through T04's outcome read).
|
||||
|
||||
**Blockers after the 2026-11-01 founder decisions:** the gate-house doctrine (GH-DEC-2026-026) and the class cuts
|
||||
are settled. The only remaining external blocker is **access-engine policy work**: a review-policy action for a
|
||||
confirm without an approval object (today review refuses `missing_act_binding`), at aal1 for C1. The C1 profile's
|
||||
own design (how the poser declares the class at intake, and refusing C2+ on this profile) is in-repo work for when
|
||||
the task starts. Not started by decision: implement after access-engine answers.
|
||||
|
||||
**Done when:** a C1 decision is confirmed in the browser and resolves its hub decision with evidence. No approval
|
||||
object is created.
|
||||
|
||||
## S3: hub write-back enablement and structured links
|
||||
## S3: decision outcome read for State Hub's pull (re-scoped 2026-11-01)
|
||||
|
||||
```task
|
||||
id: INFD-WP-0010-T04
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "8c649193-f274-5463-9897-7010a5bb4a9d"
|
||||
```
|
||||
|
||||
The write-back exists (INFD-WP-0005, `INFD_HUB_WRITEBACK`). Remaining work:
|
||||
Re-scoped by founder decision 2 (2026-11-01): the hub fetches, so informed-decision provides a read of the outcome
|
||||
of its own memos to the hub's existing intake identity, instead of pushing.
|
||||
|
||||
- the founder's decision to enable it in production;
|
||||
- with state-hub: structured decision links (`memo_id`, `approval_id`, `view_hash`, `evidence_ref`) that replace
|
||||
the rationale-text idempotency check.
|
||||
`GET /v1/memos/{memo_id}/outcome` on the private workload listener (same TokenReview, audience
|
||||
`informed-decision-intake`). Admitted only for a submitter row with `outcome_read: true` (reviewed deployment
|
||||
data; never combined with `present_existing`), and only for memos that submitter posed (others: 404, no existence
|
||||
oracle). The memo must serve a hub decision with `resolves: on-answer`. Responses: 202 `pending` (no terminal
|
||||
human answer yet, or an accept whose approval entry is not yet confirmed); 200 the
|
||||
`informed-decision.decision-outcome` v1 record; 409 `approval_entry_unresolved`, `assurance_unrecorded`,
|
||||
`memo_does_not_resolve`. Read-only, spends no budget, writes nothing.
|
||||
|
||||
**Done when:** a served hub decision is resolved by a human disposition in production, and its link fields are
|
||||
populated.
|
||||
**Done 2026-11-01 (local):** `informed_decision/outcome.py`, routing in `intake_service.py` / `workload.py`,
|
||||
issuer wiring in `requester.py`; tests in `tests/test_decision_outcome.py`. Not deployed.
|
||||
|
||||
## S3 enablement: hub write-back on via the pull
|
||||
|
||||
```task
|
||||
id: INFD-WP-0010-T05
|
||||
status: wait
|
||||
priority: medium
|
||||
depends_on: [INFD-WP-0010-T04]
|
||||
```
|
||||
|
||||
Waits on state-hub STATE-WP-0101 (decision columns, `resolve-evidence` route, worker pull). Then here: set
|
||||
`outcome_read: true` on the `system:serviceaccount:state-hub:state-hub` row of `intake-submitters.json` and ship a
|
||||
release (founder runs). The worker's egress to the private listener (8081) already exists.
|
||||
|
||||
**Done when:** a served C1/C2 hub decision is resolved in production from an outcome record this service returned.
|
||||
|
||||
## S3 cleanup: retire the push write-back
|
||||
|
||||
```task
|
||||
id: INFD-WP-0010-T06
|
||||
status: wait
|
||||
priority: low
|
||||
depends_on: [INFD-WP-0010-T05]
|
||||
```
|
||||
|
||||
Once T05 is live, remove the push path (`hub_writeback.py`, the `hub-resolution` delivery kind and
|
||||
`INFD_HUB_WRITEBACK`), keeping already-queued rows readable. It must never be switched on under decision 2.
|
||||
|
||||
**Done when:** no code path in informed-decision can write to State Hub's decision routes.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue