Add lifecycle browser authoring and durable review handoff retrieval

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-05 00:48:07 +02:00
parent 52d372df0b
commit 9bc581a547
20 changed files with 632 additions and 74 deletions

View file

@ -12,6 +12,7 @@ import threading
import base64
import hashlib
import ssl
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlsplit
from wsgiref.simple_server import WSGIRequestHandler, make_server
@ -69,11 +70,20 @@ def main():
issuer.params={k:v[0] for k,v in parse_qs(urlsplit(url).query).items()}
return url,browser
login.start=start
with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls):
with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls), ExitStack() as components:
controller.policy=policy
pump=AuditPump(controller.store,sink);pump.tick()
lifecycle = None
if os.environ.get('INFD_FLEX_AUTH_SOURCE'):
from informed_decision.lifecycle import LifecycleRepository
from informed_decision.lifecycle_service import LifecycleService
lifecycle_directory = directory/'lifecycle-policy'; lifecycle_directory.mkdir()
lifecycle_policy, _ = components.enter_context(flex_component(
os.environ['INFD_FLEX_AUTH_BINARY'], lifecycle_directory, key,
fixture=Path(os.environ['INFD_FLEX_AUTH_SOURCE'])/'examples/informed-decision-lifecycle'))
lifecycle = LifecycleService(LifecycleRepository(controller.store), lifecycle_policy)
pump=AuditPump(controller.store,sink,lifecycle_delivery=lifecycle is not None);pump.tick()
assert pump.ready()
app=App(login,controller,readiness=pump.ready)
app=App(login,controller,readiness=pump.ready,lifecycle=lifecycle)
def fixture(environ,start_response):
path=environ.get('PATH_INFO')
if path=='/authorize':
@ -104,7 +114,8 @@ def main():
tls_thread=threading.Thread(target=tls_server.serve_forever,daemon=True);tls_thread.start()
with make_server('127.0.0.1',0,fixture,handler_class=Quiet) as server:
(directory/'server.json').write_text(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}',
'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,'fixture_only':True})+'\n')
'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,
'lifecycle':lifecycle is not None,'fixture_only':True})+'\n')
print('Browser fixture ready',flush=True)
try:server.serve_forever()
except KeyboardInterrupt:pass

View file

