ceiba.to: Protocol Specification
roles_supported [operator/contributor/entity-owner], linked_entities[] (entity-aware federation with OAuth-like scopes + TTL), api_keys_emit{} (OAuth-style authz for federated reads), interconnections[].scopes + .api_key_required, and payment_rails +6 (spei/oxxo/pix/sepa/ach/bank_transfer = 16 total). v0.4 manifests remain valid — any v0.4-compliant consumer still parses v0.5 documents. See changelog v0.5 and the migration guide.
1. Technical Summary
ceiba.to is a portable community-data protocol. The fundamental unit is the centipede — a signed JSON object with typed connectors that can reside on multiple nodes simultaneously. This document specifies the data format, identity stack, trust model, karma economy, and replication protocol.
1.1 Protocol Stack
| Layer | Technology | Role | Status |
|---|---|---|---|
| Transport | HTTPS + REST/JSON-LD | Public entity API | [IMPL] |
| Semantics | Schema.org + ceiba: namespace | Data vocabulary | [IMPL] |
| AI agents | MCP (anthropic-mcp-python) | Agent queries | [IMPL] |
| Micropayments | x402 (HTTP 402 + USDC) | API-native monetization | [IMPL] |
| Identity | WebAuthn Level 2 (Passkeys) | Passwordless auth | [SPEC] |
| Identity Step-0 | SMS OTP (Twilio) | Claim bootstrap | [SPEC] |
| Recovery | Social attestation (Shamir-inspired) | Social recovery | [SPEC] |
| Entities | ceiba-manifest (JSON + Ed25519 signatures) | Portable centipede format | [IMPL] |
| Scaffolding | koa-gen CLI | New-ceiba generation | [IMPL] |
1.2 Storage
The protocol is backend-agnostic. The reference implementation supports three modes:
- SQLite (local) — default mode for independent nodes and development. One
.dbfile per ceiba. WAL mode for concurrent writes. In production at La Cartelera (lacartelera.app). - Supabase (hosted) — managed PostgreSQL with per-entity RLS. For ceibas with multiple operators or high-availability requirements.
- IPFS (archival) [SPEC] — for immutable snapshots of centipede versions. The CID would be stored in
replicas[].ipfs_cid. Does not replace the primary node. Not implemented.
1.3 Identity — Overview
Identity in ceiba.to has three progressive layers:
// Layer 0: claim bootstrapped by SMS OTP
entity.owner = "did:phone:+521234567890"
// Layer 1: Passkey registration (WebAuthn Level 2)
entity.auth = { credential_id: "base64url...", rpId: "ceiba.to" }
// Layer 2: social recovery attestation (N-of-M)
recovery.attestations = [ { attester: "did:ceiba:...", sig: "..." }, ... ]
1.4 Reference Deployments
| Ceiba | Domain | Entities | Backend | Extra stack |
|---|---|---|---|---|
| La Cartelera | lacartelera.app | 158 indexed comedians, 183 venues | SQLite | MCP server |
| Sociales | sociales.lat | LATAM dance events | Supabase | iCal source |
2. The Centipede Model — Full Specification
A centipede is the atomic unit of the protocol: a self-contained, portable JSON object representing a real-world entity. Every field has source attribution. Every relationship is expressed as a typed directed connector. Every version is monotonically ordered and signable.
2.1 Full Schema — JSON with JSON-LD
{
// ── IDENTIFICATION ──────────────────────────────────────────
"@context": [
"https://schema.org",
"https://ceiba.to/context/v1.json"
],
"ceiba:entity": "comedian/ana-torres",
// Format: {type}/{slug} — both lowercase, kebab-case
// Namespaced: ceiba/comedia/comedian/ana-torres in a multi-tenant ceiba
// ── BODY ────────────────────────────────────────────────────
"body": {
"@type": "Person", // Schema.org type
"name": "Ana Torres",
"description": "Stand-up comedian, CDMX.",
"city": "Mexico City",
"birth_year": 1992,
"phone_e164": "+521234567890", // private if entity.owner marks it
"social_handles": {
"instagram": "@anatorres_comedy",
"tiktok": "@anatorres"
},
"genres": ["stand-up", "improv"],
"years_active": 6
},
// ── CONNECTORS ──────────────────────────────────────────────
"connectors": {
"outbound": [
{
"type": "performs_at",
"target": "venue/foro-normandie",
"weight": 0.9, // relative frequency 0-1
"first_seen": "2024-03-10",
"last_seen": "2026-03-28"
},
{
"type": "member_of",
"target": "ceiba/comedia-cdmx"
}
],
"inbound": [
{
"type": "has_performer",
"source": "venue/foro-normandie"
},
{
"type": "has_member",
"source": "ceiba/comedia-cdmx"
}
]
},
// ── REPLICAS ────────────────────────────────────────────────
"replicas": [
{
"node": "lacartelera.app",
"last_sync": "2026-04-01T14:00:00Z",
"ipfs_cid": "bafybeig..." // CID of the archived version
},
{
"node": "sociales.lat",
"last_sync": "2026-04-01T13:55:00Z"
}
],
// ── VERSIONING ──────────────────────────────────────────────
"version": 47, // monotonic integer, incremented on each edit
"updated_at": "2026-04-01T14:00:00Z", // ISO 8601 UTC
// ── IDENTITY AND SIGNATURES ─────────────────────────────────
"owner": "did:ceiba:comedian/ana-torres",
"signatures": [
{
"signer_did": "did:ceiba:comedian/ana-torres",
"role": "owner", // owner | contributor | node
"signature": "base64url(Ed25519-sig-over-canonical-JSON)",
"timestamp": "2026-04-01T14:00:00Z",
"fields_covered": ["body.name", "body.genres"]
// null = the whole entity in the current version
},
{
"signer_did": "did:ceiba:user/carlos-contrib",
"role": "contributor",
"signature": "base64url(...)",
"timestamp": "2026-03-20T09:30:00Z",
"fields_covered": ["body.years_active"]
}
],
// Precedence rule: role==owner wins over any other signature
// in case of conflict on the same field.
// ── PER-FIELD PROVENANCE ────────────────────────────────────
"sources": [
{
"field": "body.name",
"source_url": "https://www.instagram.com/anatorres_comedy",
"fetched_at": "2026-01-15T10:00:00Z",
"confidence": "high"
// confidence: "high" | "medium" | "low" | "inferred"
// "inferred" = deduced by matching, not directly observed
},
{
"field": "body.birth_year",
"source_url": null,
"fetched_at": "2026-02-01T00:00:00Z",
"confidence": "inferred"
},
{
"field": "connectors.outbound[performs_at:venue/foro-normandie]",
"source_url": "https://lacartelera.app/eventos/2026-03-28",
"fetched_at": "2026-03-28T22:00:00Z",
"confidence": "high"
}
],
// ── KARMA IN ESCROW ─────────────────────────────────────────
"karma_escrow": {
"pending": [
{
"contribution_id": "ctr_0x4f2a...",
"contributor_did": "did:ceiba:user/carlos-contrib",
"field": "body.years_active",
"status": "pending", // pending | accepted | rejected | disputed
"karma_estimate": 5,
"submitted_at": "2026-03-20T09:30:00Z"
}
]
}
}
2.2 Complementary Connector Pairs — Full Table
Every outbound connector has exactly one inbound complement. The interpreter assembles the bidirectional relationship when both ends have compatible connectors. Connectors are additive: in the case of multiple connections of the same type, all are preserved with their own weight and timestamps.
| Outbound | Inbound (complement) | Entity types | |
|---|---|---|---|
performs_at | ↔ | has_performer | Person → Venue/Event |
hosts_show | ↔ | hosted_at | Venue → Event |
produced_by | ↔ | produces | Event → Person/Org |
located_in | ↔ | has_location | Any → Place/City |
member_of | ↔ | has_member | Person → Organization/Ceiba |
managed_by | ↔ | manages | Venue/Ceiba → Person |
supplier_of | ↔ | supplied_by | Org → Venue/Business |
listed_on | ↔ | lists | Any → Ceiba/Directory |
attests | ↔ | attested_by | Person → Person (trust) |
routes_via | ↔ | routes_for | Route → Adapter (financial domain) |
priced_in | ↔ | prices | Product → Currency |
event_in | ↔ | has_event | Event → Ceiba |
teaches | ↔ | taught_by | Person → Style/Discipline |
employs | ↔ | employed_by | Org → Person |
sibling_of | ↔ | sibling_of | Any → Any (symmetric) |
derived_from | ↔ | has_derivative | Entity → Entity (fork) |
owns | ↔ | owned_by | Person/Org → Venue/Business |
knows | ↔ | known_by | Person → Person (trust graph) |
sibling_of and knows are symmetric connectors. When A declares knows → B, the interpreter creates known_by → A on B. For knows to be mutual (relevant to the trust graph), B must also declare knows → A. Mutuality is not inferred automatically.
2.3 Per-Field Provenance — Rules
- Every field in
bodymust have at least one entry insources. Fields without a source are rejected onPOST /v1/entitieswith422 Unprocessable Entity. - The
confidencefield follows the scale: high = observed directly in an authoritative source (owner or official platform), medium = observed in a secondary source, low = unverified third-party report, inferred = algorithmically derived. - Connectors can also carry a source, with path:
connectors.outbound[{type}:{target}]. - When the entity owner edits a field directly (authenticated via Passkey), the source is recorded as
source_url: "self"withconfidence: "high".
3. Identity and Trust — Specification
Identity in ceiba.to follows a progressive-claim model. An entity exists before it is claimed. The claim is a unilateral act by the real titleholder, verified by multiple mutually reinforcing mechanisms.
Layer 0 — SMS Claim (Bootstrap)
Layer 0 is the lowest identity entry point. It turns a phone number into a provisional DID. It is non-repeatable: an entity can only be claimed once through this mechanism. Later transfer requires Layer 2.
// Start claim
POST /v1/claim
Content-Type: application/json
{
"entity_id": "comedian/ana-torres",
"phone_e164": "+521234567890"
}
// Response
HTTP/1.1 202 Accepted
{
"claim_id": "clm_0xabc123",
"expires_at": "2026-04-01T14:10:00Z", // 10-minute TTL
"message": "OTP sent to +52123***7890"
}
// Verify OTP
POST /v1/claim/{claim_id}/verify
{
"otp": "847291" // 6 digits, generated by a TOTP-like PRNG
}
// On success:
HTTP/1.1 200 OK
{
"entity_id": "comedian/ana-torres",
"owner_did": "did:phone:+521234567890",
"session_token": "opaque-jwt..."
}
// Effect on the entity:
entity.owner = "did:phone:+521234567890"
entity.claimed_at = "2026-04-01T14:02:33Z"
entity.claim_method = "sms_otp"
409 Conflict. (2) The OTP expires after 10 minutes and is single-use. (3) Failed attempts are capped at 5 per claim_id; exceeding the limit invalidates the claim. (4) Twilio is the reference OTP provider; the protocol does not prescribe an SMS provider.
Layer 1 — Passkey / WebAuthn Level 2
Once the titleholder has a Layer-0 (or Layer-2) session_token, they can register a Passkey that promotes their identity from did:phone: to did:ceiba:. WebAuthn Level 2 is the W3C standard; the reference implementation uses the Python py_webauthn library.
// Phase 1: get registration challenge
POST /v1/auth/passkey/register/begin
Authorization: Bearer {session_token}
// Response: PublicKeyCredentialCreationOptions
{
"challenge": "base64url(32-random-bytes)",
"rp": { "name": "ceiba.to", "id": "ceiba.to" },
"user": {
"id": "base64url(entity_id_bytes)",
"name": "comedian/ana-torres",
"displayName": "Ana Torres"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 }, // ES256
{ "type": "public-key", "alg": -8 } // EdDSA
],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "required"
}
}
// Phase 2: complete registration
POST /v1/auth/passkey/register/complete
Authorization: Bearer {session_token}
{ "credential": { ... } } // AuthenticatorAttestationResponse
// On success: the server stores
{
"credential_id": "base64url...",
"public_key": "base64url(COSE-encoded)",
"sign_count": 0,
"entity_id": "comedian/ana-torres",
"registered_at": "..."
}
// owner_did is promoted:
entity.owner = "did:ceiba:comedian/ana-torres"
Multiple devices are supported: iCloud Keychain, Google Password Manager, or any syncable WebAuthn authenticator. The server stores all credential_ids associated with an entity. No private key is ever stored on the server.
Layer 2 — Social Recovery
Social recovery lets a titleholder who lost access to their Passkeys regain control of their entity via signed attestations from trusted contacts.
// Titleholder announces loss of access
POST /v1/recovery/initiate
{
"entity_id": "comedian/ana-torres",
"phone_e164": "+521234567890", // Layer-0 identity as bootstrap
"new_device_challenge": "base64url(32-random-bytes)"
}
// Response: recovery_id + list of the entity's trusted_contacts
// Each trusted contact issues an attestation
POST /v1/recovery/{recovery_id}/attest
Authorization: Bearer {attester_session_token}
{
"attester_entity_id": "comedian/roberto-gomez",
"target_entity_id": "comedian/ana-torres",
"timestamp": "2026-04-01T15:00:00Z",
"context": "social_recovery",
"attester_signature": "base64url(Ed25519(...new_device_challenge...))"
}
// Once >= N valid attestations accumulate:
POST /v1/recovery/{recovery_id}/complete
// → enables /v1/auth/passkey/register/begin without session_token
// → the new Passkey replaces previous credential_ids
// → previous credential_ids are revoked
N threshold, configurable per ceiba:
- Individual entities in small ceibas: N = 3
- Organizational entities or ceibas with high economic activity: N = 5
- The ceiba seed can raise the threshold up to N = 7 via configuration in
ceiba.json
Magic Link — Long-Lived Session Access
For use cases where a Passkey is impractical (access from a shared device, iCal export, etc.), the protocol supports 90-day magic links.
// Generate a magic link (requires an active Passkey session)
POST /v1/magic-link
Authorization: Bearer {passkey_session}
{
"label": "Venue shared iPad",
"ttl_days": 90
}
// Response
{
"token": "base64url(32-random-bytes)",
"url": "https://a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5.lacartelera.app/",
"expires_at": "2026-07-01T14:00:00Z",
"single_use": false
}
// The token is opaque (32 random bytes), unsigned.
// The subdomain {token}.{ceiba-domain} resolves to an authenticated session.
// Stored by the user in iCal, shortlink, or a secure note.
// Revocable at any time via DELETE /v1/magic-link/{token}
4. Trust Model — Set Theory
4.1 Formal Definition
ceiba.to's trust model is grounded in graph and set theory, not transitive chains. Transitive chains are vulnerable to intermediary attacks: if A trusts B and B trusts C, an attacker who compromises B can extend arbitrary trust to C.
// Definitions
G = (V, E)
V = set of entities
E = set of mutual edges { (A, B) : A.knows(B) AND B.knows(A) }
// A trust set S is a set where all pairs are co-known
trust_set(S) = { A ∈ V : ∀B ∈ S, (A,B) ∈ E }
// Trust predicate
trust(A, C) = TRUE iff:
∃ B : (A,B) ∈ E AND (B,C) ∈ E AND (A,C) ∈ E
// A must have at least ONE direct link with C
// or know someone whom BOTH know independently
// Weaker condition (co-neighborhood):
soft_trust(A, C) = TRUE iff:
∃ B : (A,B) ∈ E AND (B,C) ∈ E
// A and C share a direct mutual contact
// This does NOT guarantee trust(A,C) — B knows both, but A and C are strangers
soft_trust(A, C) enables discovery (C can appear in A's suggestions). trust(A, C) enables trust actions (C can attest A's recovery, C can contribute data to A's entities with elevated karma). Implementations must distinguish both predicates.
// Edge decay: an edge's weight decays over time
edge_weight(A, B, t) = w₀ * decay_fn(t - last_interaction(A, B))
// Decay function configurable per ceiba (in ceiba.json)
// Default: exponential with 1-year half-life
decay_fn(Δt_seconds) = exp(-Δt / 31_536_000)
// For trust graph queries, edges with weight < 0.1 are ignored
// (configured as a threshold in ceiba.json:trust.edge_weight_threshold)
4.2 Cross-Network Entity Resolution
When an entity exists in multiple ceibas without having been explicitly federated, the protocol can propose a cross-identification based on field matching.
// Per-field matching weights
MATCH_WEIGHTS = {
"phone_e164": 0.9, // strong identifier
"social_handles": 0.7, // per specific handle
"birth_year": 0.4,
"city": 0.3,
"genres": 0.25, // jaccard similarity
"years_active": 0.2
}
// body.name and body.@type: excluded from scoring (too common)
// Threshold: score > 0.85 → candidate match
// Algorithm
match_score(entity_A, entity_B):
score = 0
for field, weight in MATCH_WEIGHTS:
if field_matches(entity_A[field], entity_B[field]):
score += weight
return score
// Privacy: matching runs CLIENT-SIDE before being proposed to the user
// The server NEVER receives the other node's private fields to do matching
// Protocol: ceiba A sends a bloom filter of field hashes; ceiba B replies
// with its own bloom filter. The intersection reveals candidates without exposing data.
Confirmation of the cross-identification is always opt-in by the entity owner. Once confirmed, a sibling_of connector is created between the two instances and both replicas are marked federated: true.
5. Karma Economy — Specification
Karma is the incentive mechanism for verified data contributions. It is not a cryptocurrency nor does it require a token. It is an internal unit of measurement of the ceiba, convertible into fiat or USDC depending on the pool configuration.
5.1 Contribution Lifecycle
// 1. Contributor submits a data proposal
POST /v1/contributions
Authorization: Bearer {contributor_session}
{
"entity_id": "comedian/ana-torres",
"field": "body.city",
"new_value": "Guadalajara",
"source_url": "https://www.instagram.com/p/...",
"confidence": "high",
"contributor_did": "did:ceiba:user/carlos-contrib"
}
// Response
HTTP/1.1 201 Created
{
"id": "ctr_0x4f2a8b...",
"status": "pending",
"karma_estimate": 3, // computed by field type
"review_deadline": "2026-04-08T14:00:00Z"
}
// The entity is NOT modified yet. The proposed value goes into karma_escrow.
// 2. The titleholder (owner) reviews
PATCH /v1/contributions/ctr_0x4f2a8b
Authorization: Bearer {owner_session}
{
"status": "accepted",
"titular_signature": "base64url(Ed25519(contribution_id + entity_version))"
}
// On accepted:
// → entity[field] = new_value
// → entity.version += 1
// → karma credited to the contributor (enters payment escrow)
// → entity.sources[] updated with the contribution's source_url
// 3. Dispute (owner only, within 24h post-acceptance)
POST /v1/contributions/ctr_0x4f2a8b/dispute
Authorization: Bearer {owner_session}
{
"reason": "Wrong data, I still live in CDMX",
"disputer_did": "did:ceiba:comedian/ana-torres"
}
// On dispute (within 24h, disputer == owner):
// → entity[field] = previous_value
// → entity.version += 1 (the rollback also bumps version)
// → contributor karma: returned to escrow (NOT paid)
// → if USDC payment was already sent: a debt is recorded, no on-chain reversal
// 4. Query balance
GET /v1/karma/balance?entity=did:ceiba:user/carlos-contrib
{
"karma": 142.7, // karma_at_time(now) — decay already applied
"karma_raw": 180, // karma earned without decay
"pending_payout_mxn": 34.50,
"pool_balance": 1250.00, // in MXN equivalent
"next_distribution": "2026-05-01T00:00:00Z"
}
// Payout: batched when pool_balance > threshold (configurable per ceiba)
// Options: x402/USDC (protocol-native) or fiat bridge (SPEI/Stripe)
5.2 Karma Decay
Karma decays over time to align incentives with data freshness. A contributor who provided data 5 years ago receives less payout than one who contributed last week.
// Decay function
karma_at_time(t) = karma_earned * max(0, 1 - (t - earned_at) / DECAY_PERIOD)
// Parameters (adjustable via governance)
DECAY_PERIOD = 157_680_000 // 5 years in seconds
// Example
// earned_at = 2023-01-01, karma_earned = 100, now = 2026-01-01
// t - earned_at = 3 years = 94,608,000 seconds
karma_at_time(now) = 100 * max(0, 1 - 94_608_000 / 157_680_000)
= 100 * max(0, 1 - 0.5997)
= 100 * 0.4003
= 40.03
// After 5 years: karma_at_time = 0 (contribution expired)
// karma_raw is not deleted: it is part of the contributor's history
5.3 Pool Distribution (80/20 split, v0.5)
The seed operator pays themselves from their product, not from the pool. The declared pool is split only between contributors and the ceiba's commons fund. There is no mandatory tax to the upstream protocol.
// Distribution at each payout event (v0.5)
pool_total = revenue declared by the operator as a pool input
(revenue lines explicitly listed in ceiba.json,
NOT the operator's total product revenue)
pool_total * 0.80 → contributors (proportional to karma_at_time(now))
pool_total * 0.20 → ceiba commons_fund
(community-governed: moderators, infra,
events, voluntary donation to upstream protocol)
// Payout to contributor i:
payout_i = (pool_total * 0.80) * (karma_i / sum_karma_all)
// where karma_i = karma_at_time(now) of contributor i
// Full distribution is recomputed on every event
Multi-rail. A ceiba can activate one or several payment rails: USDC stablecoin (Base/Arbitrum), x402 micropayments, local fiat with formal invoicing per the seed operator's jurisdiction (CFDI in MX, monotributo in AR, invoice in US/EU), Lightning, MercadoPago, Wise, Stripe, off-protocol. None is the default; the protocol does not impose jurisdiction.
Redeemable, non-transferable karma. Karma is internal ceiba accounting (no token, no blockchain, no exchange):
- Redeemable only — not transferable person-to-person.
- Fixed redemption rate declared in
ceiba.json; it can only go up. - Mandatory 100% escrow of validated circulating karma, auditable via
/v1/karma/escrow_proof. - 5-year decay (already in §5.2).
5.4 Anti-Farming
Farming (mass trivial contributions to accumulate karma) is mitigated via per-action and per contributor-entity caps.
| Contribution type | Max karma | Cap per day/pair | Notes |
|---|---|---|---|
| Minor correction (typo, hours) | 1 | 1 karma/day/entity | address, hours, phone |
| New field (text) | 3–10 | — | weighted by field importance |
| New field (numeric) | 2–5 | — | capacity, price, birth_year |
| Photo/media | 5 | 3 media/entity/week | hash dedup |
| In-person QR verification | 50 | 1/attester-entity pair/year | signed by verifier Passkey |
| Verified new connector | 8 | — | requires evidence in source_url |
| Bot contribution | same weight | same cap | flagged bot: true in contribution record |
6. Distributed Replication — Specification
6.1 Sync Protocol
A node announces itself by publishing its manifest in the ceiba.to index. It then participates in pull or push synchronization.
// Node registration
POST https://ceiba.to/v1/nodes/register
{
"node_url": "https://my-ceiba.example.com",
"manifest_url": "https://my-ceiba.example.com/.well-known/ceiba.json",
"operator_did": "did:ceiba:user/operator"
}
// ── PULL ─────────────────────────────────────────────────────
// Fetch entities updated since a given timestamp
GET /v1/entities?updated_after=2026-04-01T00:00:00Z&limit=100
GET /v1/entities?updated_after=2026-04-01T00:00:00Z&limit=100&cursor=abc123
// Response
{
"entities": [ { ...centipede... }, ... ],
"next_cursor": "def456", // null if no more pages
"total_updated": 342
}
// ── PUSH ─────────────────────────────────────────────────────
POST /v1/sync/push
Authorization: Bearer {node_api_key}
{
"source_node": "https://my-ceiba.example.com",
"entity_batch": [ { ...centipede... }, ... ], // max 500 per request
"batch_hash": "sha256(canonical_json(entity_batch))"
}
// ── CONFLICT RESOLUTION ───────────────────────────────────────
// 1. Last-writer-wins by updated_at (wall-clock timestamp)
// 2. If updated_at is equal: winner = entity with higher version
// 3. If both are equal: winner = entity with role==owner signature present
// 4. Owner signature always overrides any timestamp
// 5. Connectors: union merge (BOTH connector sets are preserved)
// 6. Unresolvable conflict: flagged for manual review (status: "conflict")
6.2 Cross-Ceiba Federation (v1 — Pull-only)
In version 1, federation is asymmetric: consumer ceibas pull entities from authoritative ceibas on demand. There is no federated push in v1. This is a deliberate simplicity-over-completeness tradeoff.
// ceiba.json declares which types are authoritative
{
"authoritative_types": ["comedian", "venue"],
"federation": {
"version": "v1",
"allow_pull": true,
"cache_ttl_seconds": 3600 // read-through cache TTL
}
}
// A consumer ceiba requests an entity from an authoritative ceiba
// If not in local cache (or TTL expired):
GET https://lacartelera.app/v1/entities/comedian/ana-torres
// The consumer ceiba stores a local copy with:
{
"replicas": [
{
"node": "lacartelera.app",
"authoritative": true,
"last_sync": "2026-04-01T14:00:00Z"
}
]
}
// In v2 (roadmap): federated push via webhook subscription
// POST /v1/federation/subscribe { entity_type, callback_url, events: ["update", "delete"] }
6.2.1 Reference implementation: NDNS → Cartelera (LIVE 2026-05-14)
First productive federation under this protocol. Pattern: defense in depth — three convergent paths reaching the same sink endpoint with idempotent upsert on (source_platform, source_id).
- Real-time push — Postgres
AFTER INSERT OR UPDATEtrigger in NDNS'snini.calendario_comediacallsnet.http_post(pg_net) toPOST /v1/events/syncon cartelera. Vault secrets hold the API key and target URL. Latency <1s. - Cron fallback —
*/5 * * * *on the cartelera host runs the same mapping out of band. Reaches eventual consistency within 5 min if the trigger or sink were down. - Manual CLI — operator / smoke test, same payload shape.
The pair is declared in ceibas.json under cartelera → interconnections[] with protocol: "event", scopes: ["events:comedy:upcoming-confirmed"], api_key_required: true. NDNS (internal, opt-out of public registry) is the source; cartelera (Level 3 public) is the sink.
Full payload, troubleshooting matrix, and the abstracted pattern for future pairs (NDNS↔Sociales, Cartelera↔Sociales) live in ceiba-protocol-spec.md §4.4.1.
7. Implementation Stack
| Component | Technology | Version/Notes | Actual deployment |
|---|---|---|---|
| API | FastAPI (Python) | ≥0.110 + Pydantic v2 | La Cartelera, Sociales |
| Local ORM/DB | SQLite + WAL mode | Python sqlite3 stdlib | La Cartelera, Sociales |
| Hosted DB | Supabase (PostgreSQL 15) | RLS per entity_id | [IMPL] — available |
| Archival | IPFS (Kubo) | CID v1, sha2-256 | [SPEC] — not impl. yet |
| Frontend | Vanilla JS + HTML | No build step | All ceibas |
| Reverse proxy | Caddy 2 | Caddyfile + systemd | koa-files, bob |
| Authentication | py_webauthn | ≥2.0 | [SPEC] |
| SMS OTP | Twilio Verify | API v2 | [SPEC] |
| Micropayments | x402 (HTTP 402 + USDC) | Base L2 / Polygon | [SPEC] — not impl. |
| AI agents | anthropic-mcp-python | ≥0.1 | La Cartelera |
| Scaffolding | koa-gen CLI | internal | All ceibas |
Protocol compliance checklist
A node is a valid ceiba node if and only if it meets every item marked REQUIRED:
| Requirement | Level | Description |
|---|---|---|
Manifest /.well-known/ceiba.json | REQUIRED | Valid JSON against the v1 schema (see §8.1) |
| REST API + OpenAPI | REQUIRED | /openapi.json endpoint available and valid |
| Schema.org types | REQUIRED | body.@type on each entity is a valid Schema.org type |
| Field provenance | REQUIRED | Every body field has an entry in sources[] |
| Version history | REQUIRED | GET /v1/entities/{id}/history returns prior versions |
| Claim-based identity | REQUIRED | POST /v1/claim endpoint implemented |
| Pull sync | REQUIRED | GET /v1/entities?updated_after= with pagination |
| Passkey / WebAuthn | RECOMMENDED | Layer-1 identity (without it, Layer 0 only) |
| MCP server | RECOMMENDED | Native access for AI agents |
| x402 / micropayments | OPTIONAL | For ceibas with an active karma economy |
| IPFS archival | OPTIONAL | For long-term resilience |
| Social recovery | OPTIONAL | Requires active Passkey |
8. Technical Appendix
8.1 Full Schema — ceiba.json
{
"$schema": "https://ceiba.to/schemas/manifest/v1.json",
// ── NODE IDENTITY ────────────────────────────────────────────
"id": "lacartelera", // unique slug, lowercase kebab-case
"name": "La Cartelera",
"description": "Live comedy directory in Mexico",
"url": "https://lacartelera.app",
"operator_did": "did:ceiba:user/inge",
"operator_email": "[email protected]",
// ── PROTOCOL ─────────────────────────────────────────────────
"protocol_version": "0.5",
"roles_supported": ["operator", "contributor", "entity-owner"], // v0.5
"api_base": "https://lacartelera.app/v1",
"openapi_url": "https://lacartelera.app/openapi.json",
"mcp_url": "https://lacartelera.app/mcp",
// ── ENTITIES ─────────────────────────────────────────────────
"authoritative_types": ["comedian", "venue", "show"],
"entity_count": 341, // updated periodically
"last_updated": "2026-04-01T14:00:00Z",
// ── FEDERATION ───────────────────────────────────────────────
"federation": {
"version": "v1",
"allow_pull": true,
"allow_push": false, // v1: pull-only
"cache_ttl_seconds": 3600
},
// ── IDENTITY AND AUTH ────────────────────────────────────────
"identity": {
"sms_otp": true, // Layer 0
"passkey": true, // Layer 1 (WebAuthn)
"social_recovery": true, // Layer 2
"recovery_threshold": 3, // N of M contacts required
"rp_id": "lacartelera.app" // WebAuthn relying party ID
},
// ── ECONOMY ──────────────────────────────────────────────────
"karma": {
"enabled": true,
"redemption_rate": "1 karma = 1 MXN", // fixed, monotonic, can only go up
"escrow_proof_url": "/v1/karma/escrow_proof",
"escrow_coverage": 1.0, // 100% of validated circulating karma
"decay_period_seconds": 157680000, // 5 years
"pool_distribution": { // v0.5: 80/20
"contributors": 0.80,
"commons_fund": 0.20
},
"payment_threshold": "declared_per_ceiba",
"payment_rails": ["x402_usdc", "spei_cfdi", "mercadopago", "stripe"]
},
// ── TRUST GRAPH ──────────────────────────────────────────────
"trust": {
"edge_weight_threshold": 0.1,
"decay_half_life_seconds": 31536000 // 1 year
},
// ── OPEN DATA ────────────────────────────────────────────────
"open_data": {
"license": "CC BY 4.0",
"ical_feed": "https://lacartelera.app/feed.ics",
"llms_txt": "https://lacartelera.app/llms.txt"
}
}
8.2 Canonical Connector-Type Registry
Connectors registered in the ceiba: namespace have fixed semantics. Ceibas can define custom connectors in their own namespace ({ceiba-id}:). Custom connectors do not participate in automatic cross-ceiba matching.
| Namespace | Type | Schema.org equivalent | Applicability |
|---|---|---|---|
| ceiba: | performs_at / has_performer | performer / performerIn | Person ↔ Event/Venue |
| ceiba: | hosts_show / hosted_at | organizer / location | Venue ↔ Event |
| ceiba: | member_of / has_member | memberOf / member | Person ↔ Organization |
| ceiba: | located_in / has_location | containedInPlace / containsPlace | Any ↔ Place |
| ceiba: | owns / owned_by | owns / — | Person ↔ Business |
| ceiba: | attests / attested_by | — (ceiba-native) | Person ↔ Person |
| ceiba: | knows / known_by | knows / — | Person ↔ Person |
| ceiba: | routes_via / routes_for | — (ceiba-native) | Route ↔ Adapter |
| lacartelera: | headlines / headlined_by | — (custom) | Show ↔ Comedian |
8.3 Karma Weights Table — Full Reference
| Field / Action | Base karma | Cap | Condition |
|---|---|---|---|
| body.name (typo fix) | 0.5 | 1/day/entity | — |
| body.city | 2 | — | requires source_url |
| body.phone_e164 | 5 | — | requires source confidence=high |
| body.social_handles (new) | 3 | — | verified via scraping |
| body.description | 3 | — | ≥50 chars, no spam |
| body.birth_year | 2 | — | confidence=medium or higher |
| body.genres (new tag) | 1 | 5 tags/entity | from the official vocabulary |
| Main photo/media | 5 | 3/entity/week | unique hash, min 400px |
| New connector (observed) | 8 | — | event/listing source_url |
| In-person QR verification | 50 | 1/pair/year | verifier Passkey required |
| Obsolete-entity report | 2 | — | if the titleholder confirms |
| Bot contribution (flag) | same | same | bot: true on record |
8.4 Trust Graph Query — Pseudocode
def trust_query(entity_A: str, entity_C: str, graph: Graph) -> TrustResult:
"""
Returns whether A trusts C under the ceiba model.
trust(A, C) = TRUE iff A has at least one direct link with C
OR both share at least one mutual contact AND
A has some direct (weak) link with C.
For social_recovery: soft_trust is the minimum required.
For karma_elevated: full trust is required.
"""
# Filter edges by weight_threshold
threshold = graph.ceiba_config.trust.edge_weight_threshold # default 0.1
def active_neighbors(entity: str) -> Set[str]:
return {
b for (a, b, w) in graph.edges
if a == entity and w >= threshold
and (b, a, _) in graph.edges # mutual edge
}
neighbors_A = active_neighbors(entity_A)
neighbors_C = active_neighbors(entity_C)
# Case 1: direct connection
if entity_C in neighbors_A:
return TrustResult(
trusted=True,
level="direct",
path=[entity_A, entity_C]
)
# Case 2: shared neighborhood (co-knowns)
common = neighbors_A & neighbors_C
if common:
# soft_trust: A and C share contact B
# Note: this is NOT full trust(A,C) without direct (A,C)
best_B = max(common, key=lambda b: edge_weight(A,b) + edge_weight(C,b))
return TrustResult(
trusted=False,
soft_trust=True,
level="co_neighbor",
path=[entity_A, best_B, entity_C],
common_contacts=list(common)
)
# Case 3: BFS up to depth=2 for discovery
# (suggestions only, NOT authorization)
depth_2 = set()
for b in neighbors_A:
depth_2 |= active_neighbors(b)
if entity_C in depth_2:
return TrustResult(
trusted=False,
soft_trust=False,
level="discovery_only"
)
return TrustResult(trusted=False, soft_trust=False, level="none")
def edge_weight(A: str, B: str, graph: Graph) -> float:
edge = graph.get_edge(A, B)
if not edge: return 0.0
delta_t = now() - edge.last_interaction
half_life = graph.ceiba_config.trust.decay_half_life_seconds # default 1 year
return edge.base_weight * (0.5 ** (delta_t / half_life))
9. v0.5 Additions — Roles, Federation, Payment Rails
Version 0.5 (2026-05-13) is an additive bump on top of v0.4. v0.4 manifests remain valid; v0.5 consumers parse both. The new fields are optional.
9.1 roles_supported
A ceiba may declare which of the three canonical roles it implements:
"operator"— runs the directory/registry of this ceiba."contributor"— edits entities and events (community)."entity-owner"— claims and controls a single entity inside the ceiba.
Omitting roles_supported means v0.4 legacy semantics: only the operator role is formalized. Adding it is a no-op for v0.4 consumers (unknown field, ignored).
"roles_supported": ["operator", "contributor", "entity-owner"]
9.2 linked_entities[] — Entity-aware federation
A ceiba may reference self-sovereign entities (living in another ceiba or standalone) without duplicating their data. Analogous to Wikipedia cross-references: you link, you do not copy.
"linked_entities": [
{
"entity_uri": "https://lacartelera.app/comediante/labea",
"scopes": ["read:basic", "read:events"],
"api_key_required": true,
"ttl_seconds": 3600,
"relation": "talent_pool"
}
]
Scopes follow an OAuth-style enum: read:basic, read:events, read:contact, write:events. Cache TTL is configurable (default 1h). Changes at the entity origin propagate automatically on TTL expiry.
9.3 api_keys_emit — OAuth-like authz for federated reads
A ceiba that emits authoritative entity data declares which API keys it issues, the scopes they can grant, and the rotation policy:
"api_keys_emit": {
"issuer": "https://lacartelera.app",
"scopes_offered": ["read:basic", "read:events", "read:contact"],
"key_format": "bearer:jwt:HS256",
"rotation_days": 90
}
9.4 interconnections[].scopes + .api_key_required
The existing interconnections[] array now supports per-connection scopes and an authz flag, enabling fine-grained federation declarations without promoting the link to a top-level field. The recommended protocol enum gains "federation" as a value.
"interconnections": [
{
"target": "sociales.lat",
"protocol": "federation",
"scopes": ["read:basic"],
"api_key_required": true
}
]
9.5 payment_rails — Expanded to 16 rails
Six new rails were added in v0.5 to cover LATAM, EU, and US bank-rail breadth without forcing operators to pick a regional default. The full enum is now:
"payment_rails": [
"stablecoin_usdc", "stablecoin_usdt", "x402",
"fiat_invoice", "lightning", "manual",
"mercadopago", "stripe", "wise", "clip",
// v0.5 additions:
"spei", // MX wire (instant)
"oxxo", // MX cash-network voucher
"pix", // BR instant
"sepa", // EU SEPA Credit Transfer
"ach", // US ACH
"bank_transfer" // fallback generic
]
Rails remain operator-chosen; there is no default. A ceiba is not obligated to enumerate any payment rail (most are commons-only).
9.6 Migration v0.4 → v0.5
Migration is one PR that adds the optional fields. See 99_docs/ceiba-0-5-migration.md in the reference repo for a 5-step recipe (bump protocol_version; declare roles_supported; optional linked_entities; optional api_keys_emit; deploy + smoke). A v0.4 consumer continues to parse a v0.5 manifest because unknown fields are ignored.
9.7 Replication incentives (v0.5.1)
The protocol assumes that nodes cooperate by replicating data from other ceibas. v0.5.1 makes the incentive mechanism explicit: structured reciprocity.
Normative rule. For a ceiba to issue GET /v1/entities/* against another ceiba (consumer), it must expose equivalent endpoints (server) and maintain active replication of at least N network entities (default N=100, configurable by the origin ceiba via replication_minimum in its manifest).
Node karma. Each ceiba maintains a node_karma counter separate from contributor_karma (Section 6). It increments via:
+1perGET /v1/entities/*served (rate-limited 1k/day per consumer).+5each time it serves an entity whose home node is down (uptime resilience).−10if it responds with stale data >24h.
Node karma is observable on the public registry via GET /v1/registry/nodes (normative endpoint in v0.5.1). It does not grant access on its own — its value is a public signal of reliability.
Anti-collusion. Two ceibas with the same human titleholder (verifiable by DID) share a single aggregated node_karma to prevent circular farming.
x402-per-sync. Optional (MAY), non-normative (not MUST) in v0.5.1. Reserved for v0.6+ once ceibas have real revenue. Ceibas that opt to charge for replication may advertise prices in their did.json under service[].x402. Consumers are free to pay or fall back to reciprocity mode.
ceiba.to is a project by KOA Labs. Open protocol. Reference implementation in Python (FastAPI). Open data by default.
Technical contact: [email protected] · Issues: git.koanet/inge/ceiba/issues · Previous spec: v0.3 ES