Technical Specification

ceiba.to: Protocol Specification

Version 0.5.1 — 2026-05-13 · koa.dev · For protocol designers, developers, cryptographers, and open-standards contributors
What's new in v0.5 (2026-05-13). Additive bump on top of v0.4. Adds 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.
Implementation status: Sections marked [IMPL] have production implementations. Sections marked [SPEC] are formal specifications not yet 100% implemented. Sections marked [DRAFT] are subject to revision.

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

LayerTechnologyRoleStatus
TransportHTTPS + REST/JSON-LDPublic entity API[IMPL]
SemanticsSchema.org + ceiba: namespaceData vocabulary[IMPL]
AI agentsMCP (anthropic-mcp-python)Agent queries[IMPL]
Micropaymentsx402 (HTTP 402 + USDC)API-native monetization[IMPL]
IdentityWebAuthn Level 2 (Passkeys)Passwordless auth[SPEC]
Identity Step-0SMS OTP (Twilio)Claim bootstrap[SPEC]
RecoverySocial attestation (Shamir-inspired)Social recovery[SPEC]
Entitiesceiba-manifest (JSON + Ed25519 signatures)Portable centipede format[IMPL]
Scaffoldingkoa-gen CLINew-ceiba generation[IMPL]

1.2 Storage

The protocol is backend-agnostic. The reference implementation supports three modes:

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

CeibaDomainEntitiesBackendExtra stack
La Carteleralacartelera.app158 indexed comedians, 183 venuesSQLiteMCP server
Socialessociales.latLATAM dance eventsSupabaseiCal 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.

OutboundInbound (complement)Entity types
performs_athas_performerPerson → Venue/Event
hosts_showhosted_atVenue → Event
produced_byproducesEvent → Person/Org
located_inhas_locationAny → Place/City
member_ofhas_memberPerson → Organization/Ceiba
managed_bymanagesVenue/Ceiba → Person
supplier_ofsupplied_byOrg → Venue/Business
listed_onlistsAny → Ceiba/Directory
attestsattested_byPerson → Person (trust)
routes_viaroutes_forRoute → Adapter (financial domain)
priced_inpricesProduct → Currency
event_inhas_eventEvent → Ceiba
teachestaught_byPerson → Style/Discipline
employsemployed_byOrg → Person
sibling_ofsibling_ofAny → Any (symmetric)
derived_fromhas_derivativeEntity → Entity (fork)
ownsowned_byPerson/Org → Venue/Business
knowsknown_byPerson → Person (trust graph)
Note: 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

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"
Layer 0 constraints: (1) An entity can only be claimed once. Attempting to claim an already-claimed entity returns 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.

Cryptographic clarification: This mechanism is NOT Shamir Secret Sharing. There is no cryptographic split of any key. It is a social-attestation protocol: N trusted entities sign statements that authorize the registration of a new Passkey. Security depends on the social integrity of the witnesses, not on cryptographic assumptions.
// 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:

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
Key distinction: 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):

5.4 Anti-Farming

Farming (mass trivial contributions to accumulate karma) is mitigated via per-action and per contributor-entity caps.

Contribution typeMax karmaCap per day/pairNotes
Minor correction (typo, hours)11 karma/day/entityaddress, hours, phone
New field (text)3–10weighted by field importance
New field (numeric)2–5capacity, price, birth_year
Photo/media53 media/entity/weekhash dedup
In-person QR verification501/attester-entity pair/yearsigned by verifier Passkey
Verified new connector8requires evidence in source_url
Bot contributionsame weightsame capflagged bot: true in contribution record
Farming via collusion: The highest-risk scenario is titleholder-contributor collusion (A claims entity, B contributes garbage, A accepts to pay B in collusion). Mitigation: titleholder reputation analysis weighs the history of acceptances. If a titleholder accepts >80% of contributions from a single contributor, moderation review is triggered. Payouts on contributions accepted in <2 minutes are held an additional 24h.

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).

  1. Real-time push — Postgres AFTER INSERT OR UPDATE trigger in NDNS's nini.calendario_comedia calls net.http_post (pg_net) to POST /v1/events/sync on cartelera. Vault secrets hold the API key and target URL. Latency <1s.
  2. 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.
  3. 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