@ -88,6 +88,59 @@ try {
assert(state.decision_attributable.every(value=>value===0));
checks.push('One actual approval entry, stable on browser reload');
checks.push('Audit receiver holds all commitments; unsigned decision gap retained');
if (fixture.lifecycle) {
await page.goto(fixture.public_origin+'/decisions');
await page.getByRole('link',{name:'Prepare a decision',exact:true}).click();
await page.getByLabel('Question',{exact:true}).fill('Approve the browser-authored storage proposal?');
await page.getByLabel('Context, alternatives and recommendation',{exact:true}).fill('Compare recovery time and costs.');
await page.getByLabel('Subject or scope identifier',{exact:true}).fill('storage');
await page.getByLabel('Subject or scope label',{exact:true}).fill('Storage strategy');
await page.getByLabel('Intended review',{exact:true}).selectOption('approval');
await page.getByRole('button',{name:'Save draft',exact:true}).click();
const decisionPath=new URL(page.url()).pathname;
await page.getByText('Add a supporting document',{exact:true}).click();
await page.getByLabel('Document title',{exact:true}).fill('Recovery proposal');
await page.getByLabel('Document text',{exact:true}).fill('Recover in one hour. Assess cost and operational risk.');
await page.getByLabel('Required acknowledgment (optional)',{exact:true}).fill('The proposal concerns production.');
const documentForm=page.locator('form[action$="/document"]');
await documentForm.getByLabel('What changed',{exact:true}).fill('Added the review document');
await documentForm.getByRole('button',{name:'Save document in new revision',exact:true}).click();
await page.getByText('Revision 2 · Storage strategy',{exact:true}).waitFor();
checks.push('Browser creates an approval draft and immutable supporting document revision');
await page.getByText('Compare with revision 1',{exact:true}).click();
await page.getByRole('heading',{name:'Supporting documents changed',exact:true}).waitFor();
await page.getByRole('heading',{name:'Review acknowledgments changed',exact:true}).waitFor();
await page.getByText('Publish for discussion',{exact:true}).click();
await page.getByLabel('Groups',{exact:true}).fill('net-kingdom-admins');
await page.getByRole('button',{name:'Publish this revision',exact:true}).click();
await page.getByLabel('Responsible party or system',{exact:true}).fill('storage-owner');
await page.getByLabel('Purpose of this approval',{exact:true}).fill('Review the recovery proposal');
await page.getByRole('button',{name:'Prepare review request',exact:true}).click();
const [requestDownload]=await Promise.all([
page.waitForEvent('download'),page.getByRole('link',{name:'Download review request',exact:true}).click()
]);
const envelope=JSON.parse(await fs.readFile(await requestDownload.path(),'utf8'));
assert.equal(envelope.memo.step_kind,'approve');
assert.equal(envelope.memo.highlights[0].required_ack,true);
assert.equal(envelope.packet[0].content,'Recover in one hour. Assess cost and operational risk.');
assert.equal(envelope.memo.binding.principal.id,'pending-human-session');
assert.equal(envelope.idempotency_key,envelope.memo.id);
await page.reload();
assert.equal(await page.getByRole('link',{name:'Download review request',exact:true}).count(),1);
checks.push('Browser publication and durable handoff download use the actual lifecycle PDP');
await page.setViewportSize({width:390,height:844});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
await page.screenshot({path:path.join(directory,'lifecycle-mobile.png'),fullPage:true});
await page.setViewportSize({width:1360,height:1000});
await page.screenshot({path:path.join(directory,'lifecycle-desktop.png'),fullPage:true});
state=await (await fetch(fixture.origin+'/_fixture/status')).json();
assert.equal(state.entries,1);assert.equal(state.entry_posts,1);assert.equal(state.consume_calls,0);
assert(state.outbox_states.every(s=>s==='delivered'));
checks.push('Prepared request creates no approval or human entry; lifecycle audit is delivered');
await page.goto(fixture.public_origin+'/decisions');
assert.equal(await page.locator('a[href="'+decisionPath+'"]').count(),1);
await page.goto(fixture.public_origin+'/review?memo_id='+encodeURIComponent(fixture.memo_id));
}
await fetch(fixture.origin+'/_fixture/refuse-caller');
await page.reload();await page.getByRole('heading',{name:'Review unavailable'}).waitFor();
assert(!(await page.content()).includes('private-brief-sentinel'));

View file

@ -355,3 +355,24 @@ def test_intake_policy_guard_rechecks_retained_metadata(handoff):
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)
def test_retained_handoff_api_is_revision_scoped_and_reauthorizes(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 _: actor)
path = '/v1/decisions/' + memo.id + '/review-requests'
assert call(api, path, QUERY_STRING='version=1')[0] == 404
receipt = prepare(handoff)
assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt)
assert not calls
for query in ('', 'version=0', 'version=1&extra=x', 'version=1&version=1'):
assert call(api, path, QUERY_STRING=query)[0] == 400
app.revise(actor, memo.next_version(brief='Next revision'), expected_version=1, summary='Refined', key='revise')
assert call(api, path, QUERY_STRING='version=2')[0] == 404
# Historical export is evidence, never permission to resubmit a stale request.
assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt)
assert request(intake, receipt['envelope'])[0] == 409
app.policy.transport.change = lambda data: data.update(effect='deny')
assert call(api, path, query='version=1')[:2] == (403, {'error': 'policy_denied'})

View file

