"""Commitment lifecycle endpoints with enforced state machine.""" import json import uuid from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from spore_node.db.connection import get_pool from spore_node.rid_types import SporeCommitment router = APIRouter(prefix="/commitments", tags=["commitments"]) # State machine transitions VALID_TRANSITIONS = { "proposed": ("verified", "cancelled"), "verified": ("active", "cancelled"), "active": ("evidence_linked", "disputed", "cancelled"), "evidence_linked": ("redeemed", "disputed"), "redeemed": (), "disputed": ("resolved", "cancelled"), "resolved": ("active", "redeemed"), "cancelled": (), } class CommitmentCreate(BaseModel): title: str description: str = "" proposer_rid: str acceptor_rid: str | None = None settlement_type: str = "attestation" terms: dict = {} metadata: dict = {} class CommitmentResponse(BaseModel): id: str rid: str title: str description: str state: str proposer_rid: str acceptor_rid: str | None settlement_type: str terms: dict metadata: dict class EvidenceLinkRequest(BaseModel): evidence_id: str @router.post("", response_model=CommitmentResponse, status_code=201) async def create_commitment(data: CommitmentCreate): pool = get_pool() cid = str(uuid.uuid4()) rid = str(SporeCommitment(cid)) row = await pool.fetchrow( """ INSERT INTO commitments (id, rid, title, description, proposer_rid, acceptor_rid, settlement_type, terms, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb) RETURNING * """, uuid.UUID(cid), rid, data.title, data.description, data.proposer_rid, data.acceptor_rid, data.settlement_type, json.dumps(data.terms), json.dumps(data.metadata), ) await _log_event(pool, rid, "commitment.created", {"title": data.title, "proposer": data.proposer_rid}) return _row_dict(row) @router.get("", response_model=list[CommitmentResponse]) async def list_commitments( state: str | None = None, proposer_rid: str | None = None, acceptor_rid: str | None = None, limit: int = Query(default=50, le=200), offset: int = 0, ): pool = get_pool() conditions, params = [], [] idx = 1 if state: conditions.append(f"state = ${idx}") params.append(state) idx += 1 if proposer_rid: conditions.append(f"proposer_rid = ${idx}") params.append(proposer_rid) idx += 1 if acceptor_rid: conditions.append(f"acceptor_rid = ${idx}") params.append(acceptor_rid) idx += 1 where = "WHERE " + " AND ".join(conditions) if conditions else "" rows = await pool.fetch( f"SELECT * FROM commitments {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}", *params, limit, offset, ) return [_row_dict(r) for r in rows] @router.get("/{commitment_id}", response_model=CommitmentResponse) async def get_commitment(commitment_id: str): pool = get_pool() row = await pool.fetchrow( "SELECT * FROM commitments WHERE id = $1", uuid.UUID(commitment_id) ) if not row: raise HTTPException(404, "Commitment not found") return _row_dict(row) @router.patch("/{commitment_id}/state") async def transition_state(commitment_id: str, new_state: str): pool = get_pool() row = await pool.fetchrow( "SELECT state, rid FROM commitments WHERE id = $1", uuid.UUID(commitment_id) ) if not row: raise HTTPException(404, "Commitment not found") current = row["state"] valid = VALID_TRANSITIONS.get(current, ()) if new_state not in valid: raise HTTPException( 422, f"Cannot transition from '{current}' to '{new_state}'. Valid: {valid}", ) await pool.execute( "UPDATE commitments SET state = $1, updated_at = now() WHERE id = $2", new_state, uuid.UUID(commitment_id), ) await _log_event(pool, row["rid"], "commitment.state_changed", {"from": current, "to": new_state}) return {"state": new_state, "previous": current} @router.post("/{commitment_id}/evidence", status_code=201) async def link_evidence(commitment_id: str, data: EvidenceLinkRequest): pool = get_pool() # Verify commitment exists and is in valid state commitment = await pool.fetchrow( "SELECT state, rid FROM commitments WHERE id = $1", uuid.UUID(commitment_id) ) if not commitment: raise HTTPException(404, "Commitment not found") if commitment["state"] not in ("active", "evidence_linked"): raise HTTPException(422, f"Cannot link evidence in state '{commitment['state']}'") # Verify evidence exists evidence = await pool.fetchrow( "SELECT id FROM evidence WHERE id = $1", uuid.UUID(data.evidence_id) ) if not evidence: raise HTTPException(404, "Evidence not found") try: await pool.execute( "INSERT INTO commitment_evidence (commitment_id, evidence_id) VALUES ($1, $2)", uuid.UUID(commitment_id), uuid.UUID(data.evidence_id), ) except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): raise HTTPException(409, "Evidence already linked") raise # Auto-transition to evidence_linked if currently active if commitment["state"] == "active": await pool.execute( "UPDATE commitments SET state = 'evidence_linked', updated_at = now() WHERE id = $1", uuid.UUID(commitment_id), ) await _log_event(pool, commitment["rid"], "commitment.evidence_linked", {"evidence_id": data.evidence_id}) return {"linked": True, "commitment_id": commitment_id, "evidence_id": data.evidence_id} @router.get("/{commitment_id}/evidence") async def list_commitment_evidence(commitment_id: str): pool = get_pool() rows = await pool.fetch( """ SELECT e.id, e.rid, e.relation, e.body, e.provenance, ce.linked_at FROM commitment_evidence ce JOIN evidence e ON e.id = ce.evidence_id WHERE ce.commitment_id = $1 ORDER BY ce.linked_at """, uuid.UUID(commitment_id), ) result = [] for r in rows: d = dict(r) d["id"] = str(d["id"]) if isinstance(d.get("provenance"), str): d["provenance"] = json.loads(d["provenance"]) result.append(d) return result # --- Helpers --- def _row_dict(row) -> dict: d = dict(row) d["id"] = str(d["id"]) for k in ("terms", "metadata"): if isinstance(d.get(k), str): d[k] = json.loads(d[k]) return d async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict): await pool.execute( "INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)", entity_rid, event_kind, json.dumps(payload), )