Implement lifecycle workspace publication and audit delivery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 20:17:24 +02:00
parent ee472acc87
commit 5fec7de4cb
21 changed files with 1022 additions and 78 deletions

View file

@ -1,4 +1,5 @@
import copy
import json
import importlib.util
from pathlib import Path
@ -75,3 +76,24 @@ def test_policy_revision_changes_immutable_configuration_name():
first=inputs();second=copy.deepcopy(first);second['policy']['version']='v2'
names=lambda data:[o['metadata']['name'] for o in module.render(data)['items'] if o['kind']=='ConfigMap']
assert names(first)!=names(second)
def test_optional_workspace_requires_distinct_pins_and_exact_policy_network_peer():
data = inputs()
data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle',
'origin': 'http://flex-auth-lifecycle.flex-auth.svc.cluster.local:8080',
'pod_name': 'flex-auth-lifecycle'}
with pytest.raises(ValueError):
module.render(data)
data['lifecycle_delivery'] = True
objects = module.render(data)['items']
config = json.loads(next(row for row in objects if row['kind']=='ConfigMap')['data']['runtime.json'])
assert config['lifecycle_policy']['package']=='informed-decision.lifecycle'
assert config['lifecycle_delivery'] is True
review_network = next(row for row in objects if row['kind']=='NetworkPolicy' and row['metadata']['namespace']=='informed-decision')
assert any(peer.get('podSelector', {}).get('matchLabels', {}).get('app.kubernetes.io/name')=='flex-auth-lifecycle'
for rule in review_network['spec']['egress'] for peer in rule.get('to', []))
assert any(row['metadata']['name']=='informed-decision-lifecycle-ingress' for row in objects)
data['lifecycle_policy']['package'] = data['policy']['package']
with pytest.raises(ValueError):
module.render(data)

View file

@ -0,0 +1,80 @@
from dataclasses import replace
import hashlib
import json
import pytest
from informed_decision.audit import OutboxWorker
from informed_decision.evidence import EventClass
from informed_decision.lifecycle import LifecycleRepository
from informed_decision.runtime import AuditPump
from informed_decision.store import Conflict, Store
from test_durable_component import receiver
from test_lifecycle import AGENT, workspace
def register_platform(workspace):
repo, memo = workspace
actor = replace(AGENT, tenant="tenant:platform")
event_id = repo.register(actor, memo, key="register")
return repo.store, memo, event_id
def test_lifecycle_commitment_is_content_free_stable_and_verifiable(workspace):
store, memo, event_id = register_platform(workspace)
assert store.stage_lifecycle_audit() == 1
assert store.stage_lifecycle_audit() == 0
evidence = store.evidence()[0]
envelope = json.loads(evidence["envelope"])
assert envelope["id"] == event_id and envelope["type"] == EventClass.LIFECYCLE.value
assert memo.brief not in evidence["envelope"] and memo.question not in evidence["envelope"]
assert envelope["data"]["content_sha256"] == "sha256:" + hashlib.sha256(evidence["content"].encode()).hexdigest()
assert store.settle_lifecycle_audit() == 0
assert store.lifecycle_counts()["pending"] == 1
assert Store(store.path).evidence() == store.evidence()
def test_lifecycle_delivery_uses_actual_receiver_and_recovers_lost_reply(workspace, receiver):
store, _, event_id = register_platform(workspace)
sink, transport, _ = receiver
store.stage_lifecycle_audit()
transport.lose_reply = True
worker = OutboxWorker(store, sink)
assert worker.run_once()["retrying"] == 1
assert store.settle_lifecycle_audit() == 0
with store._connection() as db:
db.execute("UPDATE outbox SET next_attempt=0 WHERE id=?", (event_id,))
reopened = Store(store.path)
assert OutboxWorker(reopened, sink).run_once()["delivered"] == 1
assert reopened.settle_lifecycle_audit() == 1
assert reopened.lifecycle_counts() == {"records": 1, "events": 1, "pending": 0}
assert worker.reconcile("2020-01-01T00:00:00Z", "2100-01-01T00:00:00Z")["counts"][EventClass.LIFECYCLE.value] == {"source": 1, "receiver": 1}
def test_runtime_delivery_is_explicit_and_restores_readiness_only_after_receipts(workspace, receiver):
store, _, _ = register_platform(workspace)
sink, _, _ = receiver
disabled = AuditPump(store, sink)
disabled.tick()
assert not disabled.ready() and store.lifecycle_counts()["pending"] == 1
enabled = AuditPump(store, sink, lifecycle_delivery=True)
enabled.tick()
assert enabled.ready() and store.lifecycle_counts()["pending"] == 0
assert store.evidence()[0]["class"] != EventClass.LIFECYCLE.value # Legacy heartbeats predate opt-in.
def test_unadmitted_tenant_remains_pending_without_audit_relabelling(workspace):
repo, memo = workspace
repo.register(AGENT, memo, key="register")
with pytest.raises(Conflict, match="tenant not admitted"):
repo.store.stage_lifecycle_audit()
assert repo.store.evidence() == []
assert repo.store.lifecycle_counts()["pending"] == 1
def test_pending_events_do_not_get_a_nothing_to_report_heartbeat(workspace):
store, _, _ = register_platform(workspace)
store.stage_lifecycle_audit()
store.queue_heartbeats(include_lifecycle=True)
assert not any(json.loads(row["envelope"])["data"].get("class") == EventClass.LIFECYCLE.value
for row in store.evidence() if row["class"] == EventClass.HEARTBEAT.value)

