informed-decision/tests/test_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

227 lines
10 KiB
Python

"""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