informed-decision/tests/test_lifecycle_review.py
tegwick c084600082 Integrate verified State Hub dispatcher into lifecycle release
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
2026-10-04 22:44:01 +02:00

357 lines
18 KiB
Python

import base64
from dataclasses import replace
import json
import time
import pytest
from informed_decision.disposition import Actor, ActorKind, Verb, DispositionRefused
from informed_decision.lifecycle import Attribution, LifecycleRepository
from informed_decision.lifecycle_service import Caller, LifecycleService, LifecycleError
from informed_decision.provenance import Claim, Route
from informed_decision.records import memo_from
from informed_decision.store import Conflict, Store
from test_intake import envelope
from test_intake_service import setup, request
from test_policy_client import policy
@pytest.fixture
def handoff(setup):
intake, store, calls, configuration = setup
env = envelope()
memo = memo_from(env['memo'])
app = LifecycleService(LifecycleRepository(store), policy())
actor = Caller(Attribution('tenant:platform', Actor('author-agent', ActorKind.AGENT)), time.time()+3600, '{}')
documents = [{'digest': memo.packet[0].hash, 'media_type': 'text/markdown',
'content_base64': base64.b64encode(env['packet'][0]['content'].encode()).decode()}]
app.register(actor, memo, key='register', documents=documents)
app.publish(actor, memo.id, 1, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish')
options = {'submitter': 'state-hub', 'memo_class': env['memo_class'], 'review_group': env['review_group'],
'approval_intent': env['approval_intent']}
return app, actor, memo, options, intake, calls
def prepare(handoff):
app, actor, memo, options, intake, calls = handoff
return app.request_review(actor, memo.id, 1, options=options, key='request-review')
def present(store, review_id):
return store.present(review_id, principal_sub='keycape-human',
tenant=Claim('tenant:platform', Route.REGISTRATION),
principal_type=Claim('human', Route.AUTHENTICATION))
def test_durable_handoff_reuses_intake_and_exposes_revision_outcomes(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
app.repository.store = Store(app.repository.store.path)
assert prepare(handoff) == receipt
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'not_submitted'
assert request(intake, receipt['envelope'])[0] == 201
assert request(intake, receipt['envelope'])[0] == 200
assert len(calls) == 1
store = app.repository.store
review = store.memo(receipt['review_memo_id'])
assert review.brief == memo.brief and review.packet == memo.packet
assert store.memo(memo.id).approval_id is None
p = present(store, review.id)
human = Actor('keycape-human', ActorKind.PERSON)
store.acknowledge(p.id, human, review.required_ack_ids)
d = store.record_disposition(p.id, human, Verb.DECLINE, operation_id='answer', reasons=('wrong_scope',))
result = app.outcomes(actor, memo.id, 1)
assert result['review']['path'] == '/review?memo_id=' + review.id
assert result['dispositions'][0]['disposition']['id'] == d.id
assert result['dispositions'][0]['submission_state'] is None
app.revise(actor, memo.next_version(brief='Refined'), expected_version=1, summary='Update', key='revise')
assert app.outcomes(actor, memo.id, 2) == {'review': None, 'dispositions': []}
assert app.outcomes(actor, memo.id, 1)['dispositions'] == result['dispositions']
def test_altered_handoff_and_wrong_submitter_fail_before_create(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
changed = json.loads(json.dumps(receipt['envelope']))
changed['memo']['brief'] += ' Altered'
assert request(intake, changed) == (409, {'error': 'review_handoff_conflict'})
data = json.loads(intake.submitters_path.read_text())
for row in data.values():
row['submitter_id'] = 'another'
intake.submitters_path.write_text(json.dumps(data))
assert request(intake, receipt['envelope'])[0] == 409
assert not calls
def test_revision_invalidates_presented_review_and_prepared_accept(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
store = app.repository.store
p = present(store, receipt['review_memo_id'])
human = Actor('keycape-human', ActorKind.PERSON)
store.acknowledge(p.id, human, memo.required_ack_ids)
d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept')
app.revise(actor, memo.next_version(brief='Revised'), expected_version=1, summary='Update', key='revise')
with pytest.raises(Conflict):
store.begin_submission(d.id)
with pytest.raises(Conflict):
store.record_disposition(p.id, human, Verb.DECLINE, operation_id='late-answer', reasons=('wrong_scope',))
with pytest.raises(Conflict):
present(store, receipt['review_memo_id'])
assert request(intake, receipt['envelope'])[0] == 409
def test_unresolved_entry_freezes_source_and_snapshot_import_is_refused(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
store = app.repository.store
review = store.memo(receipt['review_memo_id'])
with pytest.raises(Conflict):
store.save_memo(review.next_version(brief='Bypass'))
with pytest.raises(Conflict):
store.save_memo(review)
p = present(store, review.id)
human = Actor('keycape-human', ActorKind.PERSON)
store.acknowledge(p.id, human, memo.required_ack_ids)
d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept')
attempt = store.begin_submission(d.id)
for unresolved in (False, True):
if unresolved:
store.finish_submission(d.id, attempt)
with pytest.raises(Conflict):
app.revise(actor, memo.next_version(brief='Update'), expected_version=1, summary='Update', key='revise')
assert store.memo(memo.id).version == 1
def test_denial_replay_conflict_and_agent_binding_guard(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
with pytest.raises(Conflict):
app.request_review(actor, memo.id, 1, options={**options, 'review_group': 'other'}, key='request-review')
with pytest.raises(Conflict):
app.request_review(actor, memo.id, 1, options=options, key='duplicate')
app.policy.transport.change = lambda data: data.update(effect='deny')
with pytest.raises(LifecycleError):
prepare(handoff)
with pytest.raises(LifecycleError):
app.outcomes(actor, memo.id, 1)
assert request(intake, receipt['envelope'])[0] == 201
p = present(app.repository.store, receipt['review_memo_id'])
with pytest.raises(DispositionRefused):
app.repository.store.record_disposition(p.id, Actor('keycape-human', ActorKind.AGENT), Verb.ACCEPT,
operation_id='agent-accept')
def test_create_reply_loss_does_not_retry_and_source_change_before_load_refuses(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
creator = intake.creator
def raced(body):
result = creator(body)
app.revise(actor, memo.next_version(brief='Changed during create'), expected_version=1, summary='Update', key='revise')
return result
intake.creator = raced
assert request(intake, receipt['envelope'])[0] == 409
assert request(intake, receipt['envelope'])[0] == 409
assert len(calls) == 1
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'created'
with app.repository.store._connection() as db:
assert not db.execute('SELECT 1 FROM memos WHERE id=?', (receipt['review_memo_id'],)).fetchone()
from test_approval_component import component, signing_key
from test_durable_component import receiver
@pytest.mark.parametrize("via_dispatch", [False, True])
def test_actual_engine_human_entry_and_independent_audit_link_to_source_revision(handoff, component, receiver, tmp_path, via_dispatch):
import jwt
from informed_decision.intake_approval import IntakeApprovalClient
from informed_decision.approval_http import ApprovalHTTPClient
from informed_decision.review import ReviewController
from informed_decision.runtime import AuditPump
app, actor, memo, options, intake, calls = handoff
if via_dispatch:
import os, sys
source = os.environ.get('INFD_STATE_HUB_SOURCE')
if not source:
pytest.skip('State Hub source required for private dispatcher integration')
sys.path.insert(0, source)
from api.services.memo_dispatch import Dispatcher
options = {**options, 'memo_class': 'decision-record'}
grants = json.loads(intake.submitters_path.read_text())
for row in grants.values():
row['memo_classes'] = ['decision-record']
intake.submitters_path.write_text(json.dumps(grants))
client, engine, transport, human, signing = component
groups = json.loads(intake.groups_path.read_text())
groups[options['review_group']]['subject'] = human.subject
intake.groups_path.write_text(json.dumps(groups))
claims = jwt.decode(human.access_token, options={'verify_signature': False})
claims.update(sub='informed-decision', principal_type='service', scope='approval:create')
token = jwt.encode(claims, signing, algorithm='RS256', headers={'kid': 'key-1'})
intake.creator = IntakeApprovalClient('https://approval.test', lambda: token, transport=transport)
app.revise(actor, memo.next_version(brief='Refined for human acceptance'), expected_version=1,
summary='Responded to research', key='revise')
app.publish(actor, memo.id, 2, audience={'subjects': [human.subject], 'groups': []}, key='publish-second')
receipt = app.request_review(actor, memo.id, 2, options=options, key='review-second')
if via_dispatch:
attempts = []
now = [time.time()]
def send(method, url, body, headers):
assert headers['Authorization'] == 'Bearer projected-token'
result = request(intake, body)
attempts.append(result[0])
if len(attempts) == 1:
raise OSError('lost reply after native creation')
return result
worker = Dispatcher(tmp_path / 'private-dispatch', send=send,
read_token=lambda: 'projected-token', clock=lambda: now[0])
assert worker.enqueue(receipt['envelope']).status == 'pending'
assert not attempts
assert worker.once().status == 'pending'
now[0] += 10
worker = Dispatcher(tmp_path / 'private-dispatch', send=send,
read_token=lambda: 'projected-token', clock=lambda: now[0])
forwarded = worker.once()
assert forwarded.status == 'delivered' and attempts == [201, 200]
loaded = forwarded.receipt
assert worker.once() is None
else:
status, loaded = request(intake, receipt['envelope'])
assert status == 201, loaded
controller = ReviewController(app.repository.store, policy(),
lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport), intake_attributes=True)
page = controller.open(human, receipt['review_memo_id'])
context = controller.policy.transport.calls[-1][3]['context']
assert context['intake_submitter'] == 'state-hub'
assert context['approval_requester'] == 'informed-decision' and context['approval_human_control'] is True
controller.acknowledge(human, page.presentation.id, memo.required_ack_ids)
controller.act(human, page.presentation.id, Verb.ACCEPT, operation_id='human-accept')
result = app.outcomes(actor, memo.id, 2)
assert result['dispositions'][0]['submission_state'] == 'confirmed'
from informed_decision.web import App, SESSION_COOKIE
from test_browser_auth import call
class Login:
def session(self, sid):
return human if sid == 'fixture' else None
browser = App(Login(), controller, lifecycle=app)
page_response = call(browser, '/decisions/' + memo.id, query='version=2', cookie=SESSION_COOKIE + '=fixture')
assert page_response['status'] == 200
assert 'Open protected human review' in page_response['body']
assert 'approval entry confirmed' in page_response['body']
native_page = call(browser, '/presentations/' + page.presentation.id, cookie=SESSION_COOKIE + '=fixture')
assert native_page['status'] == 200 and 'decision revision 2' in native_page['body']
assert 'content_base64' not in page_response['body']
assert len(engine.get(loaded['approval_id']).entries) == 1
assert app.outcomes(actor, memo.id, 1)['dispositions'] == []
source = app.repository.store.presentation_content(page.presentation.id)['lifecycle_source']
assert source == {'memo_id': memo.id, 'version': 2, 'request_event_id': receipt['event_id']}
# Both lifecycle commitments and the existing presentation/disposition evidence drain.
pump = AuditPump(app.repository.store, receiver[0], lifecycle_delivery=True)
for _ in range(3):
pump.tick()
assert app.repository.store.lifecycle_counts()['pending'] == 0
assert all(row['state'] == 'delivered' for row in app.repository.store.outbox())
assert not any(url.endswith('/consume') for _, url in transport.calls)
def test_http_review_request_and_outcome_adapter(handoff):
from informed_decision.lifecycle_http import LifecycleAPI
from test_lifecycle_http import call
app, actor, memo, options, intake, calls = handoff
api = LifecycleAPI(app, lambda token: actor)
status, receipt, _ = call(api, f'/v1/decisions/{memo.id}/review-requests', method='POST',
body={'version': 1, 'options': options})
assert status == 200
assert request(intake, receipt['envelope'])[0] == 201
status, outcome, _ = call(api, f'/v1/decisions/{memo.id}/outcomes', query='version=1')
assert status == 200 and outcome['review']['intake_state'] == 'ready'
assert call(api, f'/v1/decisions/{memo.id}/outcomes')[0] == 400
def test_review_request_requires_publication_and_supported_profile(handoff):
app, actor, memo, options, intake, calls = handoff
app.revise(actor, memo.next_version(brief='Unpublished successor'), expected_version=1, summary='Update', key='revise')
with pytest.raises(Conflict, match='publish'):
app.request_review(actor, memo.id, 2, options=options, key='unpublished')
from informed_decision.memo import StepKind
app.revise(actor, app.retrieve(actor, memo.id).next_version(step_kind=StepKind.MITZEICHNUNG),
expected_version=2, summary='Other step', key='revise-step')
app.publish(actor, memo.id, 3, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish-third')
with pytest.raises(ValueError, match='profile'):
app.request_review(actor, memo.id, 3, options=options, key='unsupported')
assert not calls
assert not any(event['kind'] == 'review_requested' for event in app.events(actor, memo.id))
def test_lost_create_reply_remains_unresolved_across_restart(handoff):
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
def lost(body):
calls.append(body)
raise OSError('lost receiver reply')
intake.creator = lost
assert request(intake, receipt['envelope'])[0] == 503
intake.store = Store(intake.store.path)
assert request(intake, receipt['envelope']) == (409, {'error': 'approval_create_unresolved'})
assert len(calls) == 1
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'creating'
def test_schema_four_upgrade_preserves_evidence_and_fences_old_readers(handoff):
app, actor, memo, options, intake, calls = handoff
store = app.repository.store
before = app.events(actor, memo.id)
# Reconstruct the prior schema's exact index/version boundary.
with store._connection() as db:
db.execute('DROP INDEX lifecycle_review_snapshot')
db.execute('DROP INDEX lifecycle_review_revision')
db.execute('PRAGMA user_version=4')
migrated = Store(store.path)
with migrated._connection() as db:
assert db.execute('PRAGMA user_version').fetchone()[0] == 5
assert db.execute("SELECT count(*) FROM sqlite_master WHERE name IN "
"('lifecycle_review_snapshot','lifecycle_review_revision')").fetchone()[0] == 2
assert app.events(actor, memo.id) == before
receipt = prepare(handoff)
assert prepare(handoff) == receipt
def test_attribute_review_checks_live_requester_before_policy(handoff):
from informed_decision.review import ReviewController, ReviewError
from test_policy_client import human
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
stored = app.repository.store.memo(receipt['review_memo_id'])
session = replace(human(), subject=stored.binding.principal.id)
class Engine:
def get_approval(self, approval_id):
return {'binding': {'digest': stored.approval_binding_digest, 'actor': 'wrong-requester', 'human_control': True}}
controller = ReviewController(app.repository.store, policy(), lambda session: Engine(), intake_attributes=True)
with pytest.raises(ReviewError, match='binding_changed'):
controller.open(session, stored.id)
assert controller.policy.transport.calls == []
def test_intake_policy_guard_rechecks_retained_metadata(handoff):
from informed_decision.policy import build_request
from test_policy_client import human
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
store = app.repository.store
review = store.memo(receipt['review_memo_id'])
session = replace(human(), subject=review.binding.principal.id)
request_body = build_request(session, review, 'read', 'v1', intake_context=store.intake_context(review))
observation = policy().check(request_body)
policy_id = store.observe_policy(observation)
with store._transaction() as db:
row = db.execute('SELECT metadata FROM intake_ledger WHERE memo_id=?', (review.id,)).fetchone()
metadata = json.loads(row['metadata']); metadata['review_group'] = 'changed'
db.execute('UPDATE intake_ledger SET metadata=? WHERE memo_id=?', (json.dumps(metadata), review.id))
with pytest.raises(Conflict, match='policy observation'):
store.present(review.id, principal_sub=session.subject, tenant=session.tenant,
principal_type=session.principal_type, policy_id=policy_id)