View file

@ -0,0 +1,74 @@
import json
from urllib.parse import urlencode
import uuid
from informed_decision.audit import OutboxWorker
from informed_decision.lifecycle_http import LifecycleAPI
from informed_decision.lifecycle_service import Caller
from informed_decision.runtime import Runtime
from informed_decision.web import App, SESSION_COOKIE
from informed_decision.oidc import ORIGIN
from informed_decision.policy import PolicyClient
from informed_decision.records import dumps
from test_browser_auth import login, signing_key, finish, call
from test_deployment_candidate import inputs, module
from test_durable_component import receiver
from test_lifecycle_http import call as api_call
from test_policy_client import PolicyTransport
from test_review_runtime import config
from test_skeleton import make_memo
def test_agent_to_human_collaboration_and_audit_without_configuration_changes(tmp_path, login, receiver):
path, configuration = config(tmp_path)
configuration['lifecycle_policy'] = {**configuration['policy'], 'package': 'informed-decision.lifecycle'}
configuration['lifecycle_delivery'] = True
path.write_text(json.dumps(configuration))
original_configuration = path.read_bytes()
runtime = Runtime.from_file(path)
transport = PolicyTransport()
transport.change = lambda data: data['provenance'].update(policy_package='informed-decision.lifecycle')
lifecycle = runtime.lifecycle
lifecycle.policy = PolicyClient('https://policy.test', lambda: 'synthetic-caller',
package='informed-decision.lifecycle', version='v1', package_digest=configuration['policy']['package_digest'],
transport=transport)
# Match the immutable configured digest in the simulated PDP return.
transport.change = lambda data: data['provenance'].update(
policy_package='informed-decision.lifecycle', policy_package_digest=configuration['policy']['package_digest'])
sid = finish(login)
session = login[0].session(sid)
from informed_decision.lifecycle import Attribution
from informed_decision.disposition import Actor, ActorKind
agent = Caller(Attribution(session.tenant.value, Actor('synthetic-agent', ActorKind.AGENT)),
session.expires_at, '{"principal_type_source":"authentication-derived"}')
def authenticate(token):
if token != 'fixture':
raise ValueError()
return agent
api = LifecycleAPI(lifecycle, authenticate)
app = App(login[0], runtime.controller, readiness=runtime.pump.ready, lifecycle=lifecycle)
cookie = f'{SESSION_COOKIE}={sid}'
memo = make_memo(id='agent-posed', approval_id=None, packet=(), highlights=())
status, receipt, _ = api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(memo))})
assert status == 200 and receipt['workspace_url'] == ORIGIN + '/decisions/agent-posed?version=1'
assert call(app, '/decisions/agent-posed', cookie=cookie)['status'] == 200
def post(action, **values):
values.update(csrf=session.csrf, key=str(uuid.uuid4()))
return call(app, '/decisions/agent-posed/' + action, 'POST', cookie=cookie, origin=ORIGIN,
body=urlencode(values).encode())
assert post('publish', version='1', subjects=session.subject, groups='')['status'] == 303
assert post('comment', version='1', text='Please add the alternative.')['status'] == 303
assert post('revise', version='1', question=memo.question, brief='Alternative added.', summary='Responded to feedback')['status'] == 303
assert api_call(api, '/v1/decisions/agent-posed')[1]['version'] == 2
from dataclasses import replace
second = replace(memo, id='second-agent-decision')
assert api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(second))},
HTTP_IDEMPOTENCY_KEY='second')[0] == 200
assert not runtime.pump.ready()
runtime.pump.worker = OutboxWorker(runtime.controller.store, receiver[0])
runtime.pump.tick()
assert runtime.pump.ready()
assert runtime.controller.store.lifecycle_counts() == {'records': 2, 'events': 5, 'pending': 0}
assert path.read_bytes() == original_configuration
assert runtime.controller.store.memos_for(memo.binding.principal.id) == []
assert api_call(api, '/v1/decisions/agent-posed/accept', method='POST', body={})[0] == 404

