227 lines
6.9 KiB
Python
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),
|
|
)
|