"""Governance document and DAG endpoints.""" import json from fastapi import APIRouter, HTTPException from pydantic import BaseModel from spore_node.db.connection import get_pool from spore_node.governance.parser import parse_governance_doc, GovernanceDoc from spore_node.governance.dag import build_dag router = APIRouter(prefix="/governance", tags=["governance"]) class DocIngest(BaseModel): content: str # Raw markdown with YAML frontmatter class DocResponse(BaseModel): doc_id: str doc_kind: str title: str status: str depends_on: list[str] body: str @router.post("/docs", response_model=DocResponse, status_code=201) async def ingest_doc(data: DocIngest): """Ingest a governance document (markdown with YAML frontmatter).""" try: doc = parse_governance_doc(data.content) except ValueError as e: raise HTTPException(422, str(e)) pool = get_pool() # Upsert doc await pool.execute( """ INSERT INTO governance_docs (doc_id, doc_kind, title, status, body, frontmatter) VALUES ($1, $2, $3, $4, $5, $6::jsonb) ON CONFLICT (doc_id) DO UPDATE SET doc_kind = EXCLUDED.doc_kind, title = EXCLUDED.title, status = EXCLUDED.status, body = EXCLUDED.body, frontmatter = EXCLUDED.frontmatter, updated_at = now() """, doc.doc_id, doc.doc_kind, doc.title, doc.status, doc.body, json.dumps(doc.frontmatter), ) # Replace dependency edges await pool.execute( "DELETE FROM governance_deps WHERE from_doc = $1", doc.doc_id ) for dep in doc.depends_on: # Ensure target exists (placeholder if needed) await pool.execute( """ INSERT INTO governance_docs (doc_id, doc_kind, title, status) VALUES ($1, 'unknown', '', 'placeholder') ON CONFLICT (doc_id) DO NOTHING """, dep, ) await pool.execute( "INSERT INTO governance_deps (from_doc, to_doc) VALUES ($1, $2) ON CONFLICT DO NOTHING", doc.doc_id, dep, ) # Log event await pool.execute( """ INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, 'governance.doc.ingested', $2::jsonb) """, f"governance:{doc.doc_id}", json.dumps({"doc_id": doc.doc_id, "doc_kind": doc.doc_kind}), ) return doc.model_dump() @router.get("/docs", response_model=list[DocResponse]) async def list_docs(doc_kind: str | None = None, status: str | None = None): """List governance documents with optional filters.""" pool = get_pool() query = "SELECT * FROM governance_docs WHERE doc_kind != 'unknown'" params = [] idx = 1 if doc_kind: query += f" AND doc_kind = ${idx}" params.append(doc_kind) idx += 1 if status: query += f" AND status = ${idx}" params.append(status) idx += 1 query += " ORDER BY created_at" rows = await pool.fetch(query, *params) result = [] for row in rows: deps = await pool.fetch( "SELECT to_doc FROM governance_deps WHERE from_doc = $1", row["doc_id"] ) result.append(DocResponse( doc_id=row["doc_id"], doc_kind=row["doc_kind"], title=row["title"], status=row["status"], depends_on=[d["to_doc"] for d in deps], body=row["body"], )) return result @router.get("/docs/{doc_id}", response_model=DocResponse) async def get_doc(doc_id: str): """Get a single governance document.""" pool = get_pool() row = await pool.fetchrow( "SELECT * FROM governance_docs WHERE doc_id = $1", doc_id ) if not row: raise HTTPException(404, f"Document '{doc_id}' not found") deps = await pool.fetch( "SELECT to_doc FROM governance_deps WHERE from_doc = $1", doc_id ) return DocResponse( doc_id=row["doc_id"], doc_kind=row["doc_kind"], title=row["title"], status=row["status"], depends_on=[d["to_doc"] for d in deps], body=row["body"], ) @router.get("/dag") async def get_dag(): """Get the full governance DAG.""" pool = get_pool() rows = await pool.fetch( "SELECT * FROM governance_docs WHERE doc_kind != 'unknown' ORDER BY created_at" ) docs = [] for row in rows: deps = await pool.fetch( "SELECT to_doc FROM governance_deps WHERE from_doc = $1", row["doc_id"] ) docs.append(GovernanceDoc( doc_id=row["doc_id"], doc_kind=row["doc_kind"], title=row["title"], status=row["status"], depends_on=[d["to_doc"] for d in deps], body=row["body"], )) dag = build_dag(docs) result = dag.to_dict() result["errors"] = dag.validate() return result