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
227 lines
10 KiB
Python
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
|