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=", ) 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 )