DFOS Web Relay

An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, artifacts, countersignatures, and content blobs.

The proof plane (/proof/v1/*) is frozen with Protocol v1 — see the core protocol status. The relay's other surfaces — ingestion ergonomics, peering, and the content plane — are reference-implementation behavior on their own clock, not frozen. Discuss in the DFOS space.

Wire stability. The non-frozen relay surfaces hold one uniform shape: countersignature reads use a single route, /revocations/v1 is frozen at v1 with a bounded issuer feed, and every list route uses the same limit + after + next cursor paradigm. Adopters integrate against one pagination contract, never per-route variants.

Source · npm · Protocol


Philosophy

Relays verify everything they receive and serve everything they've verified. They don't issue identity, grant permissions, or define content semantics. Give any two relays the same operations and they produce the same deterministic head state. No trust between relays, no coordination required.

A relay is a library, not a service. createRelay() returns a portable Hono application — Node.js, Cloudflare Workers, Deno, Bun, Docker, a Raspberry Pi. You provide storage and peer configuration. The relay handles verification, peering, and HTTP semantics.


Two Planes

The relay serves two distinct planes of data with different access models:

Proof Plane (public)

Signed chain operations, artifacts, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently.

All proof plane routes are unauthenticated. The operations themselves carry their own authentication (Ed25519 signatures).

Content Plane (private)

Raw content blobs — the actual documents that content chains commit to via documentCID. The content plane never gossips: blobs are never pushed on the operation log the way proof-plane operations are. A blob enters a relay one of two ways — it is uploaded to the relay that holds the chain, or it is pulled by a relay that is authorized to read it (content-addressed, behind a grant; see Content Following). Either way a blob is served only to authorized readers, and its integrity is its documentCID, so a pulled blob is verified by hash regardless of where it came from.

A relay's content plane is a document gateway — the same surface named as a standalone service on its own 0.x clock. See DOCUMENT-GATEWAY.md for the gateway contract: a stateless, content-addressed blob store whose authorization is re-derived live from the proof plane. The served blob is the document itself — whether terminal (the bytes are the content) or referential (the document points at external bytes, e.g. ipfs:// or a signed-CDN reference). The relay never resolves a referential pointer; delivery of referenced media is out of protocol.

Content plane access requires two credentials:

  • Auth token: A DID-signed JWT proving the caller controls an identity (AuthN)
  • Read credential (for non-creators): A DFOS credential with action: "read" attenuations, issued by the content creator (or delegated via chain), granting the caller read access (AuthZ). Can be presented per-request or ingested as a standing authorization (see Standing Authorization below)

The content creator (the DID that signed the genesis content operation) can always read their own blobs with just an auth token.

Content plane support is optional per relay. When disabled (capabilities.content: false in the well-known response), all content plane routes return 501 Not Implemented — not 404 (resource doesn't exist), but 501 (capability not supported).


Route Namespacing

Every proof plane route is namespaced under a single prefix, /proof/v1:

/proof/v1/operations
/proof/v1/operations/:cid
/proof/v1/countersignatures/:cid
/proof/v1/identities/:did
/proof/v1/identities/:did/log
/proof/v1/content/:contentId
/proof/v1/content/:contentId/log
/proof/v1/log

The prefix encodes the plane and its version ({plane}/{version}), so the proof plane's version clock is legible in the URL and the plane mounts or proxies as a unit by prefix. The proof routes are frozen with protocol v1 — a relay MUST serve them at exactly these paths.

Six route families deliberately stay at the root, on their own clocks:

  • GET /.well-known/dfos-relay — discovery (RFC 8615) lives at the root by convention; it announces the base and the relay's own release version.
  • Content plane routes (/content/:contentId/blob[/:ref]) — these belong to the document gateway, an optional service on a 0.x clock independent of the protocol freeze. They remain at the root under /content/:contentId because they belong to the gateway's 0.x clock, not the frozen proof plane. Note the resulting split: the proof node owns the bare chain-state paths GET /proof/v1/content/:contentId and /proof/v1/content/:contentId/log; the document gateway owns the /content/:contentId/blob* sub-paths. They are distinct namespaces that a reverse proxy can fan by prefix when the planes are split across origins.
  • Universal resolver (GET /1.0/identifiers/:did) — the DID-core / DIF Universal Resolver binding on its own 1.0 clock (the DIF driver interface version, DID-METHOD.md §5.2.4). It is an additive, read-only projection of the same self-certified terminal state the proof plane serves at /proof/v1/identities/:did, rendered as a W3C DID Document. It stays at the root because it tracks the DIF driver clock, not the frozen proof plane.
  • Revocation status (GET /revocations/v1/credential/:credentialCID, GET /revocations/v1/issuer/:did) — an indexed, read-only projection of the relay's revocation set on its own frozen v1 clock. Revocations still enter through the frozen proof plane (POST /proof/v1/operations); this family only exposes a read over the index the relay already maintains for its own credential enforcement. See Revocation Status.
  • Index (GET /index/v0/*) — an optional, non-authoritative query surface over the current-state projections the relay already folds: enumerate identities, filter content chains, reverse-look-up countersignatures by witness. On its own unfrozen 0.x clock, gated by capabilities.index. See Index (v0).
  • Signing mailbox (/signing/v0/*) — an optional, opt-in courier for sign requests: deposit, poll, respond, decline. On the SIGNING spec's own unfrozen 0.x clock — legible in the path exactly as /index/v0's — gated by capabilities.signing (default off). Courier state is not proof plane — nothing deposited there is gossiped, indexed, or folded. Routes and semantics live entirely in SIGNING.md.

Error Responses

Every route family answers failures with one body shape — { "error": "<prose>" } — plus the appropriate status code. The prose is diagnostic, never contractual: callers branch on status codes and MUST NOT match message text. Two deliberate exceptions: POST /proof/v1/operations MAY carry an additive details array of per-item schema issues beside error on a 400 (same discipline — diagnostic only), and the universal resolver (GET /1.0/identifiers/:did) answers in the DIF resolution envelope its own contract requires (didResolutionMetadata.error), not this shape.

Cursor-paginated list routes add one rule: an unrecognized or undecodable cursor is a 400, never a silently empty page. The precise behavior splits three ways, and each route names which it has:

  • Relay-local positional routes — the ingestion-ordered logs (global and per-chain) — answer an after the relay never issued with 400: cursors there are meaningful only against the relay that issued them, and a client that receives the 400 restarts its walk from the beginning (ingestion is idempotent; for a per-chain content log the 400 is also how a fork/head-switch mid-walk surfaces).
  • Transparent keyset routes — countersignature reads and the /index/v0/* lexical default — resume strictly past after whether or not it names a present row, so even a foreign cursor is safe and never errors.
  • Opaque-token routes/index/v0/* ordered mode, the signing poll — resume strictly past the encoded composite key when the token decodes, and answer an undecodable token with 400 (a decodable token whose row has since expired or changed resumes at the next key, keyset-style).

Operation Ingestion

All proof plane operations enter through a single endpoint: POST /proof/v1/operations. The request body is an array of JWS tokens — identity operations, content operations, artifacts, and countersignatures can be mixed freely in the same batch. A batch carries at most 100 tokens — a larger array is a 400 — and the reference implementations additionally guard the aggregate request body at 16 MB, answering an oversized body with 413 in both implementations (a client that sees 413 chunks and retries; 400 remains the malformed-content verdict); gossiping peers chunk larger runs to stay within the caps.

Classification

Each token is classified by its JWS typ header:

typ header Classification
did:dfos:identity-op Identity chain operation
did:dfos:content-op Content chain operation
did:dfos:artifact Artifact
did:dfos:countersign Countersignature
did:dfos:credential DFOS credential
did:dfos:revocation Credential revocation

Each operation type has its own typ header. Classification is unambiguous — no DID comparison needed.

Dependency Sort

Within a batch, operations are sorted by dependency priority before processing:

  1. Identity operations — must be processed first so their keys are available
  2. Artifacts — reference identity keys for signature verification
  3. Content operations — reference identity keys, may have chain dependencies
  4. Countersignatures — reference identity keys and existing operations (target must exist)

Within each priority level, genesis operations (no previousOperationCID) are processed before extensions. This ensures that a single batch can bootstrap an entire identity-and-content lifecycle — including chained create + update operations — without multiple round trips.

Verification

Each operation is verified against the relay's stored state:

  • Identity operations: Extension operations are verified against the relay's current trusted state using O(1) extension verification — the trusted head state plus the new operation is sufficient. Genesis operations verify the single-operation chain. The relay uses verifyIdentityChain() / verifyIdentityExtensionFromTrustedState() from the protocol library
  • Content operations: Extension operations are verified against trusted state with enforceAuthorization: true. Non-creator signers must include a DFOS credential with action: "write" attenuations. The relay uses verifyContentChain() / verifyContentExtensionFromTrustedState() from the protocol library
  • Revocations: Signature is verified against the revoking DID's current identity state. The revocation payload must reference a valid credential CID. Once ingested, the revoked credential is no longer honored for authorization or content plane access
  • Artifacts: Signature is verified against the signing DID's current identity state. CID integrity is checked. Payload must conform to the declared $schema. CBOR-encoded payload must not exceed 16384 bytes
  • Countersignatures: Two-phase verification. Protocol-level (stateless): signature, CID integrity, payload schema. Relay-level (stateful): target CID must exist in the relay, witness DID must differ from the target's author DID, one countersign per witness per target

Chain Resolution

The relay must route each incoming operation to its chain. Resolution differs by type:

  • Identity genesis: No prior chain — the relay verifies the single-operation chain and creates a new StoredIdentityChain keyed by the new DID
  • Identity extension: The kid in the JWS header is a DID URL (did:dfos:<id>#<keyId>). The relay extracts the DID prefix (before #) and looks up the existing chain. A kid without # on a non-genesis operation is rejected — it cannot be routed
  • Content genesis: No prior chain — creates a new StoredContentChain keyed by the content ID derived from verification
  • Content extension: The previousOperationCID payload field is used to look up a StoredOperation, which carries the chainId. The relay then fetches the content chain by that chainId. If the previous operation doesn't exist or isn't a content operation, the extension is rejected
  • Countersignatures: The targetCID payload field is used to look up the target operation. The target's author DID is resolved from the stored operation to enforce the witness ≠ author rule

This is relay-level machinery — the protocol library verifies chain integrity, but the relay decides how to locate the chain a given operation belongs to.

Fork Acceptance

Content chains fork; identity chains do not. This section is the content-chain rule; the identity-chain rule is Identity Linearity and Order Authority below.

Content-chain forks are accepted. If an incoming content operation's previousOperationCID references any operation in the chain (not just the current head), the relay verifies the extension against the chain state at that fork point and accepts it. The chain log accumulates all branches.

Deterministic head selection: after accepting a fork, the relay recomputes the head — highest createdAt among tips, lexicographic highest CID as tiebreaker. This is deterministic across relays given the same set of operations, regardless of ingestion order. As forks propagate via peering, all relays converge to the same head.

State at fork point: to verify a fork extension, the relay computes chain state at the parent CID. The Store interface abstracts this via getContentStateAtCID — implementations choose the strategy (full replay, snapshot-backed, etc.).

Content-chain deletes stay per-branch: a content delete seals its own branch, forks rooted at a pre-delete operation remain valid, and head selection may make a non-deleted branch the head (PROTOCOL.md "Terminal States"). Identity undeletion is not a fork behavior — it is the explicit restore operation on the linear identity chain (see Deletion Semantics).

Future timestamp guard: Identity and content operations with a createdAt more than 24 hours in the future are rejected. Since head selection favors the highest timestamp, a far-future createdAt would permanently dominate content-chain head selection — a temporal denial-of-service. The 24-hour window accommodates clock drift while preventing abuse. (Identity chains select no head, but the same bound applies to their operations — one admission rule, no per-kind exception.)

Identity Linearity and Order Authority

Identity chains are strictly linear (PROTOCOL.md "Chain Validity"). The relay enforces this at ingest:

Conflicting extension → permanent rejection. An incoming identity operation whose previousOperationCID references an operation that already has a committed child is refused with the named error identity chains are linear: conflicting extension refused. This is a permanent rejection, not a dependency failure: it is not buffered, not retried, and never admitted later — first-seen wins locally, whatever the competing operation's createdAt claims. The rule holds on every path an identity operation arrives by — direct submission, gossip, sync, and read-through. A peer-log identity operation that conflicts with a locally-committed extension is refused identically: committed identity order is never auctioned or re-arbitrated. (Historical admission of committed logs is otherwise unchanged — see Key Resolution.)

The home relay is the order authority. The subject's services-listed relay — its DfosRelay entry (PROTOCOL.md "Services") — is the order authority for its identity chain: identity writes go to the home relay, the home relay's committed log is the chain's canonical order, and peers replicate that order. First-seen is each relay's local admission mechanism; the home relay's committed log is the convergence rule between relays. Multi-relay support is unchanged where it matters: identity chains still replicate everywhere via peering — what is single-writer is ordering, not read availability. What this closes is exactly the pathological case: an identity concurrently extended through multiple writers with the same controller key, settled by timestamp auction.

The availability trade, stated honestly. An identity write attempted while the home relay is unreachable is at-risk-until-retry: it is not arbitrated into the chain later by timestamp — it either lands at the home relay on retry or it does not exist. Identity writes are rare (key rotations, service changes, deletion); trading their write availability during a home-relay outage for a non-auctionable authority record is deliberate.

Ingestion Statuses

Three distinct outcomes from ingestion:

Status Meaning
new First time seen, verified, stored, state changed
duplicate Already had it, no state change (see note below)
rejected Verification failed

For chain operations, duplicate means the exact same CID and JWS token was already stored — a true idempotent resubmission. Submissions with the same CID but a different JWS token are rejected — since Ed25519 is deterministic, a different token for the same payload means a different signing key.

Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated — one countersign per witness per target. The relay MUST NOT store multiple attestations from the same witness for the same target. Resubmission SHOULD return duplicate (idempotent).

Deletion Semantics

Deletion means the identity stops being an active participant. Historical operations remain verifiable — keys persist in state for signature verification — but no new acts flow from a deleted identity.

The one exception, stated once for every gate below: a restore identity operation in exactly the successor-of-delete position (previousOperationCID = the delete's CID, signed by a controller key of the deleted head state — PROTOCOL.md "Identity Operations") is the single operation a deleted identity's chain accepts. A valid restore returns the identity to active: resolution reports deactivated: false, and every deleted-identity gate below reopens for operations that follow it. No other operation, of any kind, passes any of these gates while the identity is deleted.

Specifically:

  • Identity operations after deletion: Rejected, except a valid restore as the immediate linear successor of the delete. Anything else appended after a delete — including a restore anywhere but that position, or a restore signed by a key not in the deleted head state — is permanently rejected. The delete, and any restore, remain permanently in the linear log.
  • Content operations after deletion: Rejected. Both paths are checked: (a) the signer's identity is deleted — no operations from that DID are accepted, and (b) the content chain's creator identity is deleted — the chain is sealed regardless of who signs.
  • Artifacts from deleted identities: Rejected. A deleted identity MUST NOT publish new artifacts.
  • Credentials from deleted issuers: Rejected. Identity deletion suspends all authority, including outstanding DFOS credentials issued by the deleted identity. Credentials that were valid at time of issuance cease to be honored while the issuer is deleted.
  • Countersignatures from deleted witnesses: Rejected. A deleted identity MUST NOT publish new countersignatures. Countersignatures on operations by deleted authors are still accepted — deletion of the target's author does not prevent other identities from attesting.

Restore resurrects, revocation terminates. Deletion is a suspension of authority, reversible by restore; revocation is permanent. After a valid restore, credentials issued by the identity before the delete are honored again (they were never revoked — their issuer was suspended), the identity's chains accept operations again, and artifacts and countersignatures flow again. A credential the issuer actually revoked stays revoked forever, restore or not.

Self-countersignatures — where the witness DID matches the target's author DID — are rejected at the relay level. A countersignature's semantic is "a distinct witness attests." The protocol-level verifier is stateless and does not enforce this; the relay resolves the target's author and rejects self-attestation.

Result Ordering

Ingestion results are returned in the same order as the input operations array, regardless of internal processing order. results[i] corresponds to operations[i].


Artifacts

Artifacts are standalone signed inline documents — immutable, CID-addressable proof plane primitives. Unlike chain operations which extend a sequence, an artifact is a single signed statement with no predecessor or successor.

Payload

{
  "version": 1,
  "type": "artifact",
  "did": "did:dfos:...",
  "content": {
    "$schema": "https://schemas.dfos.com/profile/v1",
    "name": "My Relay",
    "description": "A relay for the dark forest"
  },
  "createdAt": "2026-03-25T00:00:00.000Z"
}

The content object MUST include a $schema string that identifies the artifact's schema. The schema acts as a discriminator — consumers use it to determine how to interpret the artifact's content. Schema names are free-form strings (no protocol-level registry). Communities may establish conventions for well-known schemas.

Constraints

  • JWS typ header: did:dfos:artifact
  • Max payload size: 16384 bytes CBOR-encoded. This is a protocol constant — not configurable per relay
  • Immutability: Once ingested, an artifact is never updated or replaced. To "update" an artifact's content, publish a new artifact
  • CID-addressable: Each artifact is addressed by the CID of its CBOR-encoded payload

Verification

  1. JWS signature verification against the signing DID's current key state
  2. CID integrity — the payload CID matches the computed CID from the raw payload bytes
  3. Payload schema validation — the payload conforms to the artifact structure (version, type, did, content with $schema, createdAt)
  4. Size limit — CBOR-encoded payload does not exceed 16384 bytes

Countersignatures

A countersignature is a standalone witness attestation — a signed statement that references a target operation by CID. Unlike the original operation primitives (which carry the data itself), a countersign is pure attestation: "I, witness W, attest to operation X."

Payload

{
  "version": 1,
  "type": "countersign",
  "did": "did:dfos:witness...",
  "targetCID": "bafy...",
  "relation": "endorses",
  "createdAt": "2026-03-25T00:00:00.000Z"
}

The optional relation field is an open-namespace tag (1–64 chars) naming the nature of the attestation (e.g. coauthors, endorses, witnessed, holds, received). Recognized values carry social meaning; unrecognized values MUST be preserved and ignored. Omitting relation is CID-neutral — a bare witness encodes identically to one signed before this field existed.

Properties

  • JWS typ header: did:dfos:countersign
  • Own CID: Each countersign has its own CID, distinct from the target. This avoids the ambiguity of multiple JWS tokens sharing the same CID
  • Stateless verification: Signature + CID integrity + payload schema. No relay state required to verify the cryptographic validity of a countersign
  • Composable: The targetCID can reference any CID-addressable operation — content ops, artifacts, identity ops, even other countersigns
  • Immutable: Once published, a countersign is permanent

Relay-Level Checks

The relay enforces semantic rules beyond cryptographic validity:

  1. Target exists: The targetCID must reference an operation already stored in the relay
  2. Witness ≠ author: The countersign's did (witness) must differ from the target operation's author DID
  3. Deduplication: One countersign per witness per target. If the same witness submits a second countersign for the same target, the relay accepts idempotently
  4. Deleted witness rejection: Countersigns from deleted identities are rejected

Endpoints

One route serves countersignature data:

  • GET /proof/v1/countersignatures/:cid?after={cid}&limit=N — Primary lookup. Returns { countersignatures: [{ cid, jwsToken }], next }, sorted by each countersignature's own CID ascending and forward-only cursor-paginated — the same row shape as per-chain log entries, so a generic client handles both identically. after is a countersignature CID and a strictly-greater keyset cursor over that CID order: an after that is not a present key resumes at the next greater key rather than truncating, so cursors survive concurrent additions and even cross-relay replay. limit defaults to 100 and maxes at 1000 (values above the max clamped); next is the last returned row's CID, or null when the page was not full (caught up). Works for any CID-addressable target (operations, artifacts). Returns 404 only when the CID is neither a known operation nor has stored countersignatures. (Each countersignature's targetCID and relation live inside its signed payload — the token is the truth; the row fields are conveniences.)

Relay Identity

Every relay has a DID that resolves on its own proof plane. The relay DID serves as:

  • Auth token audience: Auth tokens are scoped to a specific relay via the JWT aud claim, preventing cross-relay token replay
  • Peer identity: When relays gossip proof plane data to each other, the relay DID identifies the peer
  • Self-proof anchor: The relay's identity chain lives in its own store, verifiable by anyone querying the relay

Relay Profile

The relay MUST publish a profile artifact signed by its own DID using the HEAD key state. The profile artifact uses the https://schemas.dfos.com/profile/v1 schema:

{
  "$schema": "https://schemas.dfos.com/profile/v1",
  "name": "edge.relay.dfos.com",
  "description": "Cloudflare edge relay for the DFOS network",
  "links": [{ "uri": "https://dfos.com", "label": "operator", "description": "Metalabel" }]
}

All fields are optional except name, which SHOULD be present. The optional links array carries up to 20 { uri, label?, description? } entries (operator site, status page, contact). The profile JWS token is inlined in the well-known response — self-proving, no extra fetch needed.

Well-Known Endpoint (GET /.well-known/dfos-relay)

Returns relay metadata. The core discovery contract (did, protocol, version, capabilities, profile) is required — profile is the relay's proof of DID controllership (an artifact JWS signed by the relay DID's controller key). peers and extended stats fields are optional additive telemetry.

{
  "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr",
  "protocol": "dfos-web-relay",
  "version": "0.15.0",
  "capabilities": {
    "proof": true,
    "write": true,
    "content": true,
    "log": true,
    "revocations": true,
    "index": true,
    "signing": false
  },
  "profile": "eyJhbGciOiJFZERTQSIs...",
  "peers": [{ "endpoint": "https://peer.relay.example.com" }],
  "stats": {
    "pendingOps": 0,
    "opCount": 128,
    "countsByKind": {
      "identity": 12,
      "content": 30,
      "artifact": 5,
      "credential": 8,
      "countersign": 3,
      "revocation": 1
    },
    "oldestOpAt": "2026-03-25T00:00:00.000Z",
    "headCid": "bafy..."
  }
}
Field Type Description
did string The relay's DID, resolvable on this relay's proof plane
protocol string Protocol identifier, always "dfos-web-relay"
version string The relay's own release version (semver), independent of the frozen proof-plane clock — the proof version lives in the /proof/v1 path prefix, not here
capabilities object Capability flags for optional features
capabilities.proof boolean MUST be true. A relay without proof plane capability is not a relay
capabilities.write boolean Whether the relay accepts writes via POST /proof/v1/operations
capabilities.content boolean Whether the relay supports the content plane (blob upload/download)
capabilities.log boolean Whether the global operation log is available (GET /proof/v1/log)
capabilities.revocations boolean Whether the revocation status index is served (GET /revocations/v1/*). Reference relays serve it by default; a relay MAY disable it (501). An absent flag reads as true (the family predates the flag)
capabilities.index boolean Whether the index query family is served (GET /index/v0/*). An absent flag (a relay predating the family) reads as false
capabilities.signing boolean Whether the signing mailbox courier is served (/signing/v0/*). Opt-in: reference relays default it off, and an absent flag reads as false
profile string The relay's profile artifact as a compact JWS token — self-proving payload
peers array OPTIONAL additive telemetry. Configured peer relays surfaced for mesh discovery; reference relays emit [] when no peers are configured
peers[].endpoint string OPTIONAL additive telemetry. The peer relay's base URL. A future peers[].did MAY appear once a relay resolves peer DIDs
stats object Operational counters. stats.pendingOps is the count of operations pending processing (-1 if unavailable)
stats.opCount number OPTIONAL additive telemetry. Total entries in the global operation log
stats.countsByKind object OPTIONAL additive telemetry. Global-log counts by primitive kind; reference relays emit identity, content, artifact, credential, countersign, revocation
stats.oldestOpAt string | null OPTIONAL additive telemetry. createdAt of the oldest-position global-log entry, or null when the log is empty
stats.headCid string | null OPTIONAL additive telemetry. CID of the global-log tip, or null when the log is empty

capabilities.proof: false is not a valid value. A compliant relay always serves the proof plane. When capabilities.log: false, GET /proof/v1/log returns 501 Not Implemented. Per-chain logs are always available regardless of this setting. When capabilities.content: false, all content plane routes return 501 Not Implemented. When capabilities.revocations: false, the /revocations/v1/* routes return 501 Not Implemented — the same capability-not-supported semantics. When capabilities.index is false or absent, the /index/v0/* routes return 501 Not Implemented. When capabilities.signing is false or absent, the /signing/v0/* routes return 501 Not Implemented. Credential and revocation ingestion are always enabled on the proof plane — they enter through POST /proof/v1/operations like all other operation types.

Capability gates fire first — before authentication, body parsing, or any store lookup — uniformly across every gated family. Where two gates stack (content-plane blob upload is gated by content and then write), the plane-existence gate fires before the write gate.

Lite (pull-only) node — capabilities.write: false

A relay MAY run as a lite pull-only proof node: it verifies, stores, and serves the proof plane, but accepts no writes. When capabilities.write: false, POST /proof/v1/operations returns 501 Not Implemented. Because that endpoint is both the client-write and the peer-gossip-ingest path (a gossiping peer POSTs operations here, and nothing in the request distinguishes a first-party submission from a peer push), refusing it disables gossip-in along with client writes. Such a node stays current by pulling: syncFromPeers polls its peers' /proof/v1/log and ingests verified operations locally. This is the smallest, safest mesh citizen — a tiny attack surface (no untrusted write endpoint) that still contributes verification and availability. All read routes behave normally. dfos serve --no-write runs this mode.

capabilities.write: false means the node accepts no writes of any kind. It gates every write route, on both planes: POST /proof/v1/operations (proof plane) and PUT /content/:contentId/blob/:ref (content-plane blob upload; the ref MUST be the committing operation CID) both return 501 Not Implemented. Blob upload is the one route that accepts a multi-megabyte body, so leaving it open on a node whose whole point is a minimal attack surface would contradict the advertised capability.

To be precise about what "writes" quantifies over: the two replicated planes — proof-plane ingestion and content-plane blob upload, the routes enumerated above. The optional signing mailbox is on neither plane (courier state: never gossiped, never folded, retention bounded by each request's own expiry) and is governed solely by capabilities.signing. A write: false relay MAY therefore serve the mailbox; its no-ingest invariant is exactly "this relay never ingests proof-plane operations," and SIGNING.md's deposit gate — credential-authorized, byte-capped, verify-then-discard — is what keeps that invariant true on a node that couriers signing traffic.

The two flags gate different things and compose:

Flags Result
write: false No writes on either plane. Content-plane reads (blob download) still serve normally
content: false The content plane is absent entirely — all content routes 501, reads included
write: false, content: false A proof-plane-only, read-only node

capabilities.content still governs the content plane as a whole (its reads included); capabilities.write governs only the act of writing.


Operation Log

The relay maintains a global append-only operation log. Every successfully ingested operation (identity ops, content ops, artifacts, countersignatures) is appended to the log in ingestion order.

Global Log (GET /proof/v1/log?after={cid}&limit=N)

Returns log entries starting after the given CID cursor.

{
  "entries": [
    {
      "cid": "bafy...",
      "jwsToken": "eyJhbGciOiJFZERTQSIs...",
      "kind": "identity-op",
      "chainId": "did:dfos:..."
    },
    {
      "cid": "bafy...",
      "jwsToken": "eyJhbGciOiJFZERTQSIs...",
      "kind": "artifact",
      "chainId": "did:dfos:..."
    }
  ],
  "next": "bafy..."
}
Field Type Description
entries[].cid string Operation CID
entries[].jwsToken string The full compact JWS token — makes the log self-contained for sync
entries[].kind string Operation kind: identity-op, content-op, artifact, countersign, revocation, credential
entries[].chainId string DID (identity-op, artifact, credential, and revocation — all keyed to the issuer/signer DID), contentId (content-op), or targetCID (countersign)
next string|null CID to pass as after for the next page — the shared list envelope's resume field. null when the page was not full (caught up)

Parameters:

  • after (optional): cursor from a previous page's next, passed back verbatim. Omit to start from the beginning of the log. Cursors are relay-local and implementation-shaped (the reference relays use the entry CID; other conformant relays MAY use opaque composite tokens): an after the relay does not recognize is a 400, never a silently empty page. A continuously syncing client therefore persists only server-supplied next values — never a fabricated one — and on next: null (caught up) retains its last persisted cursor, cheaply re-fetching the final partial page next cycle; on a 400 it resets its cursor and re-syncs from the start (ingestion is idempotent)
  • limit (optional): Max entries to return. Default: 100. Max: 1000. Values above the max are clamped, not rejected

chainId is not a per-chain partition key. Because credential and revocation entries carry chainId = the issuer DID — the same value as that DID's identity-op and artifact entries — folding the global log per-chain on chainId alone silently co-mingles credentials, revocations, artifacts, and identity ops under a single DID. An indexer reconstructing a specific chain MUST filter by kind first (e.g. kind === 'identity-op' to rebuild the identity chain) rather than grouping on chainId alone.

Pagination is forward-only. The log is ordered by ingestion time. JWS tokens are included in every entry because proof-plane JWS payloads are bounded (chain operations and artifacts have finite size), keeping the log self-contained — a syncing peer can replay the log without separate fetches.

Per-Chain Logs

Identity and content chains expose their own log views with the same cursor-based pagination:

  • GET /proof/v1/identities/:did/log?after={cid}&limit=N
  • GET /proof/v1/content/:contentId/log?after={cid}&limit=N

Same pagination envelope and rules as the global log — after + limit in, next out, unknown cursor 400. Per-chain log entries include { cid, jwsToken } — the chain-specific subset of the global log entry shape. Returns operations belonging to that chain in chain order. The 400-on-unknown-cursor rule carries a correctness payoff here: a client paging a content chain whose head switched branches under it (fork + head selection) is told its cursor is off the served branch and restarts, instead of being silently told it is caught up on a branch that is no longer the head. (An identity chain is linear, so its per-chain cursor can only advance.)


Identity and Content State

State endpoints return projected state — the computed result of replaying the chain — without embedding the full operation log.

Identity State (GET /proof/v1/identities/:did)

{
  "did": "did:dfos:abc123...",
  "headCID": "bafy...",
  "state": {
    "did": "did:dfos:abc123...",
    "isDeleted": false,
    "authKeys": [...],
    "assertKeys": [...],
    "controllerKeys": [...],
    "services": [...]
  }
}

Resolved identity state includes the identity's services — the controller-signed discovery vocabulary (relay locators and stable content anchors) projected from the winning head. See Services in the protocol spec. Read-through and sync replicate the underlying identity operations, so a peer that fetches an identity chain recomputes the same services set deterministically.

Content State (GET /proof/v1/content/:contentId)

{
  "contentId": "abc123...",
  "genesisCID": "bafy...",
  "headCID": "bafy...",
  "state": {
    "contentId": "abc123...",
    "genesisCID": "bafy...",
    "headCID": "bafy...",
    "isDeleted": false,
    "currentDocumentCID": "bafy...",
    "length": 1,
    "creatorDID": "did:dfos:..."
  }
}

This response is frozen with protocol v1 and carries pure chain state — no derived authorization material. Public-read discovery (surfacing the aud: "*" credentials that currently authorize read on a chain) is a document gateway concern on the 0.x clock, not a proof-plane field: keeping it off the frozen route lets that ergonomic evolve without touching the locked contract. See DOCUMENT-GATEWAY.md → Public-read discovery.

Chain history is available via the per-chain log routes described above.


Content Plane Access

This section describes the content plane as the relay serves it today. The standalone gateway contract — the stateless, proof-plane-derived authorization model, where authority is re-derived live every request and any materialized public-credential index is a non-authoritative cache — is specified in DOCUMENT-GATEWAY.md.

Content plane requests carry a self-signed auth token in the Bearer header to prove caller identity (verified against the issuer's current identity state — a rotated-out key cannot mint one; see Key Resolution).

Auth-token lifetime ceiling: the relay rejects an auth token whose declared lifetime (exp − iat) exceeds a configured maximum (default 24 hours), returning 401. Auth tokens are ephemeral by design (minutes); this ceiling stops a buggy or malicious signer from minting an effectively-permanent bearer token. It applies only to auth tokens — DFOS credentials (read/write/standing) are verified on a separate path and may carry hours-to-months lifetimes. Setting the maximum to ≤ 0 disables the ceiling.

Blob Upload (PUT /content/:contentId/blob/:ref)

The upload path mirrors the download path — the operation CID identifies which operation's document is being uploaded.

Requirements:

  • Valid auth token (Bearer header)
  • The operation CID must reference an operation in this content chain that has a documentCID
  • The authenticated DID must be either the chain creator OR the signer of the referenced operation (enabling delegated uploads)
  • The uploaded bytes must hash to the operation's documentCID (dag-cbor + sha-256 verification)

Blobs are stored by (creatorDID, documentCID) — always keyed to the chain creator regardless of who uploads. If multiple content chains by the same creator reference the same document, the blob is shared (deduplication).

Blob Download (GET /content/:contentId/blob[/:ref])

Requirements:

  • If a standing authorization exists for the content (a public credential with aud: "*" covering the resource): access is granted without any auth token or per-request credential
  • Otherwise, a valid auth token (Bearer header) is required, plus:
    • If the caller is the chain creator: no further credentials needed
    • If the caller is not the creator: must present a DFOS credential with action: "read" in the X-Credential header, with a delegation chain rooting at the creator

The optional :ref parameter selects which operation's document to return:

  • head (default): the current document at chain head
  • An operation CID: the document committed by that specific operation

Enumerating a Chain's Documents

There is deliberately no relay-side document list route — it would only be a relay-decoded convenience over state the client can re-derive verifiably. Fetch GET /content/:contentId/blob for the document at head, GET /content/:contentId/blob/:ref for the document any specific operation committed (immutable), and GET /proof/v1/content/:contentId/log to enumerate the chain's operations, each carrying its documentCID. This composition is strictly more verifiable: every blob is checked against its committed documentCID, and the op log is the frozen proof-plane enumeration.

Standing Authorization

Instead of presenting a read credential on every request, a DFOS credential with aud: "*" (public) can be ingested by the relay as a standing authorization — once ingested, matching content plane requests are authorized without an X-Credential header. Credential ingestion uses POST /proof/v1/operations: DFOS credentials are submitted as JWS tokens alongside other proof plane operations and stored in the op log like any other operation (addressable by CID, carried in the global log as kind: "credential").

Authority is re-derived live, not read from a stored flag. On every content plane access the relay re-verifies the standing credential against current proof-plane state — signature, issuer-key resolution, temporal validity, revocation, and a delegation chain rooted at the content creator — through the same verifier the per-request (X-Credential) path uses. The two paths differ only in where the credential came from and an audience check that public (aud: "*") credentials skip; revocation is checked symmetrically on both, at every link of the delegation chain. See DOCUMENT-GATEWAY.md → The unified verifier.

A relay MAY keep a materialized index of ingested public credentials (resource → candidate credentials) to make standing-auth lookup O(1). That index is a performance optimization, not authority — every candidate it yields is re-verified live before it can authorize, so a stale or revoked entry cannot grant access. It is a re-verified, non-authoritative cache over the op log, fully re-derivable from it.

A standing authorization stops granting access the moment any live check fails:

  • The credential expires (temporal validity)
  • The credential — or any parent in its delegation chain — is revoked
  • The issuer's identity (or any delegating identity) is deleted

These are evaluated live per request, so the effect is immediate; no cache invalidation is required for correctness.

Revocation Ingestion

Revocation artifacts (typ: did:dfos:revocation) are ingested via POST /proof/v1/operations alongside other proof plane operations. When a revocation is accepted:

  1. The revoked credential's CID is recorded against its issuer, together with the revocation's own signed createdAt — the boundary the as-of rule compares against (see below)
  2. Standing authorization backed by that credential stops granting — the live per-request revocation check denies it on the next read (a relay that keeps a candidate index MAY also evict the entry eagerly, but the live check is what guarantees immediacy)
  3. Future content chain operations embedding the revoked credential as authorization are rejected
  4. Future content plane requests presenting the revoked credential are rejected

Revocation is permanent and immediate. See CREDENTIALS.md for the revocation payload format. A relay MAY additionally expose a read over the revocation index it keeps for this enforcement — see Revocation Status.

Ingest asks freshness; re-verification asks validity. Points 2–4 are all acceptance decisions, answered from what the relay currently knows: a relay MUST refuse a new operation authorized by a credential it already holds a revocation for, whatever that operation's createdAt claims — otherwise backdating would buy a revoked delegate an indefinite write window. Re-verifying operations the relay has already committed is the other question, and it is answered as of each operation's own createdAt per CREDENTIALS.md "Acceptance vs Validity". Concretely, a relay replaying a chain's history (for example to compute content-chain state at a fork point, or to re-verify a synced log) MUST NOT reject an operation because a credential in its history was revoked later — that operation was authorized when it was signed, and rejecting it would make legitimately committed history fail re-verification. The two rules coexist without tension: acceptance is local and timely, and the ingest verdict never enters the replicated log; historical validity is deterministic and therefore identical on every relay, forever.

Content Following

The operation log federates the proof plane: identity chains, content chains, public-read credentials, and revocations are all pushed and gossiped between peers. The content plane — the document bytes — is deliberately not on that wire. A relay MAY nonetheless make those bytes available locally by following: pulling the documents of the content chains it is authorized to read, content-addressed and gated by the grant. This turns a relay from a proof mirror into a true edge cache that serves public content independently of the origin that authored it.

Following is a per-relay, optional behavior on the content plane's own 0.x clock; it adds no wire surface and changes nothing for a relay that does not opt in. The reference Go relay exposes it as CONTENT_FOLLOW=eager (default none). The shape, normatively:

  • Pull, not push. A follower fetches blobs from its peers over the existing public blob route (GET /content/:contentId/blob[/:ref]). Blobs are never gossiped; there is no new endpoint.
  • The materialize gate is the serve gate. A follower materializes a chain's bytes only while a standing public-read grant authorizes anonymous read of it — the same predicate (hasPublicStandingAuth) the serve path checks. So a chain that is private, revoked, or deleted is never followed.
  • Verified by hash, trustless in source. Every pulled blob is checked against the documentCID the chain committed (its content address) before it is stored. A follower may therefore pull from any peer; a byte that does not hash to its committed CID is rejected.
  • Eventually consistent. Authorization arrives instantly (the grant rides the log); the bytes arrive asynchronously. Between the two, a follower that is authorized but has not yet materialized a blob returns 404 blob not found on GET /content/:contentId/blob[/:ref] — the honest "authorized-but-not-yet-materialized" state, not an error. A conforming follower converges to serving the bytes; it need not do so instantaneously. See DOCUMENT-GATEWAY.md → Follower materialization.
  • Convergent, ordering-immune. Following is driven by a sweep over the chains the follower already holds in local state, so it cannot be raced by op-ingest ordering (a credential op sequences before the content op it grants). The sweep is the correctness backbone; low-latency triggers, if any, are an optimization over it.
  • Revoke is correctness-free; GC is reclamation. When a grant is revoked, the per-request serve gate immediately makes any cached bytes unreachable — correctness needs nothing more. Reclaiming the now-orphaned bytes (deleting them) is a separate, convergent garbage-collection pass keyed on the same gate, and is purely a storage concern.

Revocation Status (v1)

A relay that enforces revocation already maintains an (issuerDID, credentialCID) revocation set (see Revocation Ingestion). The revocation status route family exposes an indexed, read-only projection of that set, so a client can ask "has this credential been revoked?" or "what has this issuer revoked?" with one GET instead of replaying the operation log.

Like the universal resolver, this family is a frozen v1 contract at the relay root on its own v1 clock — it is NOT part of, and is not mounted under, the frozen /proof/v1 proof plane. Nothing about ingestion changes: revocations remain ordinary proof-plane operations (typ: did:dfos:revocation), submitted via POST /proof/v1/operations and gossiped like everything else. A relay advertises support via capabilities.revocations in the well-known; when unsupported, the routes return 501 Not Implemented (capability not supported, consistent with content/log). Reference relays serve the index by default and expose the flag as a construction option; disabling it changes only these read routes — revocation enforcement (ingest, content-plane, deposit gates) runs regardless, and a client MUST treat a 501 here as "this relay does not answer revocation-status questions," never as a negative answer.

Credential Status (GET /revocations/v1/credential/:credentialCID)

{
  "credentialCID": "bafyrei…",
  "revoked": true,
  "revocation": "eyJhbGciOiJFZERTQSIs…"
}

This credential-status route is frozen AS-IS.

  • 200 revoked — includes revocation, the full revocation JWS token.
  • 200 not revoked{ "credentialCID": "…", "revoked": false }, no revocation key.
  • 400 — the param is not a well-formed credential CID (CIDv1 dag-cbor + sha256, bafyrei + 52 base32 chars).

The JWS is the proof; the boolean is a convenience. A zero-trust caller does not take the relay's word for it: it re-verifies the returned revocation token itself — signature against the issuer's identity chain, CID integrity, kid-DID == payload did, and the issuer-only rule (only the credential's issuer can revoke it). The relay serves the index; the operation carries its own authority.

Absence is NOT proof of non-revocation. revoked: false is the honest known-nothing answer: it attests only that this relay has not ingested a revocation for that CID. A relay can be behind, partitioned, or adversarially withholding. A caller that needs stronger assurance queries a quorum of independent relays (and gossip makes withholding progressively harder) — the same trust posture as every other read in the protocol.

If more than one issuer has revoked the same credential CID (possible, since the set is scoped per issuer and the issuer-only rule is enforced at credential verification rather than ingest), the relay MUST answer deterministically: the revocation with the lexicographically smallest issuerDID. Callers re-verifying the JWS against the credential's actual issuer are unaffected either way.

Issuer Revocations (GET /revocations/v1/issuer/:did?after={cid}&limit=N)

{
  "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr",
  "revocations": [{ "credentialCID": "bafyrei…", "revocation": "eyJhbGciOiJFZERTQSIs…" }],
  "next": null
}
  • 200 — every revocation this relay has ingested for the issuer, sorted by credentialCID ascending, forward-only cursor-paginated: after (a credentialCID cursor, omit to start from the first) and limit (default 100, max 1000, values above the max clamped); the response carries next — the credentialCID to pass as after for the next page, null when the page was not full (caught up). The enumeration key IS the cursor, so resumption is a strictly-greater keyset: an after that is not a present key resumes at the next greater key, cursors survive concurrent ingestion and cross-relay replay, and — decisively — a revocation whose signer-claimed createdAt is backdated can never be inserted behind a client's cursor and silently skipped, which a time-ordered enumeration could not promise (createdAt is author-claimed and unbounded downward). Chronology is a client-side sort over the returned rows, each of which carries its self-proving JWS. An issuer with none returns an empty array — same honest-absence semantics as above.
  • 400 — the param is not a canonical 31-char did:dfos identifier.

Index (v0)

A relay that verifies and folds chains already holds current-state projections — terminal states, standing public-read grants, per-chain logs. The index route family exposes read-only, cursor-paginated queries over those projections: enumerate identities, filter content chains, reverse-look-up countersignatures by witness, enumerate held public credentials. It exists so a light client can browse and discover without replaying the global operation log — the same role the revocation status family plays for credential state, generalized.

The family lives at /index/v0/* on its own 0.x clock — NOT part of the frozen /proof/v1 proof plane, and (unlike /revocations/v1) not yet frozen itself. A relay advertises support via capabilities.index in the well-known; when unsupported (or when the flag is absent — relays predating this family), the routes return 501 Not Implemented. Nothing about ingestion changes.

Hints, Not Authority

Index responses are discovery hints, never authority. Every row carries the identifiers needed to re-derive its claims from the frozen proof plane — a client that cares fetches GET /proof/v1/identities/:did / GET /proof/v1/content/:contentId (and the per-chain logs) and folds the chain itself. The trust posture is asymmetric and worth stating precisely:

  • The index cannot lie by assertion. Every claim in a row is verifiable against the proof plane. A fabricated row fails the client's fold.
  • The index CAN lie by omission. A relay can be behind, partitioned, or adversarially withholding rows — and a light client cannot detect a recall gap. Absence of a row is NOT proof of absence. A caller that needs stronger recall queries a quorum of independent relays, or replays GET /proof/v1/log and folds locally — the full-log replay remains the audit posture that keeps every index honest.
  • Index output MUST NOT be used as an authorization input. The publicRead field is a discovery hint; content plane access is always re-derived live by the gateway's unified verifier (see DOCUMENT-GATEWAY.md).

The Structural Seam

The index is bounded by one invariant:

Every index field and filter MUST be computable from protocol-defined fields and verification outcomes alone. Document payloads are opaque, except the well-known projections below.

The invariant has a semantic reading that bounds the query vocabulary itself:

The index knows who acted, when, and what things call themselves. Nothing else.

Every index field and filter is an instance of one of three axes — actor (creator, signer, witness, issuer), clock (genesisAt / headAt / operation or artifact createdAt / relay ingestedAt), or name (the display-name registry). Artifacts occupy the actor and clock axes: their JWS signing DID and signed/observed timestamps make standalone documents enumerable without interpreting their content. The credit projection is the one enumerated assertion-tier entry on the actor axis: who a public head document says made it, projected under its own rule below and never conflated with the proof-tier actor filters. A query that cannot be phrased under these axes is not an index feature: it is a client-composed filter over them, a client-side fold over verified bytes, or /search (deferred, on its own clock).

Concretely, the index may serve:

  • Structural facts the relay already computes to verify: chain kind, genesis/head CIDs, op counts, creator DIDs, operation signer sets, deletion state, countersign witnesses and targets, credential issuer/subject/scope, revocation status, standing public-read grants, identity service entries.
  • Declared labels, matched as opaque strings: an artifact or document $schema, a ContentAnchor label. The relay matches these byte-for-byte the way an HTTP server serves a Content-Type — it never interprets what they mean.

What the index MUST NOT do: interpret document payloads, join across application semantics, rank, or reify application concepts. There is no "posts" endpoint and never will be — an application-level notion like post is a client-composed filter expression over structural parts (docSchema=… & publicRead=true & creator=…), not an index concept. This keeps the relay's query vocabulary closed while the application vocabulary composed on top stays open.

Well-Known Projections

The display-name registry is the sole exception to payload opacity, enumerated here rather than left to implementations. Its rule: one display-name field per enumerated $schema — the index may know what a thing calls itself, never what it says. A registry row names an exact $schema, the single payload field that schema uses as its display name, and the index surface where the projection appears. Nothing else is ever extracted — no descriptions, bodies, summaries, or payload timestamps; what a document says is client-fold or /search territory, never an index axis. The single structured exception is the credit projection below — its own enumerated rule with the same circuit-breaker posture, sanctioned by CREDITS.md's relay-awareness design pass.

# Projection Definition
1 profile/v1 → name For an identity whose terminal services contain a ContentAnchor whose label, lowercased, equals "profile" and whose anchor is a content-chain identifier: if the relay holds the bytes of that chain's current head document, and the decoded document declares $schema: "https://schemas.dfos.com/profile/v1", and its name is a non-empty string — the index surfaces that name on the identity row's profile projection.
2 post/v1 → title For a content chain whose current head document bytes the relay holds: if the decoded document declares $schema: "https://schemas.dfos.com/post/v1" and its title is a non-empty string — the index surfaces that title on the content row.

Every registry row carries the same structural circuit breakers — every escape hatch resolves to the honest unknown, never a guess:

  • A document under any $schema NOT in this table is never field-extracted, no matter how tempting its shape.
  • A listed schema whose document is malformed, whose extracted field is missing, empty, or not a string — the projected field is null. No partial parses, no coercion.
  • Bytes the relay does not hold — docSchema: null and the projected field null (see coverage, below).
  • A chain that is not publicly readable — the projected publicRead is false — never surfaces its extracted display-name field: the projected value is null. Confidentiality of the underlying documents is enforced at the application layer by whoever serves them (see PROTOCOL.md); the index MUST NOT project a non-public document's extracted fields onto its anonymous surface. Only the extracted display-name value is withheld — the structural anchor / publicRead / docSchema fields (a declared label matched as an opaque string, never document content) still project.

Extracted values are attribution-tier claims: the value is whatever the signed head document says (row 1 is additionally reached through a controller-signed anchor). Clients verify by fetching the chain and re-hashing the served bytes to the committed documentCID. Additions to this table require an amendment to this spec — one display-name field per schema, and a relay MUST NOT ship extraction rules that are not listed here.

Credit Projection

The second and only other payload extraction, with its own rule: the index may know who a public document says made it. For a content chain whose current head document bytes the relay holds, whose decoded document declares $schema: "https://schemas.dfos.com/post/v1", and whose credits is an array: each entry with a string did projects one row onto the credits family carrying (contentId, did, role, position, hasClaim)role is the entry's string role or null, position is the entry's array index (0 is the primary author), and hasClaim is whether the entry carries a string claim. Nothing else in the entry is projected: name is a rendering convenience whose authoritative surface is the credited DID's own profile projection, and the claim token itself stays inside the document bytes.

The projection is governed by three normative rules on top of the registry's circuit breakers (which all apply — unlisted schemas, malformed entries, and unheld bytes project nothing):

  • Public-only, structurally. Credit rows exist only while the chain's projected publicRead is true and the chain is not deleted. This is a strictly harder line than the display-name null-out: a non-public chain has zero credit rows — there is no redacted variant, and the family MUST NOT be usable to probe non-public content (CREDITS.md — attribution must be no more public than the content it attributes).
  • Head-only, full-replace. Every recompute derives the content's complete row set from the current head document and replaces the previous set. A revision that drops a credited entry drops its row; a head whose new document bytes are not yet held clears the set (rows reappear when the bytes land) — the family never serves a previous head's credits.
  • Assertion-tier, unverified. Rows restate what the signed head document asserts. The relay never verifies a credit claim: hasClaim is byte-presence, not validity — the four verification states remain a client fold over the fetched document per CREDITS.md. Relays remain credit-claim-unaware as verifiers; this projection makes them aware of credits only as public-document structure.

Determinism and Coverage

  • Deterministic enumeration. By default identity, content, artifact, credential, and countersignature lists are ordered lexicographically ascending by their cursor key (did, contentId, or cid); identities, content, artifacts, and countersignatures additionally accept the route-specific time orderings below. Operations are the deliberate exception: they are a recency feed and default to ingestedAt.desc. Two relays holding the same operations serve identical author-time ordering and identical structural fields; relay-observed ingestion ordering is local by definition and need not converge across relays. Held-bytes-dependent fields (docSchema, projected values) additionally require the same held blobs to agree, per coverage below.
  • Keyset pagination is the normative shared envelope. Every index route paginates identically — after (keyset cursor), limit (default 100, max 1000), next (the resume cursor, or null when caught up) — over the route's enumeration order. New index routes inherit this envelope; none may invent another. In the default lexical mode after is a strictly-greater cursor: a page returns the rows whose (post-filter) cursor key is lexicographically greater than after, ascending, capped at limit; next is the last returned row's key, or null when the page was not full (caught up). The cursor need not be a currently-present key — a value that falls between keys, or names a row that was mutated out of the active filter between pages, resumes at the next greater key rather than truncating to an empty page. This makes enumeration stable under concurrent row changes: keys are immutable natural identifiers, so a row's filtered membership or projected values may change between pages without dropping or duplicating other rows.
  • Time-ordered enumeration (order=). /identities and /content accept order=genesisAt.desc (newest chains first) or order=headAt.desc (most recently active first). The sort key is the composite (timestamp descending, cursor key ascending) — the timestamps are the same author-claimed, head-selection-trusted createdAt values already surfaced as genesisAt / headAt, so ordered pages are exactly as deterministic and convergent across relays as the lexical default: an ordering of claimed times, never receipt times, inheriting precisely the trust posture of the fields it sorts by. In ordered mode after and next are opaque cursor tokens — a client resumes by passing next back verbatim and MUST NOT parse or construct one; the token encoding is implementation-internal. Envelope semantics carry over (limit bounds, strictly-past-the-composite-key resumption), with one honest weakening of the lexical mode's stability guarantee: genesisAt.desc sorts by an immutable key and is fully stable under concurrent row changes, but headAt is a mutable sort key. It is monotonically non-decreasing (per-branch timestamp ordering plus max-over-tips head selection — an accepted operation can only raise it), so a chain updated mid-enumeration moves strictly toward the top of headAt.desc — into pages already served. An in-flight headAt.desc enumeration therefore never duplicates a row but MAY miss one that was updated while paginating; the row is not gone, it has moved to the front of a fresher enumeration. This is the correct contract for a recency feed — clients refresh from the top; completeness remains the job of the lexical enumeration (immutable keys, fully stable) or the log replay. Absent order, enumeration is the lexical default above — existing clients are untouched. Only the two enumerated values exist; an unrecognized value is a 400.
  • Operation recency ordering. /operations accepts createdAt.desc and ingestedAt.desc and defaults to ingestedAt.desc; /artifacts and /countersignatures accept the same values while retaining lexical CID order when order is absent. All use the same descending-timestamp/CID-ascending composite and opaque ordered cursor described above. createdAt is author-claimed; ingestedAt is when this relay accepted the operation, so it is relay-local browse chronology rather than protocol authority. Unrecognized orders and undecodable ordered cursors are 400.
  • Boolean parameters fail closed. A boolean filter is either absent (no filter), exactly true, or exactly false. A present empty or otherwise unparseable value is 400 with { "error": "invalid boolean" }; it never silently widens to an unfiltered query.
  • Coverage is bounded by held bytes. docSchema and projected fields are computable only for chains whose current head document bytes the relay holds (uploaded, or pulled via content following). A chain whose bytes are absent reports docSchema: null — honest unknown, not a claim of schemalessness. A docSchema filter therefore matches only chains with held, decodable head bytes; callers MUST treat the result as a lower bound. Artifacts are the exception to the missing-bytes case: a verified artifact carries its document inline in the stored JWS, so its bytes and required $schema string are always held by construction; accepted artifact rows therefore have a non-null docSchema (the nullable wire shape preserves the index's honest-unknown convention defensively).
  • Timestamps are author-claimed. genesisAt / headAt surface the createdAt fields signed inside the operations — the same values head selection uses — not relay receipt times.
  • Maintenance. The index is fully re-derivable from the operation log plus held blobs. Reference implementations maintain it incrementally at ingestion and expose a rebuild path for pre-existing corpora; either way the serving contract is identical.
  • publicRead is a last-touch snapshot, and MAY lag time-based transitions. A materialized publicRead reflects whether a standing public-read grant authorized anonymous read at the moment the row was last recomputed (its content's most recent op, or a rebuild). One input to that predicate — a grant credential's exp — is wall-clock-relative and crosses without emitting any operation, so incremental maintenance has no event to react to: a row can continue to advertise publicRead: true after the grant that made it public has expired, until the next op dirties that content or a rebuild reruns the projection. This is deliberately tolerated because the index is a discovery hint, never an authorization input (see Hints, Not Authority) — the content plane re-derives hasPublicStandingAuth live on every read, where exp is always evaluated against the current clock. A relay MAY additionally re-sweep public rows near expiry to tighten the hint, but is not required to.

Operations (GET /index/v0/operations?kind=&chainId=&order=&after={cursor}&limit=N)

Enumerates the operations this relay holds as metadata-only recency rows. This is a non-authoritative browse ordering: createdAt is the author-claimed timestamp signed into the operation (iat, normalized to ISO 8601, for credentials), while ingestedAt is the relay-observed acceptance timestamp. Neither establishes a consensus clock.

{
  "operations": [
    {
      "cid": "bafyrei…",
      "kind": "content-op",
      "chainId": "a3n7r3nde8e4keeak92rr3aeztftvc2",
      "createdAt": "2026-04-02T00:00:00.000Z",
      "ingestedAt": "2026-04-02T00:00:01.123Z"
    }
  ],
  "next": null
}

Parameters: kind (optional exact match, one of identity-op, content-op, artifact, countersign, revocation, or credential; 400 otherwise), chainId (optional exact match against the operation-log routing identifier), order (createdAt.desc or ingestedAt.desc, default ingestedAt.desc), after (the opaque ordered cursor), and limit (default 100, max 1000). A signer filter is deliberately omitted: the reference stores do not retain operation signer DID as indexed metadata, and this browse route does not re-decode the corpus to manufacture one.

Rows are browsing metadata, never proof: they contain no JWS, payload, title, or name. chainId has the same structural visibility already present in /index/v0/content and the proof-plane global log, including for non-public or deleted chains; it does not expose document bytes or projected display names.

Artifacts (GET /index/v0/artifacts?cid=&signer={did}&docSchema=&order=&after={cid}&limit=N)

Enumerates standalone signed artifacts, cid ascending by default; order=createdAt.desc / order=ingestedAt.desc select the shared recency ordering described above. This closes the only primitive that otherwise requires full-log replay for enumeration.

{
  "artifacts": [
    {
      "cid": "bafyrei…",
      "signerDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4",
      "createdAt": "2026-04-02T00:00:00.000Z",
      "ingestedAt": "2026-04-02T00:00:01.123Z",
      "docSchema": "https://example.com/schema/v1"
    }
  ],
  "next": null
}

Parameters: cid (optional exact artifact-CID match, returning zero or one row; composes with the remaining filters), signer (optional exact DID from the artifact JWS kid; 400 when malformed), docSchema (optional exact opaque match against the inline document's $schema), order (optional createdAt.desc or ingestedAt.desc; lexical CID order when absent), after (a CID keyset cursor in lexical mode or an opaque ordered cursor in ordered mode), and limit (default 100, max 1000). Filters are ANDed. Rows contain no artifact payload or display-name projection; clients fetch the artifact from the proof plane and verify it before use.

Identities (GET /index/v0/identities?did=&hasPublicProfile=&nameContains=&order=&after={did}&limit=N)

Enumerates identity chains, did ascending by default; order=genesisAt.desc / order=headAt.desc select time-ordered enumeration (recently arrived / recently active) per Determinism and Coverage.

{
  "identities": [
    {
      "did": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4",
      "headCID": "bafyrei…",
      "opCount": 4,
      "genesisAt": "2026-03-25T00:00:00.000Z",
      "headAt": "2026-04-02T00:00:00.000Z",
      "isDeleted": false,
      "profile": {
        "anchor": "a3n7r3nde8e4keeak92rr3aeztftvc2",
        "publicRead": true,
        "docSchema": "https://schemas.dfos.com/profile/v1",
        "name": "asha"
      }
    }
  ],
  "next": null
}
Field Type Description
opCount number Operations stored for this chain — identity chains are strictly linear, so this is the linear operation count (content-chain opCount in the content family below remains branch-inclusive)
genesisAt / headAt string Author-claimed createdAt of the genesis and current head operations
profile object | null The well-known projection, or null when the identity declares no profile-labeled content-chain anchor
profile.anchor string The anchored contentId — the client's verification pointer
profile.publicRead boolean Whether a standing public-read grant currently authorizes anonymous read of the anchored chain, per this relay's fold — a hint, never an access decision
profile.docSchema string | null $schema declared by the held head document; null when bytes are not held or not decodable
profile.name string | null Extracted per the projection table; null on any circuit breaker — including when profile.publicRead is false (a non-public profile never projects its name)

Parameters: did (optional exact DID match, returning zero or one row; composes with the remaining filters), hasPublicProfile (optional boolean filter on the predicate "profile is non-null AND profile.publicRead is true" — true keeps only rows where it holds, false keeps only rows where it does not, absent applies no filter), nameContains (optional case-insensitive substring filter over projected profile.name; non-authoritative/amber; applied before keyset pagination), order (optional time ordering — genesisAt.desc or headAt.desc; 400 on any other value), after (a did keyset cursor in the lexical default — returns rows with did strictly greater — or an opaque token in ordered mode), limit (default 100, max 1000). Multiple profile-labeled anchors resolve deterministically to the one with the lexicographically smallest service id.

Content Chains (GET /index/v0/content?contentId=&creator={did}&signer={did}&docSchema=&documentCID=&publicRead=&isDeleted=&titleContains=&order=&after={contentId}&limit=N)

Enumerates content chains, contentId ascending by default; order=genesisAt.desc / order=headAt.desc select time-ordered enumeration per Determinism and Coverage. All filters are ANDed exact matches.

{
  "content": [
    {
      "contentId": "a3n7r3nde8e4keeak92rr3aeztftvc2",
      "genesisCID": "bafyrei…",
      "headCID": "bafyrei…",
      "creatorDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4",
      "isDeleted": false,
      "opCount": 3,
      "genesisAt": "2026-03-25T00:00:00.000Z",
      "headAt": "2026-04-02T00:00:00.000Z",
      "currentDocumentCID": "bafyrei…",
      "publicRead": true,
      "docSchema": "https://schemas.dfos.com/profile/v1",
      "title": null
    }
  ],
  "next": null
}

title is the display-name registry projection for content rows (row 2, post/v1 → title): null for any chain whose held head document is not an enumerated schema, on any circuit breaker (including a non-public chain — publicRead: false — whose title is never projected), or when bytes are not held — an honest unknown, never a guess. Content-chain opCount is branch-inclusive — log length across all branches, not head-branch length.

Parameters: contentId (optional exact match, returning zero or one row; composes with the remaining filters), creator (exact DID — the chain's genesis signer; 400 when malformed), signer (exact DID — keeps chains in which the DID signed at least one accepted operation, branch-inclusive: "has signed in this chain," not "signs the current head lineage"; operations on branches later deleted or abandoned still count — the log records that the signature happened; 400 when malformed), docSchema (exact opaque string match against held head bytes — a lower bound, per coverage above), documentCID (exact match against the projected currentDocumentCID — the reverse lookup "who published this document"), publicRead (boolean), isDeleted (boolean exact match against terminal deletion state), titleContains (optional case-insensitive substring filter over projected title; non-authoritative/amber; applied before keyset pagination), order (optional time ordering — genesisAt.desc or headAt.desc; 400 on any other value), after (a contentId keyset cursor in the lexical default — returns rows with contentId strictly greater — or an opaque token in ordered mode), limit (default 100, max 1000). This is the reverse lookup "what content does DID X own" plus the composition surface for application-level queries — e.g. a client's notion of public posts by X is creator=X&docSchema=<its post schema>&publicRead=true, and its notion of recent public posts is order=headAt.desc&docSchema=<its post schema>&publicRead=true, composed client-side.

When titleContains is present, the query is implicitly restricted server-side to publicRead=true rows. A non-public chain's title is never projected, and titleContains MUST NOT be usable to probe non-public rows. Explicitly combining titleContains with publicRead=false is a 400 with { "error": "invalid filter combination" }.

Post-class content chains are not tombstoned from this structural index when their subject is deleted. Concealment belongs to the credential/blob plane, so a deleted subject's chain metadata — identifiers, operation CIDs, and timestamps — remains enumerable. Consumers that do not want deleted rows use isDeleted=false; absent isDeleted preserves the complete metadata enumeration.

signer is an actor-axis verification outcome, deliberately raw: the creator matches their own chains (the creator signs genesis), and "contributed to but did not create" is client-composed as signer=X minus creator=X. It is proof-tier — the DID's key actually signed accepted operations, revealing nothing not already derivable from the public per-chain log — and it is never an authorship or credit claim: credits is assertion-tier and never enters this filter. Its sanctioned surface is the separately-declared credits family, public head documents only.

Countersignatures by Witness (GET /index/v0/countersignatures?witness={did}&relation=&order=&after={cid}&limit=N)

The reverse of the proof plane's by-target route: every countersignature this relay has ingested signed by the given witness DID, ordered by countersignature CID ascending.

{
  "witness": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr",
  "countersignatures": [
    {
      "cid": "bafyrei…",
      "targetCID": "bafyrei…",
      "relation": "endorses",
      "jwsToken": "eyJhbGciOiJFZERTQSIs…"
    }
  ],
  "next": null
}

witness is required (400 when missing or malformed); relation is an optional exact match against the countersign's opaque open-namespace tag; order optionally selects createdAt.desc or ingestedAt.desc; after is a countersignature-cid keyset cursor in lexical mode or an opaque token in ordered mode; limit defaults to 100 and maxes at 1000. The row's relation is null when omitted by the signer. Each entry carries the full JWS — self-proving, same posture as the issuer revocations feed: the caller re-verifies the token rather than trusting the row.

Credentials (GET /index/v0/credentials?issuer={did}&resource=&action=&after={cid}&limit=N)

Enumerates the relay's held public credentials, cid ascending.

{
  "credentials": [
    {
      "cid": "bafyrei…",
      "issuerDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4",
      "aud": "*",
      "att": [{ "resource": "chain:a3n7r3nde8e4keeak92rr3aeztftvc2", "action": "read" }],
      "exp": 1775088000,
      "jwsToken": "eyJhbGciOiJFZERTQSIs…"
    }
  ],
  "next": null
}

Parameters: issuer (optional exact DID — 400 when malformed), resource (optional exact match against an att[].resource; when the requested resource starts with chain:, the chain:* wildcard bucket is always unioned in because a chain:* grant may authorize the named chain), action (optional exact match against an att[].action, with the same candidate-match posture as resource), after (a credential-cid keyset cursor — returns rows with cid strictly greater), limit (default 100, max 1000). Filters are ANDed. Rows project the verified public audience (aud, necessarily "*") and unix expiry (exp); expired rows remain enumerable and clients may filter them locally.

Only public credentials (aud: "*") are ever held by the relay. Targeted bearer credentials never enter relay storage, so they are neither enumerable nor leakable here.

This route is amber and relay-asserted: resource=chain:Y returns a superset of candidates (exact chain:Y plus any chain:*). Each entry carries the full JWS — self-proving, same posture as the issuer revocations feed. The caller folds each token against the proof plane (delegation roots at Y's creator, revocation, expiry) before treating it as authorization; the relay makes no authorization claim in this index row.

Credits (GET /index/v0/credits?did=&contentId=&role=&after={cursor}&limit=N)

Enumerates the credit projection's rows: who public head documents say made them. This is the sanctioned person-to-public-works lookup — did=X answers "which publicly readable documents credit X," the query CREDITS.md refuses to serve for anything less than public content.

{
  "credits": [
    {
      "contentId": "a3n7r3nde8e4keeak92rr3aeztftvc2",
      "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr",
      "role": "photography",
      "position": 1,
      "hasClaim": true
    }
  ],
  "next": null
}

Parameters: did (optional exact match against the credited entry's did), contentId (optional exact match, returning that chain's current public credit set in position order), role (optional exact opaque-string match; entries without a role match only its absence — there is no substring or vocabulary matching), after / limit per the shared envelope. Filters are ANDed. Enumeration order is (contentId ascending, position ascending); because the natural key is composite, after and next are opaque cursor tokens in every mode (the ordered-mode rule of the shared envelope, applied always) — a client resumes by passing next back verbatim. Rows for one content may be replaced wholesale between pages (a head revision recomputes the set); as with headAt.desc, an in-flight enumeration never duplicates a row but MAY miss one that changed while paginating — completeness for a single chain is contentId= on a fresh page, or the document itself.

Every row is amber and assertion-tier: it restates the current public head document, carries no claim token, and makes no validity claim (hasClaim is byte-presence). A consumer that needs the proof tier fetches the chain, re-hashes the head document, and runs the CREDITS.md verification algorithm over the embedded entry — the row's only job is to say which documents are worth fetching. Join contentId against /index/v0/content for titles, schemas, and clocks, and did against /index/v0/identities for profiles.

Deferred from v0

  • /search — anything beyond case-insensitive substring filters over the display-name registry's name and title projections. Tokenization, ranking, fuzzy matching, and their normalization semantics are deliberately kept off this clock; if they ship, they ship as their own explicitly-unstable family, never frozen into the index contract. nameContains and titleContains are the index's search ceiling and relocation candidates when /search exists.
  • Fork/tips visibility — remains deferred at the proof-plane level (see What's Deferred).

Key Resolution

The relay uses two key resolution strategies:

  • Historical resolver (for chain re-verification): searches all keys that have ever appeared in an identity chain's log, including rotated-out keys. This is necessary because re-verifying a full content chain from genesis must resolve keys from operations signed before a key rotation.
  • Current-state resolver (for live authentication): only resolves keys in the identity's current state. After a key rotation, the old key immediately stops working for auth tokens. This prevents a compromised rotated-out key from being used to authenticate new requests. A deleted identity has no live-authentication standing at all: a relay MUST reject an auth token whose issuer's current state is deleted, however valid its signature — deletion is the terminal off-switch, and live authentication is exactly the question deletion answers. (Re-verification of committed history is untouched, per the historical resolver above.)

Which primitive uses which resolver:

  • Current-state resolver — auth tokens. A rotated-out key cannot mint an auth token, preventing stale-key auth.
  • Historical resolver — identity and content chain re-verification, artifacts, revocations, and countersignatures once committed. These are historical facts whose signing key may since have rotated out, so re-verifying them must resolve against every key that ever appeared in the chain's head lineage; re-verifying them under current state would break sync of honest operations after any rotation. Their invalidation mechanism is revocation or deletion, not key rotation.
  • Historical resolution governs re-verification and peer sync, not first admission. The same acceptance/re-verification split already stated for credential revocation above — ingest asks freshness; re-verification asks validity — applies to signing keys. A relay accepting a new artifact, countersignature, or content-chain operation submitted directly to it resolves the signer against the identity's current state: an operation freshly signed by a rotated-out key is refused at the door, whatever its createdAt claims — otherwise rotation would leave a compromised key an indefinite authoring window. Rotation ends a key's authoring window, including for operations composed before the rotation but never submitted; the honest remedy is re-signing with a current key. Once an operation is committed — accepted by this relay, or ingested from a peer's committed log — it is a historical fact and re-verifies historically, forever; the admission verdict never enters the replicated log, so cross-relay convergence is untouched. Peer-log ingestion deliberately inherits the peer's admission discipline: choosing peers is a trust decision, and a relay that peers with a lax admitter accepts that admitter's door policy for the history it replays. Identity-chain extension admission itself is unchanged by this rule — its signer requirement is the prior-state controller rule in the core protocol's Chain Validity section. Credentials and credit claims are untouched: their historical resolution is a MUST in their own specs, and revocation — not rotation — is their invalidation mechanism.
  • Credentials are the exception to the auth grouping. Although a credential proves authorization, it uses the historical resolver and survives key rotation — a credential signed before a rotation remains valid afterward. Revocation (not key rotation) is the invalidation mechanism for credentials. See CREDENTIALS.md.

Storage Interface

The relay delegates persistence to a RelayStore interface. Implementations handle how data is stored — the relay handles what to store and when.

interface RelayStore {
  getOperation(cid: string): Promise<StoredOperation | undefined>;
  putOperation(op: StoredOperation): Promise<void>;

  getIdentityChain(did: string): Promise<StoredIdentityChain | undefined>;
  putIdentityChain(chain: StoredIdentityChain): Promise<void>;

  getContentChain(contentId: string): Promise<StoredContentChain | undefined>;
  putContentChain(chain: StoredContentChain): Promise<void>;

  getBlob(key: BlobKey): Promise<Uint8Array | undefined>;
  putBlob(key: BlobKey, data: Uint8Array): Promise<void>;

  getCountersignatures(operationCID: string): Promise<string[]>;
  addCountersignature(operationCID: string, jwsToken: string): Promise<void>;

  appendToLog(entry: LogEntry): Promise<void>;
  // `null` (the whole result, not the `next` field) = the store does not
  // recognize `after` — the relay MUST answer 400, never an empty page. A
  // store that cannot signal this would silently mask foreign cursors as
  // caught-up, permanently stalling any peer that trusted the answer.
  readLog(params: {
    after?: string;
    limit: number;
  }): Promise<{ entries: LogEntry[]; next: string | null } | null>;

  // chain state at arbitrary CID (content fork verification; identity historical state)
  getIdentityStateAtCID(
    did: string,
    cid: string,
  ): Promise<{ state: VerifiedIdentity; lastCreatedAt: string } | null>;
  getContentStateAtCID(
    contentId: string,
    cid: string,
  ): Promise<{ state: VerifiedContentChain; lastCreatedAt: string } | null>;

  // peer sync cursors
  getPeerCursor(peerUrl: string): Promise<string | undefined>;
  setPeerCursor(peerUrl: string, cursor: string): Promise<void>;
}

The getIdentityStateAtCID / getContentStateAtCID methods compute materialized chain state at an arbitrary operation CID. Content-fork verification is the driving use — the ingestion pipeline needs state at the fork point to verify signer authority and timestamp ordering (identity chains are linear, so identity ingestion extends only the trusted head state; the identity variant serves historical state queries). Implementations decide how: MemoryStore replays from genesis, SQLiteStore can use snapshot tables.

The package includes MemoryRelayStore for development and testing. Production deployments would implement the interface over Postgres, SQLite, D1, or any durable store.


Quick Start

import { createHttpPeerClient, createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';

// JIT mode — generates relay identity + profile artifact at startup
const relay = await createRelay({
  store: new MemoryRelayStore(),
});

// With peering
const relay = await createRelay({
  store: new MemoryRelayStore(),
  peers: [{ url: 'https://peer.relay.example.com' }],
  peerClient: createHttpPeerClient(),
});

// Mount on any Hono-compatible runtime
export default relay.app;

// Schedule sync polling
setInterval(() => relay.syncFromPeers(), 30_000);

The returned CreatedRelay includes app (Hono), did (string), and syncFromPeers (async function). The Hono app exposes:

Method Path Plane Auth
GET /.well-known/dfos-relay meta none
POST /proof/v1/operations proof none
GET /proof/v1/operations/:cid proof none
GET /proof/v1/countersignatures/:cid proof none
GET /proof/v1/identities/:did proof none
GET /proof/v1/identities/:did/log proof none
GET /proof/v1/content/:contentId proof none
GET /proof/v1/content/:contentId/log proof none
GET /proof/v1/log proof none
GET /1.0/identifiers/:did meta none
GET /revocations/v1/credential/:credentialCID revocations none
GET /revocations/v1/issuer/:did revocations none
GET /index/v0/operations index none
GET /index/v0/identities index none
GET /index/v0/content index none
GET /index/v0/artifacts index none
GET /index/v0/countersignatures index none
GET /index/v0/credentials index none
GET /index/v0/credits index none
POST /signing/v0/requests signing deposit credential (in body)
GET /signing/v0/requests signing auth token
POST /signing/v0/requests/:cid/response signing none — validity is the auth
GET /signing/v0/requests/:cid/response signing none — CID knowledge
POST /signing/v0/requests/:cid/decline signing none — advisory
PUT /content/:contentId/blob/:ref content auth token
GET /content/:contentId/blob[/:ref] content standing auth, or auth token + credential

Peering

Relay-to-relay peering enables data replication across the network. The relay expresses peering intent through a PeerClient interface (injected like Store) and per-peer configuration flags.

Three Behaviors

Behavior Trigger Mechanism
Gossip-out New op ingested Push to peers with gossip: true
Read-through Local 404 on GET Fetch from peers with readThrough: true
Sync-in Scheduled poll Pull from peers with sync: true via /proof/v1/log

Gossip fires on new status only — duplicate results are not re-gossiped, preventing gossip storms. Read-through applies to identity chains and content chains only — operations and countersignatures are not read-through targets. When triggered, the relay fetches the full chain log from a peer and ingests locally (full verification, no trust). Sync-in uses cursor-based pagination against the peer's global log.

Peer Configuration

interface PeerConfig {
  url: string;
  gossip?: boolean; // default: true
  readThrough?: boolean; // default: true
  sync?: boolean; // default: true
}

No relay roles or types. Topology is emergent from configuration. A relay with gossip: true, readThrough: false, sync: false is a write-only edge node. A relay with gossip: false, readThrough: true, sync: false is a read-only cache.

PeerClient Interface

The PeerClient is injected like Store — semantic per-resource methods, not raw HTTP. The default implementation uses HTTP. Tests inject mocks that route directly to another relay's API in-process.

interface PeerClient {
  getIdentityLog(
    peerUrl: string,
    did: string,
    params?: { after?: string; limit?: number },
  ): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;

  getContentLog(
    peerUrl: string,
    contentId: string,
    params?: { after?: string; limit?: number },
  ): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;

  // `null` = transport/peer failure; `'invalid-cursor'` = the peer explicitly
  // rejected `after` (400) — the distinguished value the sync loop's self-heal
  // requires. A client that collapses the 400 into `null` leaves the puller
  // retrying a dead cursor forever after a peer wipes or rebuilds its log.
  getOperationLog(
    peerUrl: string,
    params?: { after?: string; limit?: number },
  ): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;

  submitOperations(peerUrl: string, operations: string[]): Promise<void>;
}

Each method corresponds to a peering behavior: getIdentityLog / getContentLog support read-through, getOperationLog supports sync-in, and submitOperations supports gossip-out. A PeerLogEntry is { cid: string; jwsToken: string }. The per-chain log methods surface a mid-walk 400 the same distinguishable way ('invalid-cursor'): a content-chain fork whose head switches while a read-through walk is in flight invalidates the walk's cursor, and the correct response is one restart of that walk from the beginning — not silently treating the chain as fully fetched.

Sync discipline (normative for pullers). A puller persists only peer-supplied next values — never a cursor fabricated from an entry CID (a peer whose cursor format is not a bare CID would 400 it). On next: null (caught up) it retains its last persisted cursor and cheaply re-fetches the final partial page next cycle; a peer whose whole log fits in one page is therefore re-read each cycle — a deliberate, bounded cost (one page, dedup-idempotent) accepted in exchange for never fabricating. On 'invalid-cursor' it resets: at most once per peer per sync cycle (a second rejection in the same cycle aborts the cycle for that peer rather than looping), and the reset is persisted only after a from-scratch fetch succeeds — so one spurious 400 from an intermediary cannot destroy a real high-water mark.


Convergence

The protocol guarantees: given the same set of operations, any relay computes the same deterministic head state. Peering (gossip, read-through, sync) replicates operations across relays. But operations may arrive before their causal dependencies — a content extension before its identity chain, a content fork before the branch it forks from. A relay MUST eventually process any structurally valid operation whose causal dependencies have been processed. This is the convergence contract.

Causal Dependencies

An operation's causal dependencies are the minimum state required for verification:

Operation type Dependencies
Identity genesis None
Identity extension Previous identity operation (by previousOperationCID)
Content genesis Creator's identity chain (for key resolution)
Content extension Previous content operation + creator's identity chain
Artifact Signer's identity chain
Countersignature Signer's identity chain + target operation

If all causal dependencies are present, the operation MUST be verifiable. If any dependency is missing, the operation cannot be verified yet — but it is not invalid. The relay MUST retain it and re-attempt verification when the missing dependency arrives.

Store-Then-Verify

A relay MUST NOT discard a structurally well-formed operation because its dependencies are temporarily unavailable. The implementation strategy is store-then-verify:

  1. Store: on receipt (via POST /proof/v1/operations, gossip, sync, or read-through), store the raw JWS token in a content-addressed buffer keyed by operation CID. This is idempotent — duplicate CIDs are ignored.

  2. Verify: attempt full verification against current state. Three outcomes:

    • Sequenced — verification succeeded, operation committed to chain state and global log
    • Dependency failure — a causal dependency is missing, operation remains in the buffer
    • Permanent rejection — structurally invalid, bad signature, deleted identity, a conflicting extension of a committed identity operation, etc. — will never succeed regardless of what state arrives
  3. Sequence loop: after each ingestion batch, re-attempt all buffered operations in dependency order until no further progress is made (fixed-point). This ensures cross-batch dependencies resolve immediately — when batch B provides the identity that batch A's content operation was waiting for, the sequencer resolves it within B's response cycle.

Dependency Failures

A rejection is a dependency failure if and only if it is caused by missing state that may arrive later via peering. The set is small and stable:

  • Previous operation not yet in store (previousOperationCID unknown)
  • Identity chain not yet available (key resolution fails)
  • Content chain not yet created (genesis not arrived)
  • Content-chain fork state cannot be computed (ancestor in branch path not yet available)

All other rejections are permanent. Permanent rejections MUST NOT be retried. In particular, an identity operation refused as a conflicting extension is a permanent rejection — the identity chain is linear, and no state that arrives later can make a second child of a committed parent valid.

Serialization

All chain-state mutations (ingestion + sequencing) MUST be serialized. Concurrent ingestion of operations for the same chain produces a read-modify-write race: two goroutines read the chain log, both append their operation, and the second write clobbers the first. Raw operation storage (putRawOp) does not require serialization — it is idempotent and append-only.

Convergence Bound

Given a fully connected peer mesh where every relay syncs from every other relay:

  • After one sync cycle, every relay has every operation that any peer accepted (stored as raw)
  • After one sequencer pass, every operation whose full dependency chain exists locally is sequenced
  • Deterministic head selection ensures all relays agree on the canonical head

In practice, the dependency depth for chain operations is 1 (each op depends on its immediate predecessor). Convergence is typically achieved in a single sync + sequence cycle.

Storage Interface (Convergence)

The RelayStore interface extends with methods for raw operation buffering:

// raw ops — content-addressed store for all received operations
putRawOp(cid: string, jwsToken: string): Promise<void>;
getUnsequencedOps(limit: number): Promise<string[]>;
markOpsSequenced(cids: string[]): Promise<void>;
markOpRejected(cid: string, reason: string): Promise<void>;
countUnsequenced(): Promise<number>;
resetSequencer(): Promise<void>;

What's Deferred

  • Peer discovery: Static configuration only — no dynamic discovery
  • SSE/realtime push: Polling GET /proof/v1/log for now, SSE in the future
  • Fork visibility API: Dedicated endpoint to list a content chain's tips/branches
  • Search: fuzzy/tokenized name queries — deliberately excluded from the index contract; would ship as its own explicitly-unstable family
  • Branch termination op: Protocol-level operation to explicitly kill content-chain fork branches
  • Rate limiting / anti-spam: Operational concern, not protocol concern
  • Blob size limits: No enforcement yet — production deployments should add limits at the middleware layer
  • Artifact $schema registry: Schema names are free-form strings for now — no formal registry or validation beyond structural checks