@ -90,3 +90,93 @@ def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser):
lifecycle.policy.transport.change = lambda data: data.update(effect="deny")
response = call(app, path, cookie=cookie)
assert response["status"] == 403 and "Compare cost" not in response["body"]
def approval_draft(browser):
response = post(browser, '/decisions/new/prepare', question='Approve the storage proposal?',
brief='Compare recovery time and costs.', scope_id='storage', scope_label='Storage strategy',
review_profile='approval')
assert response['status'] == 303
return dict(response['headers'])['Location']
def test_browser_authors_packet_and_exports_exact_durable_review_handoff(browser):
import json
from informed_decision.lifecycle import LifecycleRepository
from informed_decision.lifecycle_service import LifecycleService
from informed_decision.store import Store
app, lifecycle, session, cookie = browser
path = approval_draft(browser)
fields = dict(version='1', label='Storage proposal', content='Recover in one hour. Cost: 50 EUR.',
highlight='This is a production change.', summary='Added the proposal', key=str(uuid.uuid4()))
assert post(browser, path+'/document', **fields)['status'] == 303
assert post(browser, path+'/document', **fields)['status'] == 303
memo_id = path.split('/')[-1]
assert lifecycle.repository.store.memo(memo_id).version == 2
assert lifecycle.repository.store.memo(memo_id, 1).packet == ()
packet = call(app, path+'/packet/0', cookie=cookie, query='version=2')
assert packet['status'] == 200 and packet['body'] == fields['content']
comparison = call(app, path, cookie=cookie)['body']
assert 'Supporting documents changed' in comparison and 'Review acknowledgments changed' in comparison
assert fields['highlight'] in comparison
assert post(browser, path+'/publish', version='2', subjects='', groups='net-kingdom-admins')['status'] == 303
review = dict(version='2', principal='storage-owner', purpose='Approve the recovery proposal', key=str(uuid.uuid4()))
assert post(browser, path+'/request-review', **review)['status'] == 303
assert post(browser, path+'/request-review', **review)['status'] == 303
page = call(app, path, cookie=cookie)
assert 'Download review request' in page['body'] and 'has not created an approval' in page['body']
exported = call(app, path+'/review-request', cookie=cookie, query='version=2')
assert exported['status'] == 200 and 'attachment;' in dict(exported['headers'])['Content-Disposition']
envelope = json.loads(exported['body'])
assert envelope['packet'][0]['content'] == fields['content']
assert envelope['memo']['highlights'][0]['required_ack'] is True
assert envelope['memo']['binding']['principal']['id'] == 'pending-human-session'
assert envelope['approval_intent']['principal'] == 'storage-owner'
assert envelope['memo_class'] == 'decision-record' and envelope['review_group'] == 'net-kingdom-admins'
reopened = LifecycleService(LifecycleRepository(Store(lifecycle.repository.store.path)), lifecycle.policy)
retained = reopened.review_handoff(Caller.from_human_session(session), memo_id, 2)
assert retained['envelope'] == envelope
with reopened.repository.store._connection() as db:
assert db.execute('select count(*) from intake_ledger').fetchone()[0] == 0
assert db.execute('select count(*) from submissions').fetchone()[0] == 0
assert post(browser, path+'/revise', version='2', question='Approve the revised storage proposal?',
brief='A different proposal.', summary='Changed the proposal')['status'] == 303
current = call(app, path, cookie=cookie)['body']
assert 'Download review request' not in current
assert 'earlier revision' in call(app, path, cookie=cookie, query='version=2')['body']
def test_packet_conflict_and_rejected_content_leave_no_partial_revision(browser):
app, lifecycle, session, cookie = browser
path = approval_draft(browser)
fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document')
assert post(browser, path+'/document', **fields)['status'] == 303
assert post(browser, path+'/document', **{**fields, 'content':'Concurrent replacement'})['status'] == 409
assert post(browser, path+'/document', **{**fields, 'version':'2', 'content':'-----BEGIN OPENSSH PRIVATE KEY-----'})['status'] == 400
assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 2
assert len(lifecycle.repository.store.memo(path.split('/')[-1]).packet) == 1
assert call(app, path+'/review-request', cookie=cookie, query='version=2')['status'] == 404
def test_review_and_document_mutations_require_policy_origin_and_csrf(browser):
app, lifecycle, session, cookie = browser
path = approval_draft(browser)
fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document')
assert post(browser, path+'/document', **fields, csrf='wrong')['status'] == 403
assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review', csrf='wrong')['status'] == 403
lifecycle.policy.transport.change = lambda data: data.update(effect='deny')
assert post(browser, path+'/document', **fields)['status'] == 403
assert call(app, path+'/review-request', cookie=cookie, query='version=1')['status'] == 403
assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 1
def test_review_profiles_are_explicit_and_no_browser_route_binds(browser):
app, lifecycle, session, cookie = browser
form = call(app, '/decisions/new/prepare', cookie=cookie)['body']
assert 'Human approval (organizational)' in form and 'Discussion only' in form
response = post(browser, '/decisions/new/prepare', question='Discuss the storage proposal?', brief='Discuss alternatives.',
scope_id='storage', scope_label='Storage', review_profile='discussion')
path = dict(response['headers'])['Location']
assert 'not supported by the current human approval flow' in call(app, path, cookie=cookie)['body']
assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review')['status'] == 400
assert post(browser, path+'/accept', version='1')['status'] == 404