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
226 lines
11 KiB
Python
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
|