spore-commons/node/spore_node/api/routers/commitments.py

227 lines
6.9 KiB
Python

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