informed-decision/tests/test_lifecycle.py
custodian-sync 04f66f81b6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-10-04:
  - update .custodian-brief.md for informed-decision

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
2026-10-04 18:36:55 +02:00

226 lines
11 KiB
Python

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