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.ext.asyncio import AsyncSession from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.decision import Decision, DecisionStatus, DecisionType 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.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 @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, 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) 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", ) data = body.model_dump() 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") for field, value in body.model_dump(exclude_unset=True).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") 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, }, ) 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) 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) 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": body.decided_by, "rationale_snippet": (body.rationale or "")[:240], }, ) asyncio.create_task(publish_event(subject, envelope)) 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 )