T02: additive migration d5e6f7a8b9c0 (decision_class, memo_id,
approval_id, resolution_source, resolution_evidence, evidence_refs,
CHECKs, partial unique memo_id); typed evidence_refs; 409
evidence_required for text resolve, resolving PATCH fields and resolved
create on C1/C2; raise-only class; C3 refused as reserved.
T03: POST /decisions/{id}/resolve-evidence (link/resolve), projected
token verified by TokenReview with audience
state-hub-decision-writeback and an exact subject allowlist; outcome
record v1 validation, 8 KiB bound, secret scan; idempotent on
(decision_id, memo_id). Off by default (DECISION_WRITEBACK_ENABLED).
Tests use a fake reviewer; full suite 1286 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
472 lines
19 KiB
Python
472 lines
19 KiB
Python
"""STATE-WP-0101-T02/T03: decision classes, evidence refusals and the write-back route.
|
|
|
|
Design: docs/decision-services-alignment-answer.md sections 3-6, founder decisions
|
|
of 2026-11-01 (section 8.1). The token reviewer is a fake; no live token is used.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import sqlalchemy
|
|
|
|
from api.config import settings
|
|
from api.services.decision_writeback import TokenReviewResult, TokenReviewUnavailable, get_token_reviewer
|
|
from tests.conftest import _SYNC_URL, create_test_domain, create_test_topic
|
|
|
|
WORKER = "system:serviceaccount:state-hub:state-hub"
|
|
AUDIENCE = "state-hub-decision-writeback"
|
|
GOOD_TOKEN = "worker-token"
|
|
OTHER_TOKEN = "other-sa-token"
|
|
WRONG_AUD_TOKEN = "wrong-audience-token"
|
|
|
|
|
|
class FakeReviewer:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, str]] = []
|
|
self.down = False
|
|
|
|
async def review(self, token: str, audience: str) -> TokenReviewResult:
|
|
self.calls.append((token, audience))
|
|
if self.down:
|
|
raise TokenReviewUnavailable("down")
|
|
if token == GOOD_TOKEN:
|
|
return TokenReviewResult(True, WORKER, (audience,))
|
|
if token == OTHER_TOKEN:
|
|
return TokenReviewResult(True, "system:serviceaccount:other:agent", (audience,))
|
|
if token == WRONG_AUD_TOKEN:
|
|
return TokenReviewResult(True, WORKER, ())
|
|
return TokenReviewResult(False)
|
|
|
|
|
|
@pytest.fixture
|
|
def reviewer(client, monkeypatch):
|
|
from api.main import app
|
|
|
|
fake = FakeReviewer()
|
|
app.dependency_overrides[get_token_reviewer] = lambda: fake
|
|
monkeypatch.setattr(settings, "decision_writeback_enabled", True)
|
|
return fake
|
|
|
|
|
|
async def _topic(client):
|
|
await create_test_domain(client)
|
|
return await create_test_topic(client)
|
|
|
|
|
|
async def _decision(client, topic, **extra):
|
|
r = await client.post(
|
|
"/decisions/", json={"title": "Pick a vendor", "topic_id": topic["id"], **extra}
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _outcome(decision_id, memo_id="memo-abc", **extra):
|
|
record = {
|
|
"kind": "informed-decision.decision-outcome",
|
|
"version": 1,
|
|
"hub_decision_id": decision_id,
|
|
"memo_id": memo_id,
|
|
"memo_revision": 2,
|
|
"verb": "accept",
|
|
"subject": {"iss": "https://id.example/realm", "sub": "u-123"},
|
|
"assurance": {"acr": "aal2", "auth_time": "2026-11-01T09:00:00Z"},
|
|
"decided_at": "2026-11-01T09:05:00Z",
|
|
"view_hash": "sha256:" + "a" * 64,
|
|
"presentation_id": "pres-1",
|
|
"display_name": "Bernd",
|
|
}
|
|
record.update(extra)
|
|
return record
|
|
|
|
|
|
def _auth(token=GOOD_TOKEN):
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
async def _write(client, decision_id, body, token=GOOD_TOKEN):
|
|
return await client.post(
|
|
f"/decisions/{decision_id}/resolve-evidence", json=body, headers=_auth(token)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T02: legacy / C0 unchanged
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_legacy_decision_reads_new_fields_and_resolves_by_text(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic)
|
|
assert d["decision_class"] is None
|
|
assert d["evidence_refs"] == []
|
|
assert d["memo_id"] is None and d["resolution_evidence"] is None
|
|
r = await client.post(f"/decisions/{d['id']}/resolve", json={"rationale": "ok", "decided_by": "b"})
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "resolved"
|
|
assert r.json()["resolution_source"] == "text"
|
|
|
|
|
|
async def test_c0_patch_and_resolved_create_as_today(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C0")
|
|
r = await client.patch(
|
|
f"/decisions/{d['id']}", json={"status": "resolved", "decided_by": "b", "rationale": "x"}
|
|
)
|
|
assert r.status_code == 200 and r.json()["status"] == "resolved"
|
|
created = await _decision(client, topic, decision_class="C0", status="resolved")
|
|
assert created["status"] == "resolved"
|
|
|
|
|
|
async def test_evidence_refs_typed_and_any_class(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(
|
|
client,
|
|
topic,
|
|
decision_class="C1",
|
|
evidence_refs=[{"type": "ccr", "ref": "CCR-0058"}, {"type": "commit", "ref": "abc", "digest": "sha1:abc"}],
|
|
)
|
|
assert [x["type"] for x in d["evidence_refs"]] == ["ccr", "commit"]
|
|
r = await client.patch(
|
|
f"/decisions/{d['id']}", json={"evidence_refs": [{"type": "workplan_task", "ref": "STATE-WP-0101-T02"}]}
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["evidence_refs"][0]["ref"] == "STATE-WP-0101-T02"
|
|
bad = await client.post(
|
|
"/decisions/",
|
|
json={"title": "x", "topic_id": topic["id"], "evidence_refs": [{"type": "tweet", "ref": "y"}]},
|
|
)
|
|
assert bad.status_code == 422
|
|
|
|
|
|
async def test_list_filters_by_class(client):
|
|
topic = await _topic(client)
|
|
await _decision(client, topic)
|
|
c1 = await _decision(client, topic, decision_class="C1")
|
|
r = await client.get("/decisions/", params={"decision_class": "C1"})
|
|
assert [x["id"] for x in r.json()] == [c1["id"]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T02: refusals (design section 4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("klass", ["C1", "C2"])
|
|
async def test_text_resolve_refused_for_c1_c2(client, klass):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class=klass)
|
|
r = await client.post(f"/decisions/{d['id']}/resolve", json={"rationale": "ok", "decided_by": "b"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "evidence_required"
|
|
again = await client.get(f"/decisions/{d['id']}")
|
|
assert again.json()["status"] == "open"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"patch",
|
|
[
|
|
{"status": "resolved"},
|
|
{"decided_by": "b"},
|
|
{"decided_at": "2026-11-01T00:00:00Z"},
|
|
{"rationale": "because"},
|
|
],
|
|
)
|
|
async def test_resolving_patch_fields_refused_for_c1(client, patch):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
r = await client.patch(f"/decisions/{d['id']}", json=patch)
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "evidence_required"
|
|
|
|
|
|
async def test_raise_and_resolve_in_one_patch_refused(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic)
|
|
r = await client.patch(f"/decisions/{d['id']}", json={"decision_class": "C2", "status": "resolved"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "evidence_required"
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["memo_id", "approval_id", "resolution_source", "resolution_evidence"])
|
|
async def test_link_fields_not_writable_by_patch_or_create(client, field):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic)
|
|
value = str(uuid.uuid4()) if field == "approval_id" else ({} if field == "resolution_evidence" else "x")
|
|
r = await client.patch(f"/decisions/{d['id']}", json={field: value})
|
|
assert r.status_code == 422
|
|
r = await client.post("/decisions/", json={"title": "t", "topic_id": topic["id"], field: value})
|
|
assert r.status_code == 422
|
|
|
|
|
|
async def test_class_raise_allowed_lowering_refused(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic)
|
|
assert (await client.patch(f"/decisions/{d['id']}", json={"decision_class": "C1"})).status_code == 200
|
|
assert (await client.patch(f"/decisions/{d['id']}", json={"decision_class": "C2"})).status_code == 200
|
|
for lower in ("C1", "C0", None):
|
|
r = await client.patch(f"/decisions/{d['id']}", json={"decision_class": lower})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "decision_class_lowering"
|
|
|
|
|
|
async def test_class_of_resolved_decision_frozen(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic)
|
|
await client.post(f"/decisions/{d['id']}/resolve", json={"rationale": "ok", "decided_by": "b"})
|
|
r = await client.patch(f"/decisions/{d['id']}", json={"decision_class": "C1"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "decision_class_frozen"
|
|
|
|
|
|
async def test_c3_reserved(client):
|
|
topic = await _topic(client)
|
|
r = await client.post("/decisions/", json={"title": "t", "topic_id": topic["id"], "decision_class": "C3"})
|
|
assert r.status_code == 422
|
|
assert r.json()["detail"]["error"] == "decision_class_reserved"
|
|
d = await _decision(client, topic)
|
|
r = await client.patch(f"/decisions/{d['id']}", json={"decision_class": "C3"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("klass", ["C1", "C2"])
|
|
async def test_resolved_create_refused_for_c1_c2(client, klass):
|
|
topic = await _topic(client)
|
|
r = await client.post(
|
|
"/decisions/",
|
|
json={"title": "t", "topic_id": topic["id"], "decision_class": klass, "status": "resolved"},
|
|
)
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "evidence_required"
|
|
|
|
|
|
async def test_supersede_allowed_for_c2(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C2")
|
|
r = await client.delete(f"/decisions/{d['id']}")
|
|
assert r.status_code == 200 and r.json()["status"] == "superseded"
|
|
|
|
|
|
async def test_database_check_refuses_resolved_c1_without_evidence(client):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
engine = sqlalchemy.create_engine(_SYNC_URL)
|
|
try:
|
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
sqlalchemy.text("UPDATE decisions SET status='resolved' WHERE id=:id"),
|
|
{"id": d["id"]},
|
|
)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_migration_constraints_match_model():
|
|
from api.models import decision as model
|
|
|
|
path = Path(__file__).parent.parent / "migrations/versions/d5e6f7a8b9c0_decision_evidence_links.py"
|
|
spec = importlib.util.spec_from_file_location("mig_d5e6", path)
|
|
mig = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mig)
|
|
for name in ("CK_DECISION_CLASS", "CK_RESOLUTION_SOURCE", "CK_CLASS_EVIDENCE", "CK_CLASS_APPROVAL"):
|
|
assert getattr(mig, name) == getattr(model, name)
|
|
assert mig.down_revision == "c4d5e6f7a8b9"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T03: caller verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_route_disabled_by_default(client, monkeypatch):
|
|
monkeypatch.setattr(settings, "decision_writeback_enabled", False)
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 503
|
|
assert r.json()["detail"]["error"] == "decision_writeback_disabled"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("headers", "status", "error"),
|
|
[
|
|
({}, 401, "token_required"),
|
|
(_auth("garbage"), 401, "token_invalid"),
|
|
(_auth(WRONG_AUD_TOKEN), 401, "token_invalid"),
|
|
(_auth(OTHER_TOKEN), 403, "subject_not_allowed"),
|
|
],
|
|
)
|
|
async def test_route_refuses_unverified_callers(client, reviewer, headers, status, error):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
r = await client.post(
|
|
f"/decisions/{d['id']}/resolve-evidence",
|
|
json={"mode": "link", "memo_id": "memo-abc"},
|
|
headers=headers,
|
|
)
|
|
assert r.status_code == status
|
|
assert r.json()["detail"]["error"] == error
|
|
assert (await client.get(f"/decisions/{d['id']}")).json()["memo_id"] is None
|
|
|
|
|
|
async def test_token_review_audience_and_fail_closed(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
reviewer.down = True
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 503
|
|
assert reviewer.calls == [(GOOD_TOKEN, AUDIENCE)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T03: link and resolve
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_c1_link_then_resolve_idempotent(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 200
|
|
assert r.json()["memo_id"] == "memo-abc" and r.json()["status"] == "open"
|
|
assert (await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})).status_code == 200
|
|
|
|
body = {"mode": "resolve", "memo_id": "memo-abc", "outcome": _outcome(d["id"], note="go ahead")}
|
|
r = await _write(client, d["id"], body)
|
|
assert r.status_code == 200, r.text
|
|
got = r.json()
|
|
assert got["status"] == "resolved"
|
|
assert got["resolution_source"] == "informed_decision"
|
|
assert got["decided_by"] == "Bernd"
|
|
assert got["resolution_evidence"]["subject"] == {"iss": "https://id.example/realm", "sub": "u-123"}
|
|
assert got["resolution_evidence"]["view_hash"] == "sha256:" + "a" * 64
|
|
assert "accepted in informed-decision, memo memo-abc revision 2" in got["rationale"]
|
|
|
|
again = await _write(client, d["id"], body)
|
|
assert again.status_code == 200
|
|
events = (await client.get("/progress/", params={"decision_id": d["id"]})).json()
|
|
resolved = [e for e in events if e.get("event_type") == "decision_resolved"]
|
|
assert len(resolved) == 1
|
|
assert resolved[0]["detail"]["attested_via"] == WORKER
|
|
|
|
|
|
async def test_decline_resolves_without_prior_link(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
body = {"mode": "resolve", "memo_id": "memo-dec", "outcome": _outcome(d["id"], "memo-dec", verb="decline")}
|
|
r = await _write(client, d["id"], body)
|
|
assert r.status_code == 200
|
|
assert r.json()["rationale"].startswith("declined")
|
|
|
|
|
|
async def test_different_memo_conflicts(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-xyz"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"] == {
|
|
"error": "memo_conflict",
|
|
"message": "the decision is linked to another memo",
|
|
"memo_id": "memo-abc",
|
|
}
|
|
|
|
|
|
async def test_memo_answers_one_decision_only(client, reviewer):
|
|
topic = await _topic(client)
|
|
a = await _decision(client, topic, decision_class="C1")
|
|
b = await _decision(client, topic, decision_class="C1")
|
|
assert (await _write(client, a["id"], {"mode": "link", "memo_id": "memo-abc"})).status_code == 200
|
|
r = await _write(client, b["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "memo_already_linked"
|
|
|
|
|
|
async def test_c0_and_legacy_refused_on_evidence_route(client, reviewer):
|
|
topic = await _topic(client)
|
|
for extra in ({}, {"decision_class": "C0"}):
|
|
d = await _decision(client, topic, **extra)
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"]["error"] == "evidence_not_applicable"
|
|
|
|
|
|
async def test_c2_requires_approval(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C2")
|
|
body = {"mode": "resolve", "memo_id": "memo-c2", "outcome": _outcome(d["id"], "memo-c2")}
|
|
r = await _write(client, d["id"], body)
|
|
assert r.status_code == 422
|
|
assert r.json()["detail"]["error"] == "approval_required"
|
|
|
|
approval = str(uuid.uuid4())
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-c2", "approval_id": approval})
|
|
assert r.status_code == 200 and r.json()["approval_id"] == approval
|
|
other = _outcome(d["id"], "memo-c2", approval_id=str(uuid.uuid4()), approval_entry_id="e1")
|
|
r = await _write(client, d["id"], {"mode": "resolve", "memo_id": "memo-c2", "outcome": other})
|
|
assert r.status_code == 409 and r.json()["detail"]["error"] == "approval_conflict"
|
|
|
|
good = _outcome(d["id"], "memo-c2", approval_id=approval, approval_entry_id="e1")
|
|
r = await _write(client, d["id"], {"mode": "resolve", "memo_id": "memo-c2", "outcome": good})
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["status"] == "resolved" and r.json()["approval_id"] == approval
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutate", "error"),
|
|
[
|
|
(lambda o, d: o.update(hub_decision_id=str(uuid.uuid4())), "hub_decision_mismatch"),
|
|
(lambda o, d: o.update(memo_id="memo-other"), "memo_mismatch"),
|
|
(lambda o, d: o.update(note="password: hunter2hunter2hunter2"), "secret_marker"),
|
|
],
|
|
)
|
|
async def test_outcome_checks(client, reviewer, mutate, error):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
outcome = _outcome(d["id"])
|
|
mutate(outcome, d)
|
|
r = await _write(client, d["id"], {"mode": "resolve", "memo_id": "memo-abc", "outcome": outcome})
|
|
assert r.status_code == 422
|
|
assert r.json()["detail"]["error"] == error
|
|
assert (await client.get(f"/decisions/{d['id']}")).json()["status"] == "open"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutate",
|
|
[
|
|
lambda o: o.update(verb="comment"),
|
|
lambda o: o.update(view_hash="sha256:short"),
|
|
lambda o: o.update(packet={"content": "x"}),
|
|
lambda o: o.update(note="x" * 2049),
|
|
lambda o: o.update(decided_at="2026-11-01T09:05:00"),
|
|
lambda o: o.update(assurance={"auth_time": "2026-11-01T09:00:00Z"}),
|
|
lambda o: o.pop("subject"),
|
|
],
|
|
)
|
|
async def test_outcome_record_shape(client, reviewer, mutate):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
outcome = _outcome(d["id"])
|
|
mutate(outcome)
|
|
r = await _write(client, d["id"], {"mode": "resolve", "memo_id": "memo-abc", "outcome": outcome})
|
|
assert r.status_code == 422
|
|
|
|
|
|
async def test_superseded_refused(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
await client.delete(f"/decisions/{d['id']}")
|
|
r = await _write(client, d["id"], {"mode": "link", "memo_id": "memo-abc"})
|
|
assert r.status_code == 409 and r.json()["detail"]["error"] == "decision_superseded"
|
|
|
|
|
|
async def test_evidence_resolved_not_reopened_by_patch(client, reviewer):
|
|
topic = await _topic(client)
|
|
d = await _decision(client, topic, decision_class="C1")
|
|
await _write(client, d["id"], {"mode": "resolve", "memo_id": "memo-abc", "outcome": _outcome(d["id"])})
|
|
r = await client.patch(f"/decisions/{d['id']}", json={"status": "open"})
|
|
assert r.status_code == 409 and r.json()["detail"]["error"] == "evidence_bound"
|