state-hub/api/routers/decisions.py
tegwick 0b473c1bda
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m3s
STATE-WP-0101-T02/T03: decision classes, evidence refusals, write-back route
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
2026-11-01 04:01:48 +01:00

522 lines
19 KiB
Python

import asyncio
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.decision import (
APPROVAL_CLASSES,
CLASS_RANK,
EVIDENCE_CLASSES,
Decision,
DecisionClass,
DecisionStatus,
DecisionType,
ResolutionSource,
)
from api.models.progress_event import ProgressEvent
from api.models.review_contract import ReviewContract
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param
from api.services.decision_writeback import (
DecisionEvidenceWrite,
require_writeback_caller,
stored_outcome,
)
from api.services.review_contracts import aggregate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/decisions", tags=["decisions"])
_FINANCIAL_LEGAL_KEYWORDS = (
"financ", "legal", "payment", "purchas", "contract", "commit",
"obligation", "external representation",
)
def _needs_escalation(body: DecisionCreate) -> str | None:
if body.decision_type != DecisionType.pending:
return None
text = f"{body.title} {body.description or ''}".lower()
for kw in _FINANCIAL_LEGAL_KEYWORDS:
if kw in text:
return (
"Auto-escalated per constitution §4: this pending decision touches "
"financial or legal territory and requires explicit human approval before action."
)
return None
# STATE-WP-0101: fields that resolve or describe a resolution. On C1/C2 they are
# written only by POST /decisions/{id}/resolve-evidence.
_RESOLVING_PATCH_FIELDS = ("decided_by", "decided_at", "rationale")
def _class_of(decision: Decision) -> DecisionClass | None:
return DecisionClass(decision.decision_class) if decision.decision_class else None
def _refuse_reserved(value: DecisionClass | None) -> None:
if value == DecisionClass.C3:
raise HTTPException(
status_code=422,
detail={
"error": "decision_class_reserved",
"message": "C3 is reserved and not usable (gate-house GH-DEC-2026-026)",
},
)
def _evidence_required(decision_class: DecisionClass, action: str) -> HTTPException:
return HTTPException(
status_code=409,
detail={
"error": "evidence_required",
"message": (
f"{decision_class.value} decisions are resolved only by informed-decision "
f"evidence; {action} is refused"
),
"decision_class": decision_class.value,
"resolve_with": "POST /decisions/{id}/resolve-evidence",
},
)
async def _require_review_contracts(session: AsyncSession, decision: Decision) -> None:
review_rows = await session.execute(
select(ReviewContract).where(
ReviewContract.decision_id == decision.id,
ReviewContract.active.is_(True),
ReviewContract.required_for_decision.is_(True),
)
)
for contract in review_rows.scalars():
review_state = await aggregate(session, contract)
if not review_state.satisfied:
raise HTTPException(
status_code=409,
detail={
"message": "required multi-owner review is not satisfied",
"contract_key": contract.contract_key,
"contract_digest": contract.contract_digest,
},
)
def _publish_resolved(decision: Decision, decided_by: str, rationale: str | None) -> None:
subject = "org.statehub.decision.resolved"
envelope = EventEnvelope.new(
subject,
attributes={
"decision_id": str(decision.id),
"title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None,
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
"decided_by": decided_by,
"rationale_snippet": (rationale or "")[:240],
"decision_class": decision.decision_class,
"memo_id": decision.memo_id,
"approval_id": str(decision.approval_id) if decision.approval_id else None,
"resolution_source": decision.resolution_source,
},
)
asyncio.create_task(publish_event(subject, envelope))
@router.get("/", response_model=list[DecisionRead])
async def list_decisions(
request: Request,
response: Response,
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: DecisionStatus | None = None,
decision_type: DecisionType | None = None,
decision_class: DecisionClass | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Decision]:
if workstream_id is not None and workplan_id is None:
await meter_legacy_query_param(
session=session,
request=request,
response=response,
method="GET",
route="/decisions/",
replacement_ref="/decisions/?workplan_id=<workplan_id>",
)
q = select(Decision)
if topic_id:
q = q.where(Decision.topic_id == topic_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Decision.workplan_id == scope_id)
if status:
q = q.where(Decision.status == status)
if decision_type:
q = q.where(Decision.decision_type == decision_type)
if decision_class:
q = q.where(Decision.decision_class == decision_class.value)
q = q.order_by(Decision.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
async def create_decision(
request: Request,
response: Response,
body: DecisionCreate,
session: AsyncSession = Depends(get_session),
) -> Decision:
await meter_legacy_body_from_model(
body,
session=session,
request=request,
response=response,
method="POST",
route="/decisions/",
replacement_ref="POST /decisions/ with workplan_id",
)
_refuse_reserved(body.decision_class)
if body.decision_class in EVIDENCE_CLASSES and body.status == DecisionStatus.resolved:
raise _evidence_required(body.decision_class, "creating it already resolved")
data = body.model_dump()
data["decision_class"] = body.decision_class.value if body.decision_class else None
note = _needs_escalation(body)
if note:
data["escalation_note"] = note
data["status"] = DecisionStatus.escalated
decision = Decision(**data)
session.add(decision)
await session.commit()
await session.refresh(decision)
return decision
@router.get("/{decision_id}", response_model=DecisionRead)
async def get_decision(
decision_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
return decision
@router.patch("/{decision_id}", response_model=DecisionRead)
async def update_decision(
decision_id: uuid.UUID,
body: DecisionUpdate,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
fields = body.model_dump(exclude_unset=True)
current = _class_of(decision)
effective = current
if "decision_class" in fields:
new = body.decision_class
_refuse_reserved(new)
if CLASS_RANK[new] < CLASS_RANK[current] or (new is None and current is not None):
raise HTTPException(
status_code=409,
detail={
"error": "decision_class_lowering",
"message": "a decision class may be raised, never lowered",
"decision_class": current.value if current else None,
},
)
if new != current and decision.status in (
DecisionStatus.resolved, DecisionStatus.superseded
):
raise HTTPException(
status_code=409,
detail={
"error": "decision_class_frozen",
"message": "the class of a resolved or superseded decision cannot change",
},
)
effective = new
fields["decision_class"] = new.value if new else None
if effective in EVIDENCE_CLASSES:
if fields.get("status") == DecisionStatus.resolved:
raise _evidence_required(effective, "setting status=resolved by PATCH")
touched = [f for f in _RESOLVING_PATCH_FIELDS if f in fields]
if touched:
raise _evidence_required(effective, f"setting {', '.join(touched)} by PATCH")
if (
decision.status == DecisionStatus.resolved
and "status" in fields
and fields["status"] not in (DecisionStatus.resolved, DecisionStatus.superseded)
):
raise HTTPException(
status_code=409,
detail={
"error": "evidence_bound",
"message": "an evidence-resolved decision is not reopened by PATCH",
},
)
for field, value in fields.items():
setattr(decision, field, value)
await session.commit()
await session.refresh(decision)
return decision
@router.delete("/{decision_id}", response_model=DecisionRead)
async def supersede_decision(
decision_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
decision.status = DecisionStatus.superseded
await session.commit()
await session.refresh(decision)
return decision
@router.post("/{decision_id}/resolve", response_model=DecisionRead)
async def resolve_decision_action(
decision_id: uuid.UUID,
body: DecisionResolve,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
if decision.status == DecisionStatus.resolved:
raise HTTPException(status_code=409, detail="Decision already resolved")
decision_class = _class_of(decision)
if decision_class in EVIDENCE_CLASSES:
raise _evidence_required(decision_class, "text resolution")
await _require_review_contracts(session, decision)
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.rationale = body.rationale
decision.decided_by = body.decided_by
decision.decided_at = datetime.now(tz=timezone.utc)
decision.resolution_source = ResolutionSource.text.value
await session.commit()
await session.refresh(decision)
event = ProgressEvent(
topic_id=decision.topic_id,
workplan_id=decision.workplan_id,
decision_id=decision.id,
event_type="decision_resolved",
summary=f"Decision resolved: {decision.title}",
author=body.decided_by,
detail={"rationale": body.rationale},
)
session.add(event)
await session.commit()
if body.write_log:
await _write_project_log(decision, body.rationale, body.decided_by, session)
_publish_resolved(decision, body.decided_by, body.rationale)
return decision
def _conflict(error: str, message: str, **extra) -> HTTPException:
return HTTPException(status_code=409, detail={"error": error, "message": message, **extra})
@router.post("/{decision_id}/resolve-evidence", response_model=DecisionRead)
async def resolve_decision_with_evidence(
decision_id: uuid.UUID,
body: DecisionEvidenceWrite,
caller: str = Depends(require_writeback_caller),
session: AsyncSession = Depends(get_session),
) -> Decision:
"""Link a memo to, or resolve, a C1/C2 decision with informed-decision evidence.
STATE-WP-0101-T03, design sections 5.1-5.3. Only the verified write-back
subject reaches this body. Idempotent on (decision_id, memo_id).
"""
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
decision_class = _class_of(decision)
if decision_class not in (DecisionClass.C1, DecisionClass.C2):
raise _conflict(
"evidence_not_applicable",
"evidence write-back applies to C1 and C2 decisions only",
decision_class=decision_class.value if decision_class else None,
)
if decision.status == DecisionStatus.superseded:
raise _conflict("decision_superseded", "the decision was superseded")
if decision.memo_id is not None and decision.memo_id != body.memo_id:
raise _conflict(
"memo_conflict", "the decision is linked to another memo", memo_id=decision.memo_id
)
if decision.memo_id is None:
other = await session.execute(
select(Decision.id).where(Decision.memo_id == body.memo_id, Decision.id != decision.id)
)
if other.scalar_one_or_none() is not None:
raise _conflict("memo_already_linked", "the memo answers another decision")
outcome = body.outcome
if outcome is not None:
if outcome.hub_decision_id != decision.id:
raise HTTPException(status_code=422, detail={"error": "hub_decision_mismatch"})
if outcome.memo_id != body.memo_id:
raise HTTPException(status_code=422, detail={"error": "memo_mismatch"})
approvals = {
a for a in (
body.approval_id,
outcome.approval_id if outcome else None,
decision.approval_id,
) if a is not None
}
if len(approvals) > 1:
raise _conflict(
"approval_conflict",
"approval_id differs from the one linked",
approval_id=str(decision.approval_id) if decision.approval_id else None,
)
approval_id = next(iter(approvals), None)
if decision.status == DecisionStatus.resolved:
# Same memo (checked above): a repeated link or resolve is a no-op.
return decision
if body.mode == "link":
changed = decision.memo_id is None or (
approval_id is not None and decision.approval_id is None
)
decision.memo_id = body.memo_id
decision.approval_id = approval_id
if changed:
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise _conflict("memo_already_linked", "the memo answers another decision")
await session.refresh(decision)
return decision
assert outcome is not None # guaranteed by DecisionEvidenceWrite
if decision_class in APPROVAL_CLASSES and (
approval_id is None or not outcome.approval_entry_id
):
raise HTTPException(
status_code=422,
detail={
"error": "approval_required",
"message": "C2 resolution needs approval_id and approval_entry_id",
},
)
record = stored_outcome(outcome)
await _require_review_contracts(session, decision)
decided_by = (outcome.display_name or f"{outcome.subject.iss}#{outcome.subject.sub}")[:100]
verdict = "accepted" if outcome.verb == "accept" else "declined"
rationale = (
f"{verdict} in informed-decision, memo {body.memo_id} revision {outcome.memo_revision}"
+ (f", option {outcome.option}" if outcome.option else "")
+ (f"\n\n{outcome.note}" if outcome.note else "")
)
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.memo_id = body.memo_id
decision.approval_id = approval_id
decision.rationale = rationale
decision.decided_by = decided_by
decision.decided_at = outcome.decided_at
decision.resolution_source = ResolutionSource.informed_decision.value
decision.resolution_evidence = record
session.add(
ProgressEvent(
topic_id=decision.topic_id,
workplan_id=decision.workplan_id,
decision_id=decision.id,
event_type="decision_resolved",
summary=f"Decision resolved ({verdict}, informed-decision): {decision.title}",
author=decided_by,
detail={
"resolution_source": ResolutionSource.informed_decision.value,
"decision_class": decision_class.value,
"memo_id": body.memo_id,
"memo_revision": outcome.memo_revision,
"verb": outcome.verb,
"subject": record["subject"],
"view_hash": outcome.view_hash,
"presentation_id": outcome.presentation_id,
"approval_id": str(approval_id) if approval_id else None,
"attested_via": caller,
},
)
)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise _conflict("memo_already_linked", "the memo answers another decision")
await session.refresh(decision)
_publish_resolved(decision, decided_by, rationale)
return decision
async def _write_project_log(
decision: Decision, rationale: str, decided_by: str, session: AsyncSession
) -> None:
"""Append a DECISIONS.md entry to the registered project directory for this topic."""
if decision.topic_id is None:
return
rows = await session.execute(
select(ProgressEvent)
.where(ProgressEvent.topic_id == decision.topic_id)
.where(ProgressEvent.event_type == "milestone")
.order_by(ProgressEvent.created_at.desc())
)
project_path: str | None = None
for pe in rows.scalars():
if pe.summary and "Project registered with State Hub:" in pe.summary:
project_path = (pe.detail or {}).get("project_path")
if project_path:
break
if not project_path:
logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id)
return
p = Path(project_path)
if not p.is_dir():
logger.warning("write_log requested but project_path does not exist: %s", project_path)
return
now = datetime.now(tz=timezone.utc)
entry = (
f"\n## {decision.title}\n\n"
f"**Date:** {now.strftime('%Y-%m-%d')} \n"
f"**Decided by:** {decided_by} \n\n"
f"{rationale}\n\n"
f"---\n"
)
log_file = p / "DECISIONS.md"
if log_file.exists():
log_file.write_text(log_file.read_text() + entry)
else:
log_file.write_text(
"# Decision Log\n\n"
"_Auto-generated by the Custodian State Hub._\n"
+ entry
)