"""Decision Events API — G3 Full Decision Memory. Event-stream for each control's compliance lifecycle: assessment → decision → fix → verification → (failure → new cycle) """ import json import logging import uuid from typing import Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from db.session import SessionLocal logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/decision-events", tags=["decision-events"]) class CreateEventRequest(BaseModel): control_uuid: str decision_trace_id: Optional[str] = None tenant_id: Optional[str] = None event_type: str input_state: dict = {} output_state: dict = {} summary: Optional[str] = None actor: Optional[str] = None evidence_ids: list[str] = [] metadata: dict = {} @router.post("") async def create_event(req: CreateEventRequest): """Record a decision event in the compliance lifecycle.""" db = SessionLocal() try: eid = str(uuid.uuid4()) db.execute(text(""" INSERT INTO decision_events (id, decision_trace_id, control_uuid, tenant_id, event_type, input_state, output_state, summary, actor, evidence_ids, metadata) VALUES (CAST(:id AS uuid), CASE WHEN :trace_id IS NOT NULL THEN CAST(:trace_id AS uuid) ELSE NULL END, CAST(:control_uuid AS uuid), CASE WHEN :tenant_id IS NOT NULL THEN CAST(:tenant_id AS uuid) ELSE NULL END, :event_type, CAST(:input AS jsonb), CAST(:output AS jsonb), :summary, :actor, CAST(:evidence AS jsonb), CAST(:meta AS jsonb)) """), { "id": eid, "trace_id": req.decision_trace_id, "control_uuid": req.control_uuid, "tenant_id": req.tenant_id, "event_type": req.event_type, "input": json.dumps(req.input_state), "output": json.dumps(req.output_state), "summary": req.summary, "actor": req.actor, "evidence": json.dumps(req.evidence_ids), "meta": json.dumps(req.metadata), }) db.commit() return {"id": eid, "event_type": req.event_type, "status": "recorded"} finally: db.close() @router.get("") async def list_events( control_uuid: Optional[str] = None, tenant_id: Optional[str] = None, event_type: Optional[str] = None, limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), ): """List decision events with filters.""" db = SessionLocal() try: clauses = [] params: dict = {"limit": limit, "offset": offset} if control_uuid: clauses.append("de.control_uuid = CAST(:cuuid AS uuid)") params["cuuid"] = control_uuid if tenant_id: clauses.append("de.tenant_id = CAST(:tid AS uuid)") params["tid"] = tenant_id if event_type: clauses.append("de.event_type = :etype") params["etype"] = event_type where = "WHERE " + " AND ".join(clauses) if clauses else "" rows = db.execute(text(f""" SELECT de.id, de.control_uuid, cc.control_id, de.event_type, de.summary, de.actor, de.input_state, de.output_state, de.evidence_ids, de.created_at FROM decision_events de LEFT JOIN canonical_controls cc ON cc.id = de.control_uuid {where} ORDER BY de.created_at DESC LIMIT :limit OFFSET :offset """), params).fetchall() return { "total": len(rows), "events": [ { "id": str(r[0]), "control_uuid": str(r[1]), "control_id": r[2], "event_type": r[3], "summary": r[4], "actor": r[5], "input_state": r[6], "output_state": r[7], "evidence_ids": r[8], "created_at": str(r[9]), } for r in rows ], } finally: db.close() @router.get("/stats") async def event_stats(tenant_id: Optional[str] = None): """Lifecycle statistics: cycle times, failure rates.""" db = SessionLocal() try: tf = "" params: dict = {} if tenant_id: tf = "WHERE tenant_id = CAST(:tid AS uuid)" params["tid"] = tenant_id by_type = db.execute(text(f""" SELECT event_type, count(*) FROM decision_events {tf} GROUP BY event_type ORDER BY count(*) DESC """), params).fetchall() total = sum(r[1] for r in by_type) failures = next((r[1] for r in by_type if r[0] == "failure"), 0) verifications = next((r[1] for r in by_type if r[0] == "verification"), 0) return { "total_events": total, "by_event_type": {r[0]: r[1] for r in by_type}, "failure_rate": round(failures / total * 100, 1) if total > 0 else 0, "verification_rate": round(verifications / total * 100, 1) if total > 0 else 0, } finally: db.close() @router.get("/timeline/{control_id}") async def get_timeline(control_id: str): """Full chronological timeline for a control's compliance lifecycle.""" db = SessionLocal() try: # Resolve control_id to UUID ctrl = db.execute(text(""" SELECT id, control_id, title FROM canonical_controls WHERE control_id = :cid """), {"cid": control_id}).fetchone() if not ctrl: raise HTTPException(status_code=404, detail="Control not found") events = db.execute(text(""" SELECT id, event_type, summary, actor, input_state, output_state, evidence_ids, created_at FROM decision_events WHERE control_uuid = CAST(:uuid AS uuid) ORDER BY created_at ASC """), {"uuid": str(ctrl[0])}).fetchall() # Determine current state from latest event current_state = "unknown" if events: last = events[-1] output = last[5] or {} current_state = output.get("status", last[1]) # Calculate avg fix time (assessment → fix_completed) fix_times = [] assessment_at = None for e in events: if e[1] == "assessment": assessment_at = e[7] elif e[1] == "fix_completed" and assessment_at: delta = (e[7] - assessment_at).total_seconds() / 3600 fix_times.append(delta) assessment_at = None return { "control_id": ctrl[1], "control_title": ctrl[2], "current_state": current_state, "total_events": len(events), "time_to_fix_avg_hours": round(sum(fix_times) / len(fix_times), 1) if fix_times else None, "events": [ { "id": str(e[0]), "type": e[1], "summary": e[2], "actor": e[3], "input_state": e[4], "output_state": e[5], "evidence_count": len(e[6]) if e[6] else 0, "at": str(e[7]), } for e in events ], } finally: db.close()