diff --git a/node/spore_node/api/main.py b/node/spore_node/api/main.py index 22fcb27..78a62b1 100644 --- a/node/spore_node/api/main.py +++ b/node/spore_node/api/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI from spore_node.config import SporeConfig from spore_node.db.connection import init_pool, close_pool -from spore_node.api.routers import health, holons, governance +from spore_node.api.routers import health, holons, governance, claims, intents, commitments log = logging.getLogger(__name__) @@ -20,6 +20,9 @@ def mount_routers(app: FastAPI) -> None: app.include_router(health.router) app.include_router(holons.router) app.include_router(governance.router) + app.include_router(claims.router) + app.include_router(intents.router) + app.include_router(commitments.router) def create_standalone_app(cfg: SporeConfig | None = None) -> FastAPI: diff --git a/node/spore_node/api/routers/claims.py b/node/spore_node/api/routers/claims.py new file mode 100644 index 0000000..39afbf3 --- /dev/null +++ b/node/spore_node/api/routers/claims.py @@ -0,0 +1,314 @@ +"""Claims, Evidence, and Attestations endpoints.""" + +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 SporeClaim, SporeEvidence, SporeAttestation + +router = APIRouter(prefix="/claims", tags=["claims"]) + +# --- Models --- + +VALID_STATUSES = ("proposed", "supported", "challenged", "superseded") +VALID_TRANSITIONS = { + "proposed": ("supported", "challenged", "superseded"), + "supported": ("challenged", "superseded"), + "challenged": ("supported", "superseded"), + "superseded": (), +} + + +class ClaimCreate(BaseModel): + content: str + proposer_rid: str + confidence: float = 0.5 + anchor_type: str = "assertion" + metadata: dict = {} + + +class ClaimResponse(BaseModel): + id: str + rid: str + proposer_rid: str + content: str + status: str + confidence: float + anchor_type: str + metadata: dict + + +class EvidenceCreate(BaseModel): + relation: str # supports | challenges + body: str + provenance: dict = {} + + +class EvidenceResponse(BaseModel): + id: str + rid: str + claim_id: str + relation: str + body: str + provenance: dict + + +class AttestationCreate(BaseModel): + attester_rid: str + verdict: str # endorse | dispute | abstain + strength: float = 1.0 + reasoning: str = "" + + +class AttestationResponse(BaseModel): + id: str + rid: str + claim_id: str + attester_rid: str + verdict: str + strength: float + reasoning: str + + +class ClaimStrengthResponse(BaseModel): + claim_id: str + claim_rid: str + status: str + confidence: float + endorse_count: int + dispute_count: int + abstain_count: int + supporting_evidence: int + challenging_evidence: int + net_sentiment: float + + +# --- Claims --- + +@router.post("", response_model=ClaimResponse, status_code=201) +async def create_claim(data: ClaimCreate): + pool = get_pool() + claim_id = str(uuid.uuid4()) + rid = str(SporeClaim(claim_id)) + + row = await pool.fetchrow( + """ + INSERT INTO claims (id, rid, proposer_rid, content, confidence, anchor_type, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, rid, proposer_rid, content, status, confidence, anchor_type, metadata + """, + uuid.UUID(claim_id), rid, data.proposer_rid, data.content, + data.confidence, data.anchor_type, json.dumps(data.metadata), + ) + + await _log_event(pool, rid, "claim.created", {"content": data.content[:200]}) + return _row_dict(row) + + +@router.get("", response_model=list[ClaimResponse]) +async def list_claims( + status: str | None = None, + proposer_rid: str | None = None, + limit: int = Query(default=50, le=200), + offset: int = 0, +): + pool = get_pool() + conditions, params = [], [] + idx = 1 + + if status: + conditions.append(f"status = ${idx}") + params.append(status) + idx += 1 + if proposer_rid: + conditions.append(f"proposer_rid = ${idx}") + params.append(proposer_rid) + idx += 1 + + where = "WHERE " + " AND ".join(conditions) if conditions else "" + rows = await pool.fetch( + f"SELECT * FROM claims {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}", + *params, limit, offset, + ) + return [_row_dict(r) for r in rows] + + +@router.get("/{claim_id}", response_model=ClaimResponse) +async def get_claim(claim_id: str): + pool = get_pool() + row = await pool.fetchrow("SELECT * FROM claims WHERE id = $1", uuid.UUID(claim_id)) + if not row: + raise HTTPException(404, "Claim not found") + return _row_dict(row) + + +@router.patch("/{claim_id}/status") +async def transition_claim_status(claim_id: str, new_status: str): + pool = get_pool() + row = await pool.fetchrow("SELECT status, rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) + if not row: + raise HTTPException(404, "Claim not found") + + current = row["status"] + if new_status not in VALID_TRANSITIONS.get(current, ()): + raise HTTPException( + 422, + f"Cannot transition from '{current}' to '{new_status}'. " + f"Valid: {VALID_TRANSITIONS.get(current, ())}", + ) + + await pool.execute( + "UPDATE claims SET status = $1, updated_at = now() WHERE id = $2", + new_status, uuid.UUID(claim_id), + ) + await _log_event(pool, row["rid"], "claim.status_changed", + {"from": current, "to": new_status}) + return {"status": new_status, "previous": current} + + +@router.get("/{claim_id}/strength", response_model=ClaimStrengthResponse) +async def get_claim_strength(claim_id: str): + pool = get_pool() + # Refresh materialized view + await pool.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY claim_strength") + row = await pool.fetchrow( + "SELECT * FROM claim_strength WHERE claim_id = $1", uuid.UUID(claim_id) + ) + if not row: + raise HTTPException(404, "Claim not found") + d = dict(row) + d["claim_id"] = str(d["claim_id"]) + return d + + +# --- Evidence --- + +@router.post("/{claim_id}/evidence", response_model=EvidenceResponse, status_code=201) +async def add_evidence(claim_id: str, data: EvidenceCreate): + if data.relation not in ("supports", "challenges"): + raise HTTPException(422, "relation must be 'supports' or 'challenges'") + + pool = get_pool() + # Verify claim exists + claim = await pool.fetchrow("SELECT rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) + if not claim: + raise HTTPException(404, "Claim not found") + + eid = str(uuid.uuid4()) + rid = str(SporeEvidence(eid)) + + row = await pool.fetchrow( + """ + INSERT INTO evidence (id, rid, claim_id, relation, body, provenance) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + RETURNING id, rid, claim_id, relation, body, provenance + """, + uuid.UUID(eid), rid, uuid.UUID(claim_id), data.relation, + data.body, json.dumps(data.provenance), + ) + + await _log_event(pool, claim["rid"], f"evidence.{data.relation}", + {"evidence_rid": rid}) + return _evidence_dict(row) + + +@router.get("/{claim_id}/evidence", response_model=list[EvidenceResponse]) +async def list_evidence(claim_id: str, relation: str | None = None): + pool = get_pool() + if relation: + rows = await pool.fetch( + "SELECT * FROM evidence WHERE claim_id = $1 AND relation = $2 ORDER BY created_at", + uuid.UUID(claim_id), relation, + ) + else: + rows = await pool.fetch( + "SELECT * FROM evidence WHERE claim_id = $1 ORDER BY created_at", + uuid.UUID(claim_id), + ) + return [_evidence_dict(r) for r in rows] + + +# --- Attestations --- + +@router.post("/{claim_id}/attestations", response_model=AttestationResponse, status_code=201) +async def attest_claim(claim_id: str, data: AttestationCreate): + if data.verdict not in ("endorse", "dispute", "abstain"): + raise HTTPException(422, "verdict must be 'endorse', 'dispute', or 'abstain'") + + pool = get_pool() + claim = await pool.fetchrow("SELECT rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) + if not claim: + raise HTTPException(404, "Claim not found") + + aid = str(uuid.uuid4()) + rid = str(SporeAttestation(aid)) + + try: + row = await pool.fetchrow( + """ + INSERT INTO attestations (id, rid, claim_id, attester_rid, verdict, strength, reasoning) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, rid, claim_id, attester_rid, verdict, strength, reasoning + """, + uuid.UUID(aid), rid, uuid.UUID(claim_id), data.attester_rid, + data.verdict, data.strength, data.reasoning, + ) + except Exception as e: + if "unique" in str(e).lower(): + raise HTTPException(409, f"Attester '{data.attester_rid}' already attested this claim") + raise + + await _log_event(pool, claim["rid"], f"attestation.{data.verdict}", + {"attester": data.attester_rid, "strength": data.strength}) + return _attestation_dict(row) + + +@router.get("/{claim_id}/attestations", response_model=list[AttestationResponse]) +async def list_attestations(claim_id: str, verdict: str | None = None): + pool = get_pool() + if verdict: + rows = await pool.fetch( + "SELECT * FROM attestations WHERE claim_id = $1 AND verdict = $2 ORDER BY created_at", + uuid.UUID(claim_id), verdict, + ) + else: + rows = await pool.fetch( + "SELECT * FROM attestations WHERE claim_id = $1 ORDER BY created_at", + uuid.UUID(claim_id), + ) + return [_attestation_dict(r) for r in rows] + + +# --- Helpers --- + +def _row_dict(row) -> dict: + d = dict(row) + d["id"] = str(d["id"]) + if isinstance(d.get("metadata"), str): + d["metadata"] = json.loads(d["metadata"]) + return d + + +def _evidence_dict(row) -> dict: + d = dict(row) + d["id"] = str(d["id"]) + d["claim_id"] = str(d["claim_id"]) + if isinstance(d.get("provenance"), str): + d["provenance"] = json.loads(d["provenance"]) + return d + + +def _attestation_dict(row) -> dict: + d = dict(row) + d["id"] = str(d["id"]) + d["claim_id"] = str(d["claim_id"]) + 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), + ) diff --git a/node/spore_node/api/routers/commitments.py b/node/spore_node/api/routers/commitments.py new file mode 100644 index 0000000..44dec89 --- /dev/null +++ b/node/spore_node/api/routers/commitments.py @@ -0,0 +1,226 @@ +"""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), + ) diff --git a/node/spore_node/api/routers/intents.py b/node/spore_node/api/routers/intents.py new file mode 100644 index 0000000..bb6e1dc --- /dev/null +++ b/node/spore_node/api/routers/intents.py @@ -0,0 +1,261 @@ +"""Intent publishing, matching, and lifecycle endpoints.""" + +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 SporeIntent + +router = APIRouter(prefix="/intents", tags=["intents"]) + +VALID_STATES = ("open", "matched", "committed", "expired", "withdrawn") +VALID_TRANSITIONS = { + "open": ("matched", "expired", "withdrawn"), + "matched": ("committed", "open", "withdrawn"), + "committed": ("expired",), + "expired": (), + "withdrawn": (), +} +COMPLEMENTARY_TYPES = { + "need": "offer", + "offer": "need", + "possibility": "possibility", +} + + +class IntentCreate(BaseModel): + publisher_rid: str + title: str + description: str = "" + intent_type: str # need | offer | possibility + capacity: dict = {} + timing: dict = {} + governance_fit: list[str] = [] + metadata: dict = {} + + +class IntentResponse(BaseModel): + id: str + rid: str + publisher_rid: str + title: str + description: str + intent_type: str + capacity: dict + timing: dict + governance_fit: list[str] + state: str + metadata: dict + + +class MatchResponse(BaseModel): + intent_a_id: str + intent_b_id: str + similarity: float + match_details: dict + + +@router.post("", response_model=IntentResponse, status_code=201) +async def publish_intent(data: IntentCreate): + if data.intent_type not in ("need", "offer", "possibility"): + raise HTTPException(422, "intent_type must be 'need', 'offer', or 'possibility'") + + pool = get_pool() + iid = str(uuid.uuid4()) + rid = str(SporeIntent(iid)) + + row = await pool.fetchrow( + """ + INSERT INTO intents (id, rid, publisher_rid, title, description, intent_type, + capacity, timing, governance_fit, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10::jsonb) + RETURNING * + """, + uuid.UUID(iid), rid, data.publisher_rid, data.title, data.description, + data.intent_type, json.dumps(data.capacity), json.dumps(data.timing), + data.governance_fit, json.dumps(data.metadata), + ) + + await _log_event(pool, rid, "intent.published", + {"type": data.intent_type, "title": data.title}) + + # Trigger matching (inline for now, ARQ worker in production) + await _compute_matches(pool, uuid.UUID(iid), data.intent_type) + + return _row_dict(row) + + +@router.get("", response_model=list[IntentResponse]) +async def list_intents( + intent_type: str | None = None, + state: str | None = None, + publisher_rid: str | None = None, + limit: int = Query(default=50, le=200), + offset: int = 0, +): + pool = get_pool() + conditions, params = [], [] + idx = 1 + + if intent_type: + conditions.append(f"intent_type = ${idx}") + params.append(intent_type) + idx += 1 + if state: + conditions.append(f"state = ${idx}") + params.append(state) + idx += 1 + if publisher_rid: + conditions.append(f"publisher_rid = ${idx}") + params.append(publisher_rid) + idx += 1 + + where = "WHERE " + " AND ".join(conditions) if conditions else "" + rows = await pool.fetch( + f"SELECT * FROM intents {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}", + *params, limit, offset, + ) + return [_row_dict(r) for r in rows] + + +@router.get("/{intent_id}", response_model=IntentResponse) +async def get_intent(intent_id: str): + pool = get_pool() + row = await pool.fetchrow("SELECT * FROM intents WHERE id = $1", uuid.UUID(intent_id)) + if not row: + raise HTTPException(404, "Intent not found") + return _row_dict(row) + + +@router.patch("/{intent_id}/state") +async def transition_intent_state(intent_id: str, new_state: str): + pool = get_pool() + row = await pool.fetchrow("SELECT state, rid FROM intents WHERE id = $1", uuid.UUID(intent_id)) + if not row: + raise HTTPException(404, "Intent not found") + + current = row["state"] + if new_state not in VALID_TRANSITIONS.get(current, ()): + raise HTTPException( + 422, + f"Cannot transition from '{current}' to '{new_state}'. " + f"Valid: {VALID_TRANSITIONS.get(current, ())}", + ) + + await pool.execute( + "UPDATE intents SET state = $1, updated_at = now() WHERE id = $2", + new_state, uuid.UUID(intent_id), + ) + await _log_event(pool, row["rid"], "intent.state_changed", + {"from": current, "to": new_state}) + return {"state": new_state, "previous": current} + + +@router.get("/{intent_id}/matches", response_model=list[MatchResponse]) +async def get_matches(intent_id: str, min_similarity: float = 0.0): + pool = get_pool() + uid = uuid.UUID(intent_id) + rows = await pool.fetch( + """ + SELECT * FROM intent_matches + WHERE (intent_a_id = $1 OR intent_b_id = $1) AND similarity >= $2 + ORDER BY similarity DESC + """, + uid, min_similarity, + ) + return [_match_dict(r) for r in rows] + + +# --- Matching logic --- + +async def _compute_matches(pool, intent_id: uuid.UUID, intent_type: str): + """Compute matches between this intent and complementary open intents. + + Scoring: governance_fit overlap (primary), with text similarity as tiebreaker. + Embedding-based similarity added when embeddings are available. + """ + complement = COMPLEMENTARY_TYPES.get(intent_type, "") + if not complement: + return + + intent = await pool.fetchrow("SELECT * FROM intents WHERE id = $1", intent_id) + if not intent: + return + + # Find complementary open intents + candidates = await pool.fetch( + """ + SELECT * FROM intents + WHERE intent_type = $1 AND state = 'open' AND id != $2 + """, + complement, intent_id, + ) + + my_gov = set(intent["governance_fit"] or []) + + for cand in candidates: + cand_gov = set(cand["governance_fit"] or []) + + # Governance fit overlap + if my_gov and cand_gov: + gov_score = len(my_gov & cand_gov) / len(my_gov | cand_gov) + elif not my_gov and not cand_gov: + gov_score = 0.5 # neutral + else: + gov_score = 0.1 + + # Simple text overlap as proxy until embeddings available + my_words = set(intent["title"].lower().split() + intent["description"].lower().split()) + cand_words = set(cand["title"].lower().split() + cand["description"].lower().split()) + text_score = len(my_words & cand_words) / max(len(my_words | cand_words), 1) + + similarity = 0.6 * gov_score + 0.4 * text_score + + if similarity < 0.05: + continue + + # Ensure consistent ordering for unique constraint + a_id, b_id = sorted([intent_id, cand["id"]], key=str) + + await pool.execute( + """ + INSERT INTO intent_matches (intent_a_id, intent_b_id, similarity, match_details) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (intent_a_id, intent_b_id) + DO UPDATE SET similarity = $3, match_details = $4::jsonb + """, + a_id, b_id, round(similarity, 4), + json.dumps({"gov_score": round(gov_score, 4), "text_score": round(text_score, 4)}), + ) + + +# --- Helpers --- + +def _row_dict(row) -> dict: + d = dict(row) + d["id"] = str(d["id"]) + for k in ("capacity", "timing", "metadata"): + if isinstance(d.get(k), str): + d[k] = json.loads(d[k]) + if d.get("governance_fit") is None: + d["governance_fit"] = [] + return d + + +def _match_dict(row) -> dict: + d = dict(row) + d.pop("id", None) + d["intent_a_id"] = str(d["intent_a_id"]) + d["intent_b_id"] = str(d["intent_b_id"]) + if isinstance(d.get("match_details"), str): + d["match_details"] = json.loads(d["match_details"]) + 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), + ) diff --git a/node/spore_node/db/migrations/002_primitives.sql b/node/spore_node/db/migrations/002_primitives.sql new file mode 100644 index 0000000..50c8348 --- /dev/null +++ b/node/spore_node/db/migrations/002_primitives.sql @@ -0,0 +1,166 @@ +-- Spore Agent Commons — Core Primitives +-- Claims, Evidence, Attestations, Intents, Commitments + +-- ============================================================ +-- Claims (knowledge claims in the commons) +-- ============================================================ +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rid TEXT UNIQUE NOT NULL, + proposer_rid TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'proposed' + CHECK (status IN ('proposed', 'supported', 'challenged', 'superseded')), + confidence FLOAT DEFAULT 0.5, + anchor_type TEXT DEFAULT 'assertion', + embedding vector(1024), + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_claims_proposer ON claims (proposer_rid); +CREATE INDEX IF NOT EXISTS idx_claims_status ON claims (status); +CREATE INDEX IF NOT EXISTS idx_claims_metadata ON claims USING GIN (metadata); + +-- ============================================================ +-- Evidence (supports or challenges a claim) +-- ============================================================ +CREATE TABLE IF NOT EXISTS evidence ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rid TEXT UNIQUE NOT NULL, + claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + relation TEXT NOT NULL CHECK (relation IN ('supports', 'challenges')), + body TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}', + embedding vector(1024), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_evidence_claim ON evidence (claim_id); +CREATE INDEX IF NOT EXISTS idx_evidence_relation ON evidence (relation); + +-- ============================================================ +-- Attestations (endorse/dispute/abstain on a claim) +-- ============================================================ +CREATE TABLE IF NOT EXISTS attestations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rid TEXT UNIQUE NOT NULL, + claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + attester_rid TEXT NOT NULL, + verdict TEXT NOT NULL CHECK (verdict IN ('endorse', 'dispute', 'abstain')), + strength FLOAT NOT NULL DEFAULT 1.0, + reasoning TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (claim_id, attester_rid) +); + +CREATE INDEX IF NOT EXISTS idx_attestations_claim ON attestations (claim_id); +CREATE INDEX IF NOT EXISTS idx_attestations_attester ON attestations (attester_rid); + +-- ============================================================ +-- Claim strength (materialized view) +-- ============================================================ +CREATE MATERIALIZED VIEW IF NOT EXISTS claim_strength AS +SELECT + c.id AS claim_id, + c.rid AS claim_rid, + c.status, + c.confidence, + COUNT(DISTINCT CASE WHEN a.verdict = 'endorse' THEN a.id END) AS endorse_count, + COUNT(DISTINCT CASE WHEN a.verdict = 'dispute' THEN a.id END) AS dispute_count, + COUNT(DISTINCT CASE WHEN a.verdict = 'abstain' THEN a.id END) AS abstain_count, + COUNT(DISTINCT CASE WHEN e.relation = 'supports' THEN e.id END) AS supporting_evidence, + COUNT(DISTINCT CASE WHEN e.relation = 'challenges' THEN e.id END) AS challenging_evidence, + COALESCE( + (COUNT(DISTINCT CASE WHEN a.verdict = 'endorse' THEN a.id END)::float - + COUNT(DISTINCT CASE WHEN a.verdict = 'dispute' THEN a.id END)::float) / + NULLIF(COUNT(DISTINCT a.id)::float, 0), + 0 + ) AS net_sentiment, + MAX(GREATEST( + COALESCE(MAX(a.created_at), c.created_at), + COALESCE(MAX(e.created_at), c.created_at) + )) AS last_activity +FROM claims c +LEFT JOIN attestations a ON a.claim_id = c.id +LEFT JOIN evidence e ON e.claim_id = c.id +GROUP BY c.id, c.rid, c.status, c.confidence; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_strength_id ON claim_strength (claim_id); + +-- ============================================================ +-- Intents (need/offer/possibility) +-- ============================================================ +CREATE TABLE IF NOT EXISTS intents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rid TEXT UNIQUE NOT NULL, + publisher_rid TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + intent_type TEXT NOT NULL CHECK (intent_type IN ('need', 'offer', 'possibility')), + capacity JSONB NOT NULL DEFAULT '{}', + timing JSONB NOT NULL DEFAULT '{}', + governance_fit TEXT[] DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'open' + CHECK (state IN ('open', 'matched', 'committed', 'expired', 'withdrawn')), + embedding vector(1024), + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_intents_publisher ON intents (publisher_rid); +CREATE INDEX IF NOT EXISTS idx_intents_type ON intents (intent_type); +CREATE INDEX IF NOT EXISTS idx_intents_state ON intents (state); + +-- ============================================================ +-- Intent matches (computed similarity pairs) +-- ============================================================ +CREATE TABLE IF NOT EXISTS intent_matches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + intent_a_id UUID NOT NULL REFERENCES intents(id) ON DELETE CASCADE, + intent_b_id UUID NOT NULL REFERENCES intents(id) ON DELETE CASCADE, + similarity FLOAT NOT NULL, + match_details JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (intent_a_id, intent_b_id), + CHECK (intent_a_id < intent_b_id) +); + +CREATE INDEX IF NOT EXISTS idx_intent_matches_a ON intent_matches (intent_a_id); +CREATE INDEX IF NOT EXISTS idx_intent_matches_b ON intent_matches (intent_b_id); + +-- ============================================================ +-- Commitments (lifecycle state machine) +-- ============================================================ +CREATE TABLE IF NOT EXISTS commitments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rid TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'proposed' + CHECK (state IN ('proposed', 'verified', 'active', 'evidence_linked', + 'redeemed', 'disputed', 'resolved', 'cancelled')), + proposer_rid TEXT NOT NULL, + acceptor_rid TEXT, + settlement_type TEXT DEFAULT 'attestation', + terms JSONB NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_commitments_state ON commitments (state); +CREATE INDEX IF NOT EXISTS idx_commitments_proposer ON commitments (proposer_rid); +CREATE INDEX IF NOT EXISTS idx_commitments_acceptor ON commitments (acceptor_rid); + +-- ============================================================ +-- Commitment-Evidence links +-- ============================================================ +CREATE TABLE IF NOT EXISTS commitment_evidence ( + commitment_id UUID NOT NULL REFERENCES commitments(id) ON DELETE CASCADE, + evidence_id UUID NOT NULL REFERENCES evidence(id) ON DELETE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (commitment_id, evidence_id) +);