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
522 lines
19 KiB
Python
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
|
|
)
|