from concurrent.futures import ThreadPoolExecutor from dataclasses import replace import json import sqlite3 import subprocess import sys import pytest from informed_decision.disposition import Actor, ActorKind, DispositionRefused, Verb from informed_decision.lifecycle import Attribution, LifecycleRepository from informed_decision.provenance import Claim, Route from informed_decision.store import Conflict, EvidenceUnavailable, Store from test_skeleton import make_memo AGENT = Attribution("tenant:one", Actor("researcher", ActorKind.AGENT)) HUMAN = Attribution("tenant:one", Actor("reviewer", ActorKind.PERSON)) @pytest.fixture def workspace(tmp_path): private = tmp_path / "private" private.mkdir(mode=0o700) store = Store(private / "store.sqlite") memo = make_memo(approval_id=None, packet=(), highlights=()) return LifecycleRepository(store), memo def test_registration_replay_survives_restart_and_backup(workspace): repo, memo = workspace receipt = repo.register(AGENT, memo, key="register") reopened = LifecycleRepository(Store(repo.store.path)) assert reopened.register(AGENT, memo, key="register") == receipt assert reopened.retrieve(AGENT.tenant, memo.id) == memo backup = repo.store.path.parent / "backup.sqlite" repo.store.backup(backup) assert LifecycleRepository(Store(backup)).events(AGENT.tenant, memo.id) == repo.events(AGENT.tenant, memo.id) with repo.store._connection() as db: assert db.execute("SELECT event_id FROM lifecycle_pending").fetchone()[0] == receipt assert db.execute("SELECT count(*) FROM submissions").fetchone()[0] == 0 def test_replay_collision_and_existing_memo_takeover_refused(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") with pytest.raises(Conflict): repo.register(AGENT, replace(memo, brief="different"), key="register") with pytest.raises(Conflict): repo.register(HUMAN, memo, key="register") imported = replace(memo, id="imported") repo.store.save_memo(imported) with pytest.raises(Conflict): repo.register(AGENT, imported, key="import") def test_idempotency_is_scoped_by_tenant_and_actor(workspace): repo, memo = workspace other = Attribution("tenant:two", AGENT.actor) for context, ident in [(AGENT, "one"), (HUMAN, "two"), (other, "three")]: repo.register(context, replace(memo, id=ident), key="same-key") with pytest.raises(EvidenceUnavailable): repo.retrieve(other.tenant, "one") with pytest.raises(EvidenceUnavailable): repo.events(other.tenant, "one") with pytest.raises(EvidenceUnavailable): repo.annotate(other, "one", 1, kind="comment", body={"text": "leak"}, key="comment") def test_concurrent_revision_has_one_winner_and_preserves_history(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") def edit(label): try: return repo.revise(HUMAN, memo.next_version(brief=label), expected_version=1, summary=label, key=label) except Conflict: return None with ThreadPoolExecutor(max_workers=2) as pool: outcomes = list(pool.map(edit, ["first", "second"])) assert sum(outcome is not None for outcome in outcomes) == 1 assert repo.retrieve(AGENT.tenant, memo.id, 1) == memo assert repo.retrieve(AGENT.tenant, memo.id).version == 2 assert len(repo.events(AGENT.tenant, memo.id)) == 2 def test_revision_replay_after_later_edits_returns_original_receipt(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") v2 = memo.next_version(brief="updated") receipt = repo.revise(AGENT, v2, expected_version=1, summary="updated", key="revision") repo.revise(AGENT, v2.next_version(brief="again"), expected_version=2, summary="again", key="again") assert repo.revise(AGENT, v2, expected_version=1, summary="updated", key="revision") == receipt @pytest.mark.parametrize("operation", ["register", "revise", "comment"]) def test_pending_event_failure_rolls_back_entire_mutation(workspace, operation): repo, memo = workspace if operation != "register": repo.register(AGENT, memo, key="register") with repo.store._connection() as db: db.execute("CREATE TRIGGER fail_pending BEFORE INSERT ON lifecycle_pending " "BEGIN SELECT RAISE(ABORT,'injected failure'); END") with pytest.raises(sqlite3.IntegrityError): if operation == "register": repo.register(AGENT, memo, key="register") elif operation == "revise": repo.revise(AGENT, memo.next_version(), expected_version=1, summary="change", key="revision") else: repo.annotate(AGENT, memo.id, 1, kind="comment", body={"text": "hello"}, key="comment") if operation == "register": with pytest.raises(EvidenceUnavailable): repo.store.memo(memo.id) else: assert repo.retrieve(AGENT.tenant, memo.id).version == 1 assert len(repo.events(AGENT.tenant, memo.id)) == 1 def test_sources_evaluation_comments_are_revision_anchored_and_paginated(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") source = repo.annotate(AGENT, memo.id, 1, kind="source", key="source", body={ "reference": "urn:report:1", "retrieved_at": "2026-10-04T10:00:00Z", "digest": "sha256:" + "a" * 64, "summary": "Source supplied by researcher"}) evaluation = {"assessment": "Alternative A is supported", "source_ids": [source], "uncertainty": "Costs have not been independently checked"} repo.annotate(AGENT, memo.id, 1, kind="evaluation", body=evaluation, key="evaluation") comment = repo.annotate(HUMAN, memo.id, 1, kind="comment", body={"text": "Check the costs"}, key="comment") repo.revise(AGENT, memo.next_version(), expected_version=1, summary="next", key="next") with pytest.raises(EvidenceUnavailable): repo.annotate(AGENT, memo.id, 2, kind="evaluation", body=evaluation, key="evaluation-v2") first = repo.events(AGENT.tenant, memo.id, limit=2) following = repo.events(AGENT.tenant, memo.id, after=first[-1]["sequence"]) assert [row["kind"] for row in first + following] == ["registered", "source", "evaluation", "comment", "revised"] assert following[1]["id"] == comment and following[1]["version"] == 1 assert following[0]["actor"]["kind"] == "agent" assert following[1]["actor"]["kind"] == "person" def test_agent_comment_does_not_grant_disposition_capability(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") repo.annotate(AGENT, memo.id, 1, kind="comment", body={"text": "Advisory only"}, key="comment") presentation = repo.store.present(memo.id, principal_sub=AGENT.actor.sub, tenant=Claim("tenant:platform", Route.DIRECTORY), principal_type=Claim("agent", Route.AUTHENTICATION)) with pytest.raises(DispositionRefused, match="G_NOAGENT"): repo.store.record_disposition(presentation.id, AGENT.actor, Verb.ACCEPT, operation_id="agent-accept") def test_revision_invalidates_existing_presentation(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") presentation = repo.store.present(memo.id, principal_sub=HUMAN.actor.sub, tenant=Claim("tenant:platform", Route.DIRECTORY), principal_type=Claim("human", Route.AUTHENTICATION)) repo.revise(HUMAN, memo.next_version(brief="new"), expected_version=1, summary="new", key="new") with pytest.raises(DispositionRefused, match="G_PRES"): repo.store.record_disposition(presentation.id, HUMAN.actor, Verb.ACCEPT, operation_id="stale") @pytest.mark.parametrize("kind,body", [ ("accept", {"text": "yes"}), ("comment", {"text": "yes", "principal_type": "human"}), ("evaluation", {"assessment": "yes", "source_ids": "not-a-list", "uncertainty": "none"}), ("source", {"reference": "ref", "retrieved_at": "yesterday", "digest": "bad", "summary": "test"}), ]) def test_malformed_companion_records_rejected(workspace, kind, body): repo, memo = workspace repo.register(AGENT, memo, key="register") with pytest.raises(ValueError): repo.annotate(AGENT, memo.id, 1, kind=kind, body=body, key="bad") assert len(repo.events(AGENT.tenant, memo.id)) == 1 def test_lifecycle_history_is_append_only(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") with repo.store._connection() as db: for table in ("lifecycle_records", "lifecycle_events", "lifecycle_operations"): with pytest.raises(sqlite3.IntegrityError): db.execute(f"DELETE FROM {table}") def test_foundation_imports_no_ui_or_http_runtime(): result = subprocess.run([sys.executable, "-c", "import sys; import informed_decision.lifecycle; " "assert not {'informed_decision.ui', 'informed_decision.web', " "'informed_decision.runtime'} & sys.modules.keys()"], capture_output=True, text=True) assert result.returncode == 0, result.stderr def test_unpublished_records_do_not_enter_legacy_review_overview(workspace): repo, memo = workspace legacy = replace(memo, id="legacy-review") repo.store.save_memo(legacy) repo.register(AGENT, memo, key="register") assert [m.id for m in repo.store.memos_for(memo.binding.principal.id)] == [legacy.id] def test_legacy_import_cannot_bypass_lifecycle_revision_history(workspace): repo, memo = workspace repo.register(AGENT, memo, key="register") with pytest.raises(Conflict, match="lifecycle revision"): repo.store.save_memo(memo.next_version(brief="unattributed edit")) assert repo.retrieve(AGENT.tenant, memo.id).version == 1 assert repo.store.lifecycle_counts() == {"records": 1, "events": 1, "pending": 1} def test_pending_lifecycle_events_close_existing_runtime_readiness_immediately(workspace): from informed_decision.runtime import AuditPump repo, memo = workspace pump = AuditPump(repo.store, None, clock=lambda: 100) pump._last_ok = 100 assert pump.ready() repo.register(AGENT, memo, key="register") assert not pump.ready() assert not AuditPump(Store(repo.store.path), None).ready() def test_admin_reports_lifecycle_backlog_without_private_content(workspace, monkeypatch, capsys): from informed_decision.container import admin repo, memo = workspace repo.register(AGENT, memo, key="register") monkeypatch.setattr(sys, "argv", ["admin", "inspect", "--db", str(repo.store.path)]) admin() output = capsys.readouterr().out assert json.loads(output)["lifecycle"] == {"records": 1, "events": 1, "pending": 1} assert memo.question not in output and AGENT.actor.sub not in output