View file

@ -0,0 +1,111 @@
from dataclasses import replace
import json
import time
import pytest
from informed_decision.lifecycle_service import LifecycleError, LifecycleService
from informed_decision.store import Conflict, EvidenceUnavailable, Store
from test_lifecycle import AGENT, workspace
from test_lifecycle_service import caller, service
from test_policy_client import policy
AUDIENCE = {"subjects": ["reviewer"], "groups": []}
def test_publication_is_revision_pinned_durable_and_replayable(workspace):
app, memo = service(workspace)
actor = caller()
app.register(actor, memo, key="register")
receipt = app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish")
assert app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish") == receipt
state = app.repository.policy_context(AGENT.tenant, memo.id)
assert state["published_audience"] == AUDIENCE
reopened = type(app.repository)(Store(app.repository.store.path))
assert reopened.policy_context(AGENT.tenant, memo.id) == state
assert reopened.events(AGENT.tenant, memo.id)[-1]["kind"] == "published"
assert app.policy.transport.calls[1][3]["context"]["publication_audience"] == AUDIENCE
app.revise(actor, memo.next_version(brief="revised"), expected_version=1, summary="Changed", key="revise")
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
assert app.repository.policy_context(AGENT.tenant, memo.id, 1)["published_audience"] == AUDIENCE
with pytest.raises(Conflict):
app.publish(actor, memo.id, 1, audience=AUDIENCE, key="new-publish")
with app.repository.store._connection() as db:
assert db.execute("SELECT count(*) FROM dispositions").fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM submissions").fetchone()[0] == 0
@pytest.mark.parametrize("audience", [{}, {"public": True}, {"subjects": [], "groups": []},
{"subjects": ["*"], "groups": []},
{"subjects": "reviewer", "groups": []}])
def test_publication_requires_explicit_bounded_audience(workspace, audience):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
with pytest.raises(ValueError):
app.publish(caller(), memo.id, 1, audience=audience, key="publish")
assert len(app.repository.events(AGENT.tenant, memo.id)) == 1
def test_audience_is_not_local_permission_and_denial_prevents_publication(workspace):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
app.policy.transport.change = lambda data: data.update(effect="deny")
with pytest.raises(LifecycleError) as error:
app.publish(caller(), memo.id, 1, audience=AUDIENCE, key="publish")
assert error.value.status == 403
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
def test_audience_change_during_authorization_blocks_read_and_write(workspace, monkeypatch):
app, memo = service(workspace)
app.register(caller(), memo, key="register")
original = app.repository.annotate
def changed(*args, **kwargs):
app.repository.publish(AGENT, memo.id, 1, audience=AUDIENCE, key="concurrent-publish")
return original(*args, **kwargs)
monkeypatch.setattr(app.repository, "annotate", changed)
with pytest.raises(LifecycleError, match="record_changed"):
app.annotate(caller(), memo.id, 1, kind="comment", body={"text": "pending"}, key="comment")
original_read = app.repository.retrieve
def changed_read(*args, **kwargs):
result = original_read(*args, **kwargs)
app.repository.publish(AGENT, memo.id, 1, audience={"subjects": ["another"], "groups": []}, key="replace")
return result
monkeypatch.setattr(app.repository, "retrieve", changed_read)
with pytest.raises(LifecycleError, match="record_changed"):
app.retrieve(caller(), memo.id)
def test_search_filters_denied_records_and_opaque_cursor_is_caller_scoped(workspace):
app, memo = service(workspace)
for index in range(51):
app.repository.register(AGENT, replace(memo, id=f"record-{index:03}"), key=f"register-{index}")
# Actual PolicyClient still validates these synthetic PDP responses.
def decide(data):
if data["binding"]["resource"]["id"] == "memo:record-001":
data["effect"] = "deny"
app.policy.transport.change = decide
first = app.search(caller())
assert len(first["decisions"]) == 49
assert "record-001" not in json.dumps(first)
assert first["next_cursor"] and "record-049" not in first["next_cursor"]
assert app.search(caller(), cursor=first["next_cursor"])["decisions"][0]["memo_id"] == "record-050"
with pytest.raises(ValueError):
app.search(caller(), query="different", cursor=first["next_cursor"])
foreign = replace(caller(), attribution=replace(AGENT, tenant="tenant:other"))
with pytest.raises(ValueError):
app.search(foreign, cursor=first["next_cursor"])
with pytest.raises(ValueError):
LifecycleService(app.repository, policy()).search(caller(), cursor=first["next_cursor"])
app.policy.transport.change = lambda data: data.update(effect="deny")
with pytest.raises(LifecycleError):
app.search(caller(), cursor=first["next_cursor"])
def test_search_does_not_turn_policy_failure_into_empty_success(workspace):
app, memo = service(workspace)
app.repository.register(AGENT, memo, key="register")
app.policy.transport.status = 503
with pytest.raises(LifecycleError):
app.search(caller())