ComponentTechnologyVersion/NotesActual deployment
APIFastAPI (Python)≥0.110 + Pydantic v2La Cartelera, Sociales
Local ORM/DBSQLite + WAL modePython sqlite3 stdlibLa Cartelera, Sociales
Hosted DBSupabase (PostgreSQL 15)RLS per entity_id[IMPL] — available
ArchivalIPFS (Kubo)CID v1, sha2-256[SPEC] — not impl. yet
FrontendVanilla JS + HTMLNo build stepAll ceibas
Reverse proxyCaddy 2Caddyfile + systemdkoa-files, bob
Authenticationpy_webauthn≥2.0[SPEC]
SMS OTPTwilio VerifyAPI v2[SPEC]
Micropaymentsx402 (HTTP 402 + USDC)Base L2 / Polygon[SPEC] — not impl.
AI agentsanthropic-mcp-python≥0.1La Cartelera
Scaffoldingkoa-gen CLIinternalAll ceibas

Protocol compliance checklist

A node is a valid ceiba node if and only if it meets every item marked REQUIRED:

RequirementLevelDescription
Manifest /.well-known/ceiba.jsonREQUIREDValid JSON against the v1 schema (see §8.1)
REST API + OpenAPIREQUIRED/openapi.json endpoint available and valid
Schema.org typesREQUIREDbody.@type on each entity is a valid Schema.org type
Field provenanceREQUIREDEvery body field has an entry in sources[]
Version historyREQUIREDGET /v1/entities/{id}/history returns prior versions
Claim-based identityREQUIREDPOST /v1/claim endpoint implemented
Pull syncREQUIREDGET /v1/entities?updated_after= with pagination
Passkey / WebAuthnRECOMMENDEDLayer-1 identity (without it, Layer 0 only)
MCP serverRECOMMENDEDNative access for AI agents
x402 / micropaymentsOPTIONALFor ceibas with an active karma economy
IPFS archivalOPTIONALFor long-term resilience
Social recoveryOPTIONALRequires 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.

NamespaceTypeSchema.org equivalentApplicability
ceiba:performs_at / has_performerperformer / performerInPerson ↔ Event/Venue
ceiba:hosts_show / hosted_atorganizer / locationVenue ↔ Event
ceiba:member_of / has_membermemberOf / memberPerson ↔ Organization
ceiba:located_in / has_locationcontainedInPlace / containsPlaceAny ↔ Place
ceiba:owns / owned_byowns / —Person ↔ Business
ceiba:attests / attested_by— (ceiba-native)Person ↔ Person
ceiba:knows / known_byknows / —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 / ActionBase karmaCapCondition
body.name (typo fix)0.51/day/entity
body.city2requires source_url
body.phone_e1645requires source confidence=high
body.social_handles (new)3verified via scraping
body.description3≥50 chars, no spam
body.birth_year2confidence=medium or higher
body.genres (new tag)15 tags/entityfrom the official vocabulary
Main photo/media53/entity/weekunique hash, min 400px
New connector (observed)8event/listing source_url
In-person QR verification501/pair/yearverifier Passkey required
Obsolete-entity report2if the titleholder confirms
Bot contribution (flag)samesamebot: 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:

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:

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.


Implementation status — 2026-05-13: La Cartelera (lacartelera.app) implements §2 (centipede model), §6 (pull replication), and §7 (full stack). Sections §3 (WebAuthn identity), §4 (formalized trust graph), and §5 (v0.5 karma economy) are formal specifications being prepared for implementation in Q3 2026. The §9 v0.5 fields (roles, federation, payment rails) are schema-LIVE on ceiba.to — operators may opt into them per ceiba. Contributions welcome: git.koanet/inge/ceiba.

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

Changelog — latest: v0.5 (2026-05-13)