View file

@ -0,0 +1,92 @@
import re
import uuid
from urllib.parse import urlencode
import pytest
from informed_decision.lifecycle_service import Caller
from informed_decision.oidc import ORIGIN
from informed_decision.web import App, SESSION_COOKIE
from test_browser_auth import login, signing_key, finish, call
from test_lifecycle import workspace
from test_lifecycle_service import service
@pytest.fixture
def browser(workspace, login):
lifecycle, _ = service(workspace)
sid = finish(login)
session = login[0].session(sid)
return App(login[0], lifecycle=lifecycle), lifecycle, session, f"{SESSION_COOKIE}={sid}"
def post(browser, path, **values):
app, _, session, cookie = browser
values.setdefault("csrf", session.csrf)
values.setdefault("key", str(uuid.uuid4()))
return call(app, path, "POST", cookie=cookie, origin=ORIGIN, body=urlencode(values).encode())
def create(browser):
response = post(browser, "/decisions/new/prepare", question="Which storage option should we adopt?",
brief="Compare cost, recovery and maintenance.", scope_id="storage", scope_label="Storage strategy")
assert response["status"] == 303
return dict(response["headers"])["Location"]
def test_human_workspace_create_refine_publish_discuss_and_retrieve(browser):
app, lifecycle, session, cookie = browser
assert 'Open the decision workspace' in call(app, cookie=cookie)["body"]
path = create(browser)
page = call(app, path, cookie=cookie)
assert page["status"] == 200 and "Storage strategy" in page["body"]
assert "Human acceptance is not available" in page["body"]
assert post(browser, path + "/comment", version="1", text="Please include restore time.")["status"] == 303
assert post(browser, path + "/publish", version="1", subjects=session.subject, groups="")["status"] == 303
assert "published" in call(app, path, cookie=cookie)["body"]
assert post(browser, path + "/revise", version="1", question="Which storage option should we adopt?",
brief="Option A restores in one hour.", summary="Added recovery estimate")["status"] == 303
assert "Revision 2" in call(app, path, cookie=cookie)["body"]
assert "Compare cost" in call(app, path, cookie=cookie, query="version=1")["body"]
listing = call(app, "/decisions", cookie=cookie, query="q=storage")
assert listing["status"] == 200 and path in listing["body"]
memo_id = path.rsplit("/", 1)[1]
assert lifecycle.repository.policy_context(session.tenant.value, memo_id)["published_audience"] is None
assert lifecycle.repository.store.lifecycle_counts()["pending"] == 4
def test_same_create_form_replay_reuses_record(browser):
fields = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
"scope_label": "Target", "key": str(uuid.uuid4())}
first = post(browser, "/decisions/new/prepare", **fields)
second = post(browser, "/decisions/new/prepare", **fields)
assert dict(first["headers"])["Location"] == dict(second["headers"])["Location"]
assert browser[1].repository.store.lifecycle_counts()["records"] == 1
def test_csrf_and_origin_are_enforced_before_any_mutation(browser):
app, lifecycle, session, cookie = browser
values = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
"scope_label": "Target", "key": str(uuid.uuid4()), "csrf": session.csrf}
for origin, csrf in [(None, session.csrf), ("https://other.test", session.csrf), (ORIGIN, "wrong")]:
response = call(app, "/decisions/new/prepare", "POST", cookie=cookie, origin=origin,
body=urlencode({**values, "csrf": csrf}).encode())
assert response["status"] == 403
assert lifecycle.repository.store.lifecycle_counts()["records"] == 0
def test_workspace_is_opt_in_and_requires_human_session(browser):
app, _, _, cookie = browser
assert call(app, "/decisions")["status"] == 401
assert call(App(app.login), "/decisions", cookie=cookie)["status"] == 404
def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser):
app, lifecycle, _, cookie = browser
path = create(browser)
post(browser, path + "/comment", version="1", text='<script>alert("x")</script>')
page = call(app, path, cookie=cookie)["body"]
assert "<script>" not in page and "&lt;script&gt;" in page
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"]

View file

@ -63,6 +63,21 @@ def test_config_load_does_not_provision_or_read_credentials(tmp_path):
assert all(r['state']=='blocked' for r in runtime.controller.store.outbox())
def test_lifecycle_workspace_requires_separate_explicit_policy_pin(tmp_path):
path, data = config(tmp_path)
assert Runtime.from_file(path).lifecycle is None
data['lifecycle_policy'] = dict(data['policy'])
path.write_text(json.dumps(data))
with pytest.raises(ValueError, match='separate admission'):
Runtime.from_file(path)
data['lifecycle_policy']['package'] = 'informed-decision.lifecycle'
path.write_text(json.dumps(data))
runtime = Runtime.from_file(path)
assert runtime.lifecycle.repository.store is runtime.controller.store
assert runtime.lifecycle.policy is not runtime.controller.policy
assert not runtime.pump.ready()
@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin'])
def test_unsafe_or_incomplete_config_refused(tmp_path,fault):
path,data=config(tmp_path)