DFOS Relay
A relay receives, verifies, stores, and serves the DFOS proof plane: identity chains, content chains, artifacts, countersignatures, credentials, and revocations. It optionally stores and serves the document bytes those chains commit to. This document is the HTTP wire and the ingest rules for anyone implementing or operating one.
Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay.
Source · npm · Go relay · Protocol
What a relay is
A relay verifies everything it accepts and serves everything it has verified. It does not issue identity, grant permissions, or define content semantics.
A relay serves one linear view per identity. An identity chain is linear per view (PROTOCOL, Views). Two identity operations that claim the same chain position are two views of that identity. A relay admits the first successor it sees for a position and refuses later ones, so the log it serves is one linear chain. Which relay you read is which view you get. No rule in this corpus arbitrates between relays that admitted different successors at one position.
A relay carries authorship proofs; it is not an authority over them. Every read it serves is re-derivable by the reader from operations the reader verifies itself. A relay's verdict on what it will hold is local. A signature is not.
Content chains need no choice of view: they are DAGs, and deterministic head selection resolves the same operation set to the same head on every relay (PROTOCOL, Chain validity).
Two planes
The proof plane is public and replicates. Signed chain operations, artifacts, countersignatures, credentials, and revocations are cryptographic proofs that anyone can verify with a public key. Relays push them to peers and pull them from peers; every route that reads them is unauthenticated, because the operations carry their own authentication.
The content plane holds document bytes and does not replicate. The blobs a
content chain commits to via documentCID are never pushed on the operation log.
A blob enters a relay by upload to the relay that holds the chain. Its integrity
is its documentCID, so served bytes are checkable against the chain whatever
their source. Access is host-cooperative: the operator reads what it stores, and
anything that must stay confidential against the operator is withheld or
encrypted above the protocol.
The well-known document
GET /.well-known/dfos-relay is part of the contract. It is unauthenticated,
ungated, and rooted, per RFC 8615; the
dfos-relay suffix is registered in the
extension registry's external-registrations table.
A client reads it before it calls anything else.
{
"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...",
"openapi": "/openapi.json",
"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). The proof-plane version lives in the /proof/v1 path prefix, not here |
capabilities |
object | Which route families this relay serves |
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 and content-plane blob upload |
capabilities.content |
boolean | Whether the relay serves the content plane (blob upload and download) |
capabilities.log |
boolean | Whether the global operation log is served (GET /proof/v1/log) |
capabilities.revocations |
boolean | Whether the revocation status reads are served (GET /revocations/v1/*). An absent flag reads as true |
capabilities.index |
boolean | Whether the index query family is served (GET /index/v0/*). An absent flag reads as false |
capabilities.signing |
boolean | Whether the signing mailbox is served (/signing/v0/*). An absent flag reads as false |
ingestion |
string | Admission-mode hint: "open", "proof-required", or "closed". Absent derives from capabilities.write: true reads as "open", false as "closed" |
profile |
string | The relay's profile artifact as a compact JWS token, self-proving payload |
openapi |
string | OPTIONAL. URL of an OpenAPI document describing this relay's HTTP surface, absolute or root-relative against the relay's base URL. Serving the document is SHOULD, never MUST; a relay that serves one advertises it here, and a relay that serves no document omits the field. The document is discovery, never authority: the routes, capability gates, and auth rules in this specification govern regardless of what an advertised document says |
peers |
array | OPTIONAL telemetry. Configured peer relays, surfaced for mesh discovery; reference relays emit [] when no peers are configured |
peers[].endpoint |
string | OPTIONAL telemetry. The peer relay's base URL |
stats |
object | Operational counters. stats.pendingOps is the count of operations pending sequencing (-1 if unavailable) |
stats.opCount |
number | OPTIONAL telemetry. Entries the relay holds in its global operation log. On a relay that prunes or culls this is below the log's tip position and is not a proxy for it. Counts are relay-local and not comparable across relays |
stats.countsByKind |
object | OPTIONAL telemetry. Global-log counts by primitive kind, operations, never chains: the identity bucket counts identity operations, not identities. Reference relays emit identity, content, artifact, credential, countersign, revocation |
stats.oldestOpAt |
string | null | OPTIONAL telemetry. createdAt of the oldest-position global-log entry, or null when the log is empty |
stats.headCid |
string | null | OPTIONAL telemetry. CID of the global-log tip, or null when the log is empty |
stats.peerSync |
object | OPTIONAL telemetry. Per-peer sync state keyed by endpoint: lastAttemptAt, lastSuccessAt, lastReceived, lastInserted, caughtUp, consecutiveFailures, lastReconcile*, pinMismatch. Absent when no sync peer is configured |
Capabilities are how a relay honestly declines a family
A relay that does not serve a family says so in capabilities and answers
501 Not Implemented on that family's routes. 501 says the capability is
absent; 404 would say the resource is. The distinction is what lets a client tell
"this relay does not answer that question" from "there is no such thing".
capabilities.proof: falseis not a valid value. A compliant relay always serves the proof plane.capabilities.log: falsemakesGET /proof/v1/loga 501. Per-chain logs are served regardless.capabilities.content: falsemakes every content-plane route a 501, reads included.capabilities.revocations: falsemakes every/revocations/v1/*route a 501. Revocation enforcement runs regardless: only the reads are gated.capabilities.index: falseor absent makes every/index/v0/*route a 501.capabilities.signing: falseor absent makes every/signing/v0/*route a 501.capabilities.write: falsemakesPOST /proof/v1/operationsandPUT /content/:contentId/blob/:ref501s. See Write-disabled relays.
Credential and revocation ingestion have no flag of their own: they enter through
POST /proof/v1/operations like every other operation kind.
Capability gates fire first, before authentication, body parsing, or any
store lookup, uniformly across every gated family. Where two gates stack, blob
upload is gated by content and then by write, the plane-existence gate fires
first.
Relay identity and profile
Every relay has a DID that resolves on its own proof plane. It is the relay's peer identity when it gossips, and its self-proof anchor for anyone querying it.
The relay MUST publish a profile artifact signed by its own DID using the head
key state, under 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": "DFOS Inc" }]
}All fields are optional except name, which SHOULD be present. The optional
links array carries up to 20 { uri, label?, description? } entries. The
profile JWS token is inlined in the well-known response, so it is self-proving
and needs no second fetch.
The read contract
Every route below is served when the relay holds the data and the family's capability is on. Reads are unauthenticated: the proof plane is public and its operations carry their own authentication.
| Method | Path | Purpose |
|---|---|---|
POST |
/proof/v1/operations |
Submit proof-plane operations |
GET |
/proof/v1/operations/:cid |
One stored operation by CID |
GET |
/proof/v1/countersignatures/:cid |
Countersignatures on a target CID |
GET |
/proof/v1/identities/:did |
Projected identity state |
GET |
/proof/v1/identities/:did/log |
Identity chain operation log |
GET |
/proof/v1/content/:contentId |
Projected content-chain state |
GET |
/proof/v1/content/:contentId/log |
Content chain operation log |
GET |
/proof/v1/log |
Global ingestion-ordered operation log |
GET |
/revocations/v1/credential/:credentialCID |
Revocation status of one credential |
GET |
/revocations/v1/issuer/:did |
Every revocation this relay holds for an issuer |
POST /proof/v1/operations is the one write route on this table; it is behind
capabilities.write and subject to the relay's admission policy,
and it is specified in The write contract. Everything else
above is a read.
The /proof/v1 prefix encodes the plane and its version ({plane}/{version}),
so the plane mounts or proxies as a unit by prefix. /revocations/v1 sits at the
relay root on its own prefix: revocations enter through
POST /proof/v1/operations, and this family exposes a read over the index a
relay already maintains for its own enforcement.
Four more families sit at the root, each on its own prefix:
GET /.well-known/dfos-relay (above),
GET /1.0/identifiers/:did (the DIF Universal Resolver binding,
DID-METHOD), GET /index/v0/*
(Index), and /signing/v0/*
(Signing mailbox). Content-plane blob
routes live at /content/:contentId/blob*, distinct from the proof node's
/proof/v1/content/:contentId paths, so a reverse proxy can fan the two planes
across origins by prefix.
Error body
Every route 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 exceptions:
POST /proof/v1/operations MAY carry an additive details array of per-item
schema issues beside error on a 400 (diagnostic in the same way), and the
universal resolver answers in the DIF resolution envelope its own interface
requires.
A store failure is answered with the same body and a 5xx status. A relay MUST NOT answer a store failure with an empty result: see A store error is never absence.
Pagination envelope
Every list route paginates identically: limit (default 100, max 1000, values
above the max clamped, never rejected) plus after (cursor from a previous
page's next, passed back verbatim; omit to start) in, next out, the last
returned row's cursor, or null when the page was not full (caught up). An
unrecognized or undecodable cursor is a 400, never a silently empty page.
Cursor conduct splits three ways, and each route names which it has:
| Conduct | Routes | An unrecognized after... |
|---|---|---|
| Relay-local positional | the ingestion-ordered logs (global and per-chain) | 400: cursors are meaningful only against the relay that issued them, and the client restarts its walk from the beginning (ingestion is idempotent) |
| Transparent keyset | countersignature reads, /revocations/v1/issuer/:did, the index families in their lexical default |
resumes strictly past after whether or not it names a present row, so even a foreign cursor is safe and never errors |
| Opaque token | the index families in ordered mode, the mailbox poll | resumes strictly past the encoded key when the token decodes; an undecodable token is 400 |
A continuously syncing client persists only server-supplied next values, never
a fabricated one, and on next: null retains its last persisted cursor, cheaply
re-fetching the final partial page next cycle. On a 400 it resets and re-syncs
from the start. No route family invents another envelope.
Operation read (GET /proof/v1/operations/:cid)
{ "cid": "bafy…", "jwsToken": "eyJ…", "chainType": "identity", "chainId": "did:dfos:…" }chainType is one of identity, content, artifact, countersign,
revocation, credential; chainId is the routing identifier (DID for
identity-keyed kinds, contentId for content operations, target CID for
countersignatures). 404 when the CID is not held.
Identity state (GET /proof/v1/identities/:did)
{
"did": "did:dfos:…",
"headCID": "bafy…",
"state": {
"did": "did:dfos:…",
"isDeleted": false,
"authKeys": [...],
"assertKeys": [...],
"controllerKeys": [...],
"services": [...]
}
}Projected state: the computed result of replaying the chain to its head under the
core verification rules, including the identity's services discovery vocabulary
(PROTOCOL, Services). Key arrays carry
the effective state, so a key membership whose introduction carries no valid
possession envelope is absent from them
(PROTOCOL, Key possession). Pure
chain state: no derived authorization material rides this route.
A deleted identity still resolves, carrying isDeleted: true. A sealed chain
stays addressable forever, because it is the verification substrate for
everything that identity ever signed.
Content state (GET /proof/v1/content/:contentId)
{
"contentId": "…",
"genesisCID": "bafy…",
"headCID": "bafy…",
"state": {
"contentId": "…",
"genesisCID": "bafy…",
"headCID": "bafy…",
"isDeleted": false,
"currentDocumentCID": "bafy…",
"length": 1,
"creatorDID": "did:dfos:…"
}
}Pure chain state. Which credentials currently authorize a public read is a content-plane question, answered live on the read path, and it does not ride this route.
Logs
Global (GET /proof/v1/log?after={cursor}&limit=N): every successfully
ingested operation in ingestion order, entries { cid, jwsToken, kind, chainId },
kind one of the six primitive kinds. JWS tokens are included in every entry
because proof-plane payloads are bounded, so a syncing peer replays the log
without separate fetches. Cursors are relay-local and implementation-shaped (the
reference relays use the entry CID; another relay MAY use an opaque composite
token), and an after the relay does not recognize is a 400.
chainIdis not a per-chain partition key.credentialandrevocationentries carrychainId= the issuer DID, the same value as that DID'sidentity-opandartifactentries, so folding the global log per-chain onchainIdalone silently co-mingles kinds under one DID. An indexer reconstructing a specific chain MUST filter bykindfirst.
Per-chain (GET /proof/v1/identities/:did/log,
GET /proof/v1/content/:contentId/log): the chain's operations in chain order,
entries { cid, jwsToken }, same envelope and same 400-on-unknown-cursor rule.
An identity chain is linear, so its per-chain cursor can only advance. A content
chain's log enumerates every branch the relay accepted, not the head lineage,
so a client folding it applies head selection itself.
Countersignatures (GET /proof/v1/countersignatures/:cid?after={cid}&limit=N)
Returns { "countersignatures": [{ "cid", "jwsToken" }], "next" }, sorted by each
countersignature's own CID ascending under the transparent keyset conduct: an
after that is not a present key resumes at the next greater key, so cursors
survive concurrent additions and cross-relay replay. Works for any CID-addressable
target. 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, and the row fields are conveniences.
The row shape matches per-chain log entries, so a generic client handles both identically.
Revocation status
Credential (GET /revocations/v1/credential/:credentialCID):
{ "credentialCID": "bafyrei…", "revoked": true, "revocation": "eyJ…" }200 revoked includes revocation, the full revocation JWS. 200 not revoked is
{ "credentialCID": "…", "revoked": false } with no revocation key. 400 when
the param is not a well-formed credential CID (bafyrei + 52 base32 chars). If
more than one issuer has revoked the same CID, the relay answers deterministically
with the lexicographically smallest issuerDID's revocation.
Issuer (GET /revocations/v1/issuer/:did?after={cid}&limit=N):
{
"did": "did:dfos:…",
"revocations": [{ "credentialCID": "bafyrei…", "revocation": "eyJ…" }],
"next": null
}Every revocation this relay has ingested for the issuer, sorted by
credentialCID ascending under the transparent-keyset conduct, so a revocation
whose signer-claimed createdAt is backdated can never be inserted behind a
client's cursor and silently skipped. 400 when the param is not a canonical
31-char did:dfos identifier. An issuer with none returns an empty array.
The JWS is the proof; the boolean is a convenience. A caller re-verifies the
returned revocation token itself: signature against the issuer's identity chain,
CID integrity, and the issuer-only rule. And absence is NOT proof of
non-revocation: revoked: false attests only that this relay has not ingested a
revocation for that CID. A caller that needs stronger assurance queries several
independent relays. A 501 from this family means the relay does not answer
revocation-status questions, and a client MUST NOT read it as a negative answer.
The two shapes carry a known privacy trade.
GET /revocations/v1/credential/:credentialCID is OCSP-shaped
(RFC 6960) and tells the relay exactly
which credential a verifier is checking.
GET /revocations/v1/issuer/:did is CRL-shaped
(RFC 5280 §5): fetch the
issuer's whole set and check locally, disclosing nothing per check at the cost of
the transfer. A verifier picks its half.
Full route surface
Every route a reference relay serves, with its plane and its authentication.
| Method | Path | Plane | Auth |
|---|---|---|---|
GET |
/.well-known/dfos-relay |
meta | none |
POST |
/proof/v1/operations |
proof | admission policy: anonymous or identity proof |
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 | identity proof |
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 | identity proof |
GET |
/content/:contentId/blob[/:ref] |
content | standing auth, or identity proof + credential |
A relay SHOULD serve an OpenAPI
document describing this surface, advertised in the well-known's openapi field.
The write contract
All proof-plane operations enter through one endpoint,
POST /proof/v1/operations, behind capabilities.write. Identity operations,
content operations, artifacts, countersignatures, credentials, and revocations
mix freely in one batch.
Submission (POST /proof/v1/operations)
The request body is { "operations": [ "<JWS>", … ] }, classified by each token's
JWS typ header (the
extension registry names the
values). A batch carries at most 100 tokens; a larger array is a 400. The
reference implementations additionally guard the aggregate body at 16 MiB
(413; a client that sees 413 chunks and retries, while 400 remains the
malformed-content verdict). Gossiping peers chunk larger runs to stay within the
caps.
The response is { "results": [ { "cid", "status", "error"? } ] } in the same
order as the input array: results[i] corresponds to operations[i],
regardless of internal processing order, with exactly three statuses:
| Status | Meaning |
|---|---|
new |
First time seen, verified, stored, state changed |
duplicate |
Already held: the exact same CID and JWS token, a true idempotent resubmission. Same CID with a different token is rejected (Ed25519 is deterministic, so a different token for the same payload means a different signing key) |
rejected |
Verification failed (error says why, diagnostically) |
Duplicate countersignatures (same witness DID, same target CID) MUST be
deduplicated: one countersign per witness per target, a relay MUST NOT store
multiple attestations from the same witness for the same target, and resubmission
SHOULD return duplicate.
Admission
Who may ingest is a policy axis, not a fixed rule, and peers are not special:
gossip-in, client submission, and open deposit are one door with one grammar. A
submission arrives in one of two admission modes, anonymous (no proof) or
identity-proven (an
identity proof
signed by the submitting party's own DID, jti required), and the relay evaluates
a relay-local admission policy over what it now knows: a proven principal, or
anonymity. Policy MAY refuse either mode. Policy content is operator-defined and
outside this specification: one relay admits only DIDs its operator recognizes,
another is open-anonymous under quotas, another is allowlist-only. "My peers" is
one possible policy set, not a separate authentication scheme.
The evaluation ladder is normative, cheapest first:
- Structural caps: batch size, body size, token shape. Failures are 400/413.
- Proof verification, when a proof is presented: one signature plus a current-state key resolution. An invalid proof is 401; an unresolvable presenter is 503.
- Admission policy over (principal | anonymous). A refusal is 403 with the ordinary error body, distinguishable from malformed (400), from an invalid proof (401), from unverifiable (503), and from capability-off (501). Refusal is request-level: nothing in the batch is examined further, and no per-item results are produced. A policy that cannot be evaluated fails closed (503, the server's condition, not a judgment on the caller).
- Full verification: the per-item work of the sections below, only for admitted submissions. The expensive step is never spent on a submission policy refuses.
The well-known's ingestion member advertises the mode so a client knows before
attempting: "open" (anonymous submissions admitted, subject to policy),
"proof-required" (anonymous refused at step 3), or "closed" (no external
ingestion, and POST /proof/v1/operations answers 501 as under
capabilities.write: false). Advertisement is a hint; the policy decision is the
authority.
The jti replay cache is REQUIRED on every write-shaped proof. An identity
proof presented to ingestion or to blob upload MUST carry the registered
jti member, recorded by the
relay with an atomic insert-if-absent keyed on the presenter and jti, and
expired with the freshness window. An already-seen jti answers 409,
INTEGRATIONS' replayed verdict; a missing or malformed one is 401. Policy runs
before full verification, so the relay grants admission-layer effects (quota
spend, reputation attribution) before it knows whether the payload is a
harmless duplicate. Read-shaped proofs rely on the freshness window alone.
Authentication
The relay owns no authentication grammar. Every authenticated request consumes the request-bound possession-proof envelopes specified in INTEGRATIONS, API authentication, and every route sits in exactly one of three tiers:
| Tier | Wire | Routes |
|---|---|---|
| Public | nothing | every read above, index and revocation reads, publicly-granted blob reads, sign-response collect (CID knowledge) and decline |
| Identity proof | Authorization: DFOS <did:dfos:identity-proof JWS> |
creator's own blob reads, blob upload, the mailbox poll, the authentication half of non-public blob reads, and optionally ingestion |
| Credential | X-Credential presentation, or an ingested standing grant |
non-creator blob reads (alongside the identity proof), mailbox deposit |
Verification is INTEGRATIONS', verbatim: the relay's own configured authority
is the host binding (a relay serving several hostnames selects the expected one
from its own configuration, never from a request header), presenter resolution is
current-state against the relay's local store, and the freshness window is the
relay's to own. A route that requires a credential rejects an identity proof at
the typ gate and the reverse. A relay MUST reject an identity proof whose
presenter's current state is deleted, however valid its signature.
Classification
Each token is classified by its JWS typ header. Classification is unambiguous:
no DID comparison is needed.
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 |
Dependency sort
Within a batch, operations are sorted by dependency priority before processing:
- Identity operations, so their keys are available
- Artifacts, which reference identity keys for signature verification
- Content operations, which reference identity keys and may have chain dependencies
- Countersignatures, which reference identity keys and an existing target
Within each priority level, genesis operations (no previousOperationCID) are
processed before extensions, so one batch bootstraps an entire
identity-and-content lifecycle without multiple round trips.
Verification
Each operation is verified against the relay's stored state.
- Identity operations: extension operations verify against the relay's current trusted state with O(1) extension verification, the trusted head state plus the new operation. Genesis operations verify the single-operation chain.
- Content operations: extension operations verify against trusted state with
enforceAuthorization: true. A non-creator signer MUST carry a DFOS credential withaction: "write"attenuations in the operation'sauthorizationfield. - Revocations: the signature verifies against the revoking DID's identity state, and the payload must reference a valid credential CID.
- Artifacts: the signature verifies against the signing DID's identity state,
CID integrity is checked, the payload conforms to the declared
$schemastructure, and the CBOR-encoded payload MUST NOT exceed 16384 bytes. - Countersignatures: two phases. Stateless, at the protocol level: signature, CID integrity, payload schema. Stateful, at the relay: the target CID MUST exist in the relay, the witness DID MUST differ from the target's author DID, and one countersign per witness per target.
Temporal checks resolve at the basis time:
for these committed artifacts the basis is each operation's own createdAt, so
two relays reach the same verdict on the same operation whenever each one ingests
it. A relay MUST NOT add an ingest-time wall-clock exp check.
Chain resolution
The relay routes each incoming operation to its chain. Resolution differs by kind:
- Identity genesis: no prior chain. The relay verifies the single-operation chain and creates a new stored identity chain keyed by the new DID.
- Identity extension: the
kidin the JWS header is a DID URL (did:dfos:<id>#<keyId>). The relay extracts the DID prefix (before#) and looks up the existing chain. Akidwithout#on a non-genesis operation is rejected: it cannot be routed. - Content genesis: no prior chain. The relay creates a new stored content chain keyed by the content ID derived from verification.
- Content extension: the
previousOperationCIDpayload field looks up a stored operation, which carries thechainId; the relay then fetches the content chain by thatchainId. If the previous operation does not exist or is not a content operation, the extension is rejected. - Countersignatures: the
targetCIDpayload field looks up the target operation, whose author DID enforces the witness-is-not-author rule.
Identity linearity and admission
Conflicting extension is a 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. 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, including direct submission, gossip, sync, and read-through.
A peer-log identity operation that conflicts with a locally-committed extension is
refused identically: committed order in this relay's log is never re-arbitrated.
The refusal is this relay's admission verdict, not a claim that the refused operation is invalid. Two operations at one chain position are two views of the identity (PROTOCOL, Views). What first-seen admission buys is a log no later timestamp can reorder. An identity write this relay refuses is admitted by a relay that holds no successor at that position, so the writer either retries here or continues the chain there.
Possession status never gates admission. The relay admits and sequences every structurally valid identity operation, meaning linearity, CIDs, timestamps, and declared-state signer validity, whatever its key proofs say. This is the relay's door of the three-door rule, normative in PROTOCOL, Key possession. An update introducing a key without a valid proof ingests exactly like any other: the membership it claims is void in every projection the relay serves, and the operation itself is committed log. Rejecting it instead would make log membership depend on semantic validation, and two relays disagreeing about one operation's proofs on a linear chain is a forked log. The log answers what was written; the projections answer what counts.
Content-chain forks and head selection
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.
After accepting a fork the relay recomputes the head: highest createdAt among
tips, lexicographic highest CID as tiebreaker
(PROTOCOL, Chain validity). This
is deterministic across relays given the same set of operations, regardless of
ingestion order.
To verify a fork extension the relay computes chain state at the parent CID. Implementations choose the strategy: full replay, snapshot-backed, or otherwise.
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. Identity undeletion is not a fork
behavior: it is the explicit restore operation on the linear identity chain.
The 24-hour future bound
Identity and content operations with a createdAt more than 24 hours in the
future are rejected. Head selection favors the highest timestamp, so a far-future
createdAt would permanently dominate content-chain head selection. The window
accommodates clock drift. Identity chains select no head, and the same bound
applies to their operations: one admission rule, no per-kind exception.
Deletion and restore
Deletion means the identity stops being an active participant. Historical operations remain verifiable, and 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) is the
single operation a deleted identity's chain accepts. A valid restore returns the
identity to active: resolution reports deactivated: false, and every gate below
reopens for operations that follow it.
- Identity operations after deletion: rejected, except a valid
restoreas the immediate linear successor of thedelete. Anything else appended after a delete, including arestoreanywhere but that position or one signed by a key not in the deleted head state, is permanently rejected. Thedelete, and anyrestore, remain permanently in the linear log. - Content operations after deletion: rejected. Both paths are checked, the signer's identity being deleted and the content chain's creator identity being deleted, and the chain is sealed regardless of who signs.
- Artifacts from deleted identities: rejected.
- Credentials from deleted issuers: rejected. Identity deletion suspends all authority, including outstanding credentials the deleted identity issued.
- Countersignatures from deleted witnesses: rejected. 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. After a valid restore, credentials issued before the delete are honored again, because they were never revoked and their issuer was suspended. 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. A countersignature's semantic is that a distinct witness attests. The protocol-level verifier is stateless and does not enforce this, so the relay resolves the target's author and rejects self-attestation.
Revocations and standing credentials
Revocations are ordinary proof-plane operations. When one is accepted:
- The revoked credential's CID is recorded against its issuer, together with the
revocation's own signed
createdAt. - Standing authorization backed by that credential stops granting: the live per-request check denies it on the next read. A relay that keeps a candidate index MAY also evict the entry eagerly; the live check is what guarantees immediacy.
- Future content-chain operations embedding the revoked credential as
authorizationare rejected. - Future content-plane requests presenting the revoked credential are rejected.
Eviction is issuer-scoped. A revocation retracts only its own issuer's
credential. A relay MUST key the eviction of a standing public credential on
(issuer DID, credential CID), never on the CID alone, so a DID cannot revoke
credentials it did not issue
(CREDENTIALS, Revocation).
Ingest asks freshness; re-verification asks the basis. Points 2 to 4 are
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, because backdating
would otherwise buy a revoked delegate an indefinite write window. Re-verifying an
operation the relay has already committed, when replaying a chain's history or
ingesting a peer's log, resolves at that operation's own createdAt per the
time basis: a relay MUST NOT reject a
committed operation because a credential in its history was revoked later. The
acceptance verdict is local and never enters the replicated log; historical
validity is deterministic and identical on every relay.
Buffering and sequencing
Operations may arrive before their causal dependencies. A relay MUST eventually process any structurally valid operation whose causal dependencies have been processed.
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 |
A relay MUST NOT discard a structurally well-formed operation because its dependencies are temporarily unavailable. The reference strategy is store-then-verify:
- Store: on receipt, store the raw JWS token in a content-addressed buffer keyed by operation CID. This is idempotent, and duplicate CIDs are ignored.
- Verify: attempt full verification against current state. Three outcomes. Sequenced, verification succeeded and the operation is committed to chain state and the global log. Dependency failure, a causal dependency is missing and the operation stays in the buffer. Permanent rejection, structurally invalid, bad signature, deleted identity, or a conflicting extension of a committed identity operation, which will never succeed whatever state arrives.
- Sequence loop: after each ingestion batch, re-attempt buffered operations in dependency order until no further progress is made. Cross-batch dependencies resolve within the providing batch's response cycle.
A rejection is a dependency failure if and only if it is caused by missing state that may arrive later. The set is small and stable: the previous operation is not in the store, the identity chain is not available for key resolution, the content chain's genesis has not arrived, or fork state cannot be computed because an ancestor in the branch path is absent. All other rejections are permanent and MUST NOT be retried. In particular, an identity operation refused as a conflicting extension is permanent.
The raw buffer and the sequencer are internal to the writing relay. They are not
wire surface, and nothing about them is visible to a client beyond
stats.pendingOps.
All chain-state mutations MUST be serialized. Concurrent ingestion of operations for the same chain is a read-modify-write race: two workers read the chain log, both append, and the second write clobbers the first. Raw operation storage is idempotent and append-only and needs no serialization.
A store error is never absence
A store read that fails MUST NOT be treated as a miss, and a store write that fails MUST NOT leave a half-commit. Concretely:
- A failed read during verification is a dependency failure, retryable, never a permanent rejection: a transient failure MUST NOT delete a buffered operation.
- A failed read on an idempotency or existence check is an error answer, never "not held".
- A failed write inside a batch rolls the batch back rather than reporting an outcome it did not persist.
- A failed identity lookup on any authorization path refuses the request rather than falling through to a caller-supplied alternative.
- An unavailable check on the read path denies rather than grants.
The reason is one sentence: a relay that reads absence out of its own failures converts an operational blip into a durable, signed-looking claim about the world.
Write-disabled relays
capabilities.write says whether this relay accepts writes over HTTP. It says
nothing about where the relay's corpus comes from. A relay MAY be an origin,
the authoritative source of its own log, minting operations out of band from
whatever system of record it runs, and refusing external writes precisely because
nothing outside it is entitled to append. A relay MAY equally run as a lite
pull-only proof node that verifies, stores, and serves the proof plane and
accepts no writes, staying current by polling its peers' /proof/v1/log and
ingesting verified operations locally. dfos serve --no-write runs that mode.
Both advertise write: false with ingestion: "closed", and the flag does not
distinguish them: capabilities.write is the admission surface, ingestion
is the admission mode, and where a relay's operations come from is a
deployment fact the wire does not carry.
write: false gates every write route on both planes.
POST /proof/v1/operations and PUT /content/:contentId/blob/:ref both answer
501. Because the POST route is both the client-write and the peer-gossip-ingest
path, and nothing in the request distinguishes them, refusing it disables gossip-in
along with client writes. Blob upload is the one route that accepts a
multi-megabyte body, so leaving it open on a node whose point is a minimal attack
surface would contradict the advertised capability.
"Writes" quantifies over the two replicated planes: proof-plane ingestion and
content-plane blob upload. The signing mailbox
is on neither plane and is governed solely by capabilities.signing, so a
write: false relay MAY serve the mailbox. Its no-ingest invariant is exactly
"this relay never ingests proof-plane operations".
| 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 |
Retention
The relay maintains a global append-only operation log: every successfully ingested operation is appended in ingestion order, and identity and content chains expose per-chain views in chain order.
A relay's log is its own, and no relay's log is the corpus. Append-only names the relay's serving discipline, entries appended in ingestion order and never reordered, not a promise that every relay holds everything forever. A relay is sovereign over its own retention. Peers MUST NOT treat any relay's log as complete, and cross-relay operation counts are not comparable.
- Retraction MUST be expressed as revocation. Deleting bytes from one relay's
log does not propagate: a peer that already synced them keeps them, and nothing
on the wire says they left. The act that travels is the signed one. Retraction
on an identity chain is likewise a chain operation, an
updateremoving the key, never a rewrite of history. - Relays SHOULD retain revocations indefinitely. Revocations are the retraction record and they are tiny. A relay that prunes operations SHOULD prune around them.
- Culling by visibility is sanctioned retention. A relay MAY hold only the
operations of chains that are publicly readable and cull the rest, pruning around
revocations. This is retention, not admission: content visibility is not a
property of a content operation, since a chain is created and then made publicly
readable later by a separate
aud: "*"credential, so no admission policy can express "I host only public content". Two consequences are visible on the wire and are the relay's to own. Revocations outlive what they revoke, so a captured grant reads revoked rather than unknown. The log has holes, so culled operations leave permanent gaps in the relay's sequence andopCountis not a log position. - A cursor past a relay's retention answers
400or a from-scratch first page, never a silently empty page. An empty page that reads as caught-up would convert one relay's retention into every downstream peer's silent gap.
Artifacts and countersignatures on the wire
Artifacts and countersignatures are standalone signed primitives, immutable and CID-addressable, specified in PROTOCOL, Standalone signed statements. The relay's additions are the stateful checks named under Verification and Deletion and restore: target existence, witness is not author, one countersign per witness per target, and the deleted-signer gates.
Profiles
The four families below sit beside the read and write contracts above. The index, the signing mailbox, and the content plane are each behind their own capability flag and answer 501 on every one of their routes when the flag is off. Peering adds no route at all: it is a convention that runs over the contracts above.
Index (capability index)
A relay that verifies and folds chains already holds current-state projections.
The index exposes read-only, cursor-paginated queries over them at
/index/v0/*, so a light client browses and discovers without replaying the
global operation log.
Hints, not authority
Index responses are discovery hints. Every row carries the identifiers needed to re-derive its claims from the proof plane.
- The index cannot lie by assertion. Every claim in a row is verifiable against the proof plane, and a fabricated row fails the client's fold.
- The index CAN lie by omission. A relay can be behind, partitioned, or withholding rows, and a light client cannot detect a recall gap. Absence of a row is not proof of absence.
- Index output MUST NOT be used as an authorization input.
publicReadis a discovery hint; content-plane access is re-derived live on every read.
What the index may know
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 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, and the declared keys through which an actor
acts), clock (genesisAt / headAt / operation or artifact createdAt /
relay ingestedAt), or name (the display-name registry). A query that cannot
be phrased under these axes is a client-composed filter over them, or a
client-side fold over verified bytes.
The index serves structural facts the relay already computes to verify (chain
kind, genesis and head CIDs, op counts, creator DIDs, operation signer sets,
deletion state, countersign witnesses and targets, credential issuer and scope,
revocation status, standing public-read grants, identity service entries) and
declared labels matched as opaque strings (an artifact or document $schema,
a ContentAnchor label), matched byte-for-byte the way an HTTP server serves a
Content-Type.
The index MUST NOT interpret document payloads, join across application
semantics, rank, or reify application concepts. There is no "posts" route: an
application-level notion like post is a client-composed filter expression over
structural parts (docSchema=… & publicRead=true & creator=…).
Well-known projections
The display-name registry is the sole exception to payload opacity. Its rule:
one display-name field per enumerated $schema, so the index may know what a
thing calls itself, never what it says. Nothing else is extracted: no
descriptions, bodies, summaries, or payload timestamps. The one structured
exception is the credit projection.
| # | 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 circuit breakers, and every escape hatch resolves to the honest unknown:
- A document under any
$schemanot in this table is never field-extracted. - A listed schema whose document is malformed, or whose extracted field is
missing, empty, or not a string, projects
null. No partial parses, no coercion. - Bytes the relay does not hold project
docSchema: nulland the fieldnull. - A chain that is not publicly readable (
publicReadisfalse) never surfaces its extracted display-name field: the projected value isnull. Only the extracted value is withheld; the structuralanchor/publicRead/docSchemafields still project.
Extracted values are attribution-tier claims: the value is whatever the signed
head document says. Clients verify by fetching the chain and re-hashing the served
bytes to the committed documentCID. A relay MUST NOT ship extraction rules that
are not listed here.
Credit projection
The second and only other payload extraction: 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 route
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 stays inside the document bytes.
Three normative rules ride on top of the registry's circuit breakers:
- Public-only, structurally. Credit rows exist only while the chain's
projected
publicReadistrueand the chain is not deleted. 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, because attribution must be no more public than the content it attributes (CONTENT-MODEL, Credits). - 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 absent clears the set, and rows reappear when the bytes land.
- Assertion-tier, unverified. Rows restate what the signed head document
asserts.
hasClaimis byte-presence, not validity: the verification states are a client fold over the fetched document.
Determinism and coverage
- Deterministic enumeration. By default identity, content, artifact,
credential, and countersignature lists are ordered lexicographically ascending
by their cursor key (
did,contentId, orcid), and all five additionally accept the route-specific time orderings below. Operations are the deliberate exception: they are a recency feed and default toingestedAt.desc. Two relays holding the same operations serve identical author-time ordering and identical structural fields; relay-observed ingestion ordering is local by definition. - Keyset pagination is the shared envelope. Every index route paginates with
after,limit(default 100, max 1000), andnext, over the route's enumeration order. In the default lexical modeafteris a strictly-greater cursor: a page returns the rows whose post-filter cursor key is lexicographically greater thanafter, ascending, capped atlimit, andnextis the last returned row's key ornullwhen the page was not full. The cursor need not be a currently-present key: a value that falls between keys, or one naming a row mutated out of the active filter between pages, resumes at the next greater key rather than truncating to an empty page. Keys are immutable natural identifiers, so a row's membership or projected values may change between pages without dropping or duplicating other rows. - Time-ordered enumeration (
order=)./identitiesand/contentacceptorder=genesisAt.desc(newest chains first) ororder=headAt.desc(most recently active first). The sort key is the composite(timestamp descending, cursor key ascending), over the same author-claimedcreatedAtvalues surfaced asgenesisAt/headAt, so ordered pages are as deterministic across relays as the lexical default. In ordered modeafterandnextare opaque cursor tokens: a client passesnextback verbatim and MUST NOT parse or construct one. One honest weakening:genesisAt.descsorts by an immutable key and is fully stable, whileheadAtis a mutable sort key. It is monotonically non-decreasing, so a chain updated mid-enumeration moves strictly toward the top ofheadAt.desc, into pages already served. An in-flightheadAt.descenumeration 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. Completeness is the job of the lexical enumeration or the log replay. Only the two enumerated values exist, and an unrecognized value is a400. - Operation recency ordering.
/operationsacceptscreatedAt.descandingestedAt.descand defaults toingestedAt.desc./artifacts,/credentials, and/countersignaturesaccept the same values while retaining lexical CID order whenorderis absent. All use the same descending-timestamp/CID-ascending composite and the same opaque ordered cursor.createdAtis author-claimed;ingestedAtis when this relay accepted the operation, so it is relay-local browse chronology rather than protocol authority. Unrecognized orders and undecodable ordered cursors are400. - Boolean parameters fail closed. A boolean filter is either absent (no
filter), exactly
true, or exactlyfalse. A present empty or otherwise unparseable value is a 400, never a silent widening to an unfiltered query. - Coverage is bounded by held bytes.
docSchemaand projected fields are computable only for chains whose current head document bytes the relay holds. A chain whose bytes are absent reportsdocSchema: null, an honest unknown rather than a claim of schemalessness, and adocSchemafilter matches only chains with held, decodable head bytes, so callers MUST treat the result as a lower bound. Artifacts are the exception: a verified artifact carries its document inline in the stored JWS, so accepted artifact rows have a non-nulldocSchema. - Timestamps are author-claimed.
genesisAt/headAtsurface thecreatedAtfields signed inside the operations, not relay receipt times. - Maintenance. The index is fully re-derivable from the operation log plus held blobs. Projection application is driven by a sequence cursor over the operation log and runs outside the ingestion lock, so index work never widens the transaction that admits an operation. Reference implementations also expose a rebuild path for pre-existing corpora. Either way the serving contract is identical.
publicReadis a last-touch snapshot and MAY lag time-based transitions. A materializedpublicReadreflects whether a standing public-read grant authorized anonymous read at the moment the row was last recomputed. One input to that predicate, a grant credential'sexp, is wall-clock-relative and crosses without emitting any operation, so incremental maintenance has no event to react to: a row can continue to advertisepublicRead: trueafter the grant that made it public has expired, until the next operation dirties that content or a rebuild reruns the projection. This is tolerated because the index is a discovery hint, never an authorization input: the content plane re-derives the predicate live on every read. A relay MAY additionally re-sweep public rows near expiry to tighten the hint.
Operations (GET /index/v0/operations?kind=&chainId=&signerKey=&order=&after={cursor}&limit=N)
Enumerates the operations this relay holds as metadata-only recency rows.
{
"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),
signerKey (optional exact multibase public-key match against the key the row's
verified signature resolved to at ingest, an opaque byte match with no format
validation and no 400), order (createdAt.desc or ingestedAt.desc, default
ingestedAt.desc), after (the opaque ordered cursor), and limit (default 100,
max 1000). Filters are ANDed.
The signer filter is key-addressed, not DID-addressed. A kid is an
identity-local indirection, rebindable across key material by the chain that
defines it; the public key a signature verified against is the immutable fact of
the row, and ingestion already resolved it to verify the operation. A
DID-addressed signer filter is not defined on this route: which DID stands behind
a key is the identity index's question.
Rows are browsing metadata, never proof: they contain no JWS, payload, title, or name.
Artifacts (GET /index/v0/artifacts?cid=&signer={did}&docSchema=&order=&after={cid}&limit=N)
Enumerates standalone signed artifacts, cid ascending by default.
{
"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,
composing 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 carry no artifact payload; clients fetch
the artifact from the proof plane and verify it before use.
Identities (GET /index/v0/identities?did=&key=&hasPublicProfile=&nameContains=&order=&after={did}&limit=N)
Enumerates identity chains, did ascending by default.
{
"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 linear per view, so this is the linear operation count |
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. Non-null means the chain names an anchor, never that this relay holds it |
profile.anchor |
string | The anchored contentId, the client's verification pointer. A chain fact, read from the identity's own signed services, present whenever the identity names an anchor, including when this relay holds nothing under that contentId |
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. false is the fold's answer for a chain this relay does not hold at all |
profile.docSchema |
string | null | $schema declared by the held head document. MUST be null whenever the relay does not hold the anchored chain's current head bytes, including when it holds no such chain. null has three causes: bytes not held, bytes that do not decode, and a decoded document declaring no string $schema. A non-null value is a claim that this relay holds and decoded those bytes |
profile.name |
string | null | Extracted per the projection table; null on any circuit breaker, including when the relay does not hold the anchored chain's head bytes and when profile.publicRead is false |
A named anchor and a held anchor are different facts. profile.anchor comes
from the identity chain; everything beside it comes from what the relay holds
under that contentId. An identity that anchored a profile and later withdrew it,
or whose anchored chain this relay never held or culled, keeps a non-null
profile carrying its anchor, with docSchema: null, name: null, and
publicRead: false. That row is the honest rendering of "this identity names a
profile; ask elsewhere for it". Because hasPublicProfile folds no-anchor,
private-anchor, and not-held-anchor into a single false, a consumer that needs
to tell them apart reads the row's profile object, not the filter.
Parameters: did (optional exact DID match, returning zero or one row), key
(optional exact match against a public key the identity has ever proved, semantics
below), hasPublicProfile (optional boolean filter on the predicate "profile is
non-null AND profile.publicRead is true"), nameContains (optional
case-insensitive substring filter over projected profile.name, applied before
keyset pagination), order (optional genesisAt.desc or headAt.desc; 400 on
any other value), after (a did keyset cursor in the lexical default, 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.
Discovery and resolution are separate promises for deleted identities. A relay
MAY omit deleted (isDeleted: true) identities from the discovery shapes
of this route (the unfiltered listing, the keyset and ordered walks,
nameContains, and hasPublicProfile). A relay MUST return a deleted
identity, carrying isDeleted: true, from either resolution shape, did= and
key=. The presence of either parameter makes the request a resolution whatever
else it carries, and a relay MUST NOT let a discovery-shaped filter reintroduce
the exclusion.
The line is whether the caller already holds the identifier. Answering a
resolution with an empty page would be a relay asserting non-existence, and
key='s consumers, key-loss recovery and mint-time burn checking, hold no DID to
fall back to.
key= is the reverse lookup "which identities has this key ever been proved
into". The value is matched byte-for-byte against the multibase public-key
strings whose memberships entered the chain's effective key state
(PROTOCOL, Key possession) under
any of the chain's accepted operations, every operation and not just the current
head: an identity matches when its genesis key is the key, or when any accepted
update ever introduced the key with a valid possession proof, whether or not a
later update rotated it out. The filter is has-ever-proved rather than
current-state, and proved rather than declared: a void membership never
indexes, so a hostile or defective listing neither surfaces the chain in the key's
recovery results nor burns the key against its true holder. One key may match many
identities, and a deleted identity always still matches. There is no key-class or
role column: which array carries the key, and whether it is current, is the
chain's answer. The value is matched as an opaque string, so a string no operation
ever proved matches nothing: no format validation, no 400.
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. 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, or when
bytes are not held. 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),
creator (exact DID, the chain's genesis signer; 400 when malformed), signer
(exact DID, keeping chains in which the DID signed at least one accepted
operation, branch-inclusive, so operations on branches later deleted or abandoned
still count; 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, applied before keyset pagination), order (optional
genesisAt.desc or headAt.desc; 400 on any other value), after (a
contentId keyset cursor in the lexical default, or an opaque token in ordered
mode), limit (default 100, max 1000). A client's notion of public posts by X
is creator=X&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.
A content chain is not tombstoned from this structural index when its subject is
deleted. Concealment belongs to the credential and blob plane, so a deleted
subject's chain metadata (identifiers, operation CIDs, timestamps) remains
enumerable. Consumers that do not want deleted rows use isDeleted=false.
signer is an actor-axis verification outcome and is deliberately raw: the
creator matches their own chains, and "contributed to but did not create" is
client-composed as signer=X minus creator=X. It is proof-tier, and it is never
an authorship or credit claim: credits is assertion-tier and never enters this
filter.
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, so the caller
re-verifies the token rather than trusting the row.
Credentials (GET /index/v0/credentials?issuer={did}&resource=&action=&order=&after={cid}&limit=N)
Enumerates the relay's held public credentials, cid ascending by default.
{
"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),
order (optional createdAt.desc or ingestedAt.desc; lexical CID order when
absent), after (a credential-cid keyset cursor in lexical mode or an opaque
ordered cursor in ordered mode), limit (default 100, max 1000). A credential's
createdAt is its JWT numeric iat normalized to ISO 8601; ingestedAt is when
this relay accepted it. Filters are ANDed. Expired rows remain enumerable and
clients 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 returns a superset of candidates: resource=chain:Y returns exact
chain:Y plus any chain:*. Each entry carries the full JWS. 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 the 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. did=X answers "which publicly readable documents credit X", the query
CONTENT-MODEL 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, where
entries without a role match only its absence), after and limit per the shared
envelope. Filters are ANDed. Enumeration order is
(contentId ascending, position ascending), and because the natural key is
composite, after and next are opaque cursor tokens in every mode. Rows for
one content may be replaced wholesale between pages, so 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 assertion-tier: it restates the current public head document, carries no claim token, and makes no validity claim. A consumer that needs the proof tier fetches the chain, re-hashes the head document, and runs the content model verification algorithm over the embedded entry.
Signing mailbox (capability signing)
A sign request is a signed envelope that says: I, this requester, ask this
subject to sign exactly these bytes, as this kind of artifact, before this
deadline. The mailbox is a relay-hosted, poll-based courier that carries the
envelope to the subject and the resulting signature back, one mailbox per subject
DID. Relays serve it when capabilities.signing is true. Signature production
is not a relay concern: the signer is whoever holds the subject's key, and the
signer path in service is the hosted authorize flow of
INTEGRATIONS, Profile A.
A relay MAY serve signing: true alongside write: false: courier state is on
neither replicated plane, so the mailbox does not make a read-only relay an
ingest path.
Courier, not ledger
- Mailbox state is not proof plane. It is never gossiped, never indexed, never referenced by any chain, and confers no meaning on its contents. A relay holds it the way a post office holds a letter.
- Retention MUST NOT exceed the envelope's
expiresAt. Two obligations, not one: an expired request MUST NOT be served on any route, and its bytes MUST NOT be retained at rest past expiry. A relay MUST delete expired courier state, not merely filter it from reads. A relay MAY delete opportunistically or on a sweep; the reference relays prune expired rows as they touch them and sweep once at construction. A relay MAY drop courier state earlier under its own policy: durability is not promised, and a composer that needs delivery guarantees re-deposits. - A relay stores only the request token, the response artifact, the decline flag, and its own bookkeeping timestamps. The deposit credential and any identity bundle are verified and discarded.
- No cross-subject enumeration. No route lists mailboxes, counts them, or reveals whether a subject's mailbox exists to anyone who cannot read it. Pending requests reveal who is asking whom to sign what.
- The courier sees payloads in the clear. A relay operator can read every pending request it carries. Deployments for which that matters run their own relay.
The mailbox requires the relay to resolve the subject's identity chain
locally: a mailbox lives where its subject's identity lives. A deposit for a
subject the relay cannot resolve is refused (404), and so is a deposit for a
subject whose local state is deleted: a tombstoned identity cannot be asked to
sign, and its mailbox does not exist. A composer picks the subject's relay from
the subject's resolved services entries of type: "DfosRelay"
(PROTOCOL, Services). When the subject
lists more than one relay, a composer SHOULD deposit at every listed relay that
advertises signing, and a signer SHOULD poll every relay its own state lists:
one-sided selection is how a correct composer and a correct signer miss each
other. Ed25519 is deterministic, so a request signed twice yields byte-identical
responses and the first-write-wins gate absorbs the duplicate.
The sign-request envelope
A sign request is a JWS in the same envelope family as credentials, credit claims,
and revocations, with its own typ.
{
"alg": "EdDSA",
"typ": "did:dfos:sign-request",
"kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe",
"cid": "bafyrei..."
}{
"version": 1,
"type": "sign-request",
"did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr",
"subject": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae",
"payloadTyp": "did:dfos:credit-claim",
"payload": "eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlZGl0LWNsYWltIiw...",
"createdAt": "2026-08-10T00:00:00.000Z",
"expiresAt": "2026-08-13T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
version |
1 |
yes | Schema version (literal 1) |
type |
"sign-request" |
yes | Literal discriminator |
did |
string | yes | The requester DID, which MUST equal the kid's DID |
subject |
string | yes | The target signer DID, the only identity being asked |
payloadTyp |
string | yes | The JWS typ the produced artifact MUST carry (for example did:dfos:credit-claim) |
payload |
string | yes | Unpadded base64url of the exact bytes to be signed |
createdAt |
string | yes | The timestamp grammar (millisecond precision, UTC) |
expiresAt |
string | yes | The timestamp grammar (millisecond precision, UTC), hard deadline |
The kid MUST be a DID URL containing #, and its DID portion MUST equal the
payload's did: the requester signs its own ask. The requester's signing key
resolves against the current state of its identity chain, so rotated-out keys
and deleted requesters are rejected. A sign request is an ephemeral ask with a
hard expiry and no revocation primitive, and rotation is how a requester whose key
is compromised stops it minting asks. Any current key role may sign.
payload carries bytes, not JSON. The field is the unpadded base64url
encoding (RFC 4648 §5, no = padding) of the exact octets the composer wants
signed, the octets that become the produced JWS's payload segment byte for byte.
It is never a re-serialized parse. Padded input, or any encoding of the same bytes
other than the canonical unpadded form, is invalid, and the decoded bytes MUST be
non-empty.
One subject. One requester asks one subject to sign one payload. Fan-out is N requests composed by the requester, not a multi-subject envelope.
payloadTyp names the artifact, and the artifact's own specification governs
it. The envelope does not say which of the subject's keys should sign, what the
payload schema is, or what the artifact means. There is no keyRole field.
Timestamps are whole-second. Signers of the envelope MUST normalize
createdAt and expiresAt to whole seconds (.000Z millisecond component), by
flooring and never rounding, so re-deriving a request from the same inputs lands
on the same CID in every implementation.
Unknown top-level fields on the envelope are preserved and ignored: the CID
commits to the exact bytes, so a verifier that stripped unknown keys would fail
its own CID check. The target payload inside payload is held to the opposite,
stricter standard at signing time.
Expiry. expiresAt MUST be strictly after createdAt, and
expiresAt − createdAt MUST NOT exceed 7 days (604800 seconds). The bound is
validity-determining and identical across implementations: a verifier MUST reject
an envelope whose window exceeds it, however far in the future or past the window
sits. A verifier evaluates expiry against the current time, so a request is live
only while now < expiresAt. The ceiling is a phishing control: a long-lived
pending request waits in a mailbox for a moment of inattention. There is no
cancellation primitive; a composer that wants a request gone stops honoring its
CID, and expiry collects it.
CID derivation is identical to every other protocol object:
dagCborCanonicalEncode(payload) -> SHA-256 -> CIDv1 (dag-cbor + SHA-256)The derived CID is embedded in the protected header as cid, and verification
re-derives it from the parsed payload and compares. The request CID is also the
request's correlation handle everywhere: the mailbox slot key and the requester's
poll handle.
Size bounds. Both are validity-determining and MUST be identical across implementations.
| Bound | Value | Applies to |
|---|---|---|
| sign-request JWS token | 8192 bytes | the serialized envelope token |
| decoded target payload | 4096 bytes | the octets payload decodes to |
Envelope verification
Given the token, a way to resolve identities, and the current time:
- Size. If the token exceeds 8192 bytes, reject. Check this before any decode.
- Decode the JWS and apply the
signature verification profile
header gates:
typMUST be exactlydid:dfos:sign-request,algexactlyEdDSA, acritmember rejects, an embedded key member (jwk,x5c, …) rejects. A header whosetyporkidis missing or not a string rejects. - Payload schema.
versionMUST be1,typeMUST be"sign-request",didandsubjectMUST be non-empty and carry thedid:prefix,payloadTypMUST be a non-empty string,payloadMUST be a non-empty string,createdAtandexpiresAtMUST parse per the timestamp grammar. Thedid:checks are prefix checks, not fulldid:dfosvalidation. - Target bytes.
payloadMUST decode as unpadded base64url; the decoded bytes MUST be non-empty and MUST NOT exceed 4096 bytes. kid↔did. Thekid's DID portion MUST equalpayload.did.- Resolve the requester named by
didto its current identity state. Unresolvable is unverifiable. Deleted rejects. Resolvable with no key matching thekidfragment in current state rejects. - Signature. Verify the JWS under that key.
- CID integrity. Re-derive the payload CID and compare against the header
cid. - Temporal.
expiresAtMUST be strictly aftercreatedAt, the window MUST NOT exceed 7 days, andnowMUST be strictly beforeexpiresAt.
The two failure verdicts MUST be machine-distinguishable: invalid (checked and failed) versus unverifiable (could not check, an unresolvable requester or a resolver transport failure). The reference implementations expose the verdict structurally, never as prose to string-match.
Signer obligations
The central risk of any remote-signing protocol is display and sign divergence: the human approves what the screen says and the key signs what the bytes say. Given a verified envelope, a signer MUST, in order:
- Verify the subject is itself.
subjectMUST equal the signer's own DID. A request addressed to anyone else MUST NOT be signed.subjectsits inside the requester-signed bytes, so a request minted for one DID cannot be re-aimed at another. - Refuse unknown types. If the signer does not implement
payloadTyp, its schema, its canonical serialization, and its signer-side rules, it MUST NOT sign. There is no generic path. - Decode and parse. Base64url-decode
payloadand parse the octets as UTF-8 JSON. Any parse failure refuses. - Validate against the
payloadTypschema, strictly. The parse MUST satisfy the target specification's payload schema and MUST contain no unknown fields. Ignore-unknown is a verifier's forward-compatibility posture; a signer is taking responsibility for bytes and cannot render a field it does not understand. The target typ's signer-side obligations apply in full. - Re-canonicalize and byte-compare. Re-serialize the validated parse using the target typ's canonical serialization and compare the result to the decoded input, byte for byte. Any mismatch refuses. After it passes, the input bytes are provably the unique canonical encoding of the parse: no duplicate key the parser collapsed, no non-shortest number form, no whitespace or escape trick, no key-order game.
- Render from the parse. Whatever the signer displays for approval MUST be derived from the validated parse, never from requester-supplied display text (the envelope carries none) and never from a second decode.
- Sign the original bytes. The produced JWS's payload segment MUST be the
decoded input octets exactly, not a re-serialization, however canonical. The
signer constructs its own protected header:
alg: "EdDSA",typequal to the request'spayloadTyp, its ownkid, and whatever header fields the target specification requires.
Steps 5 and 7 together are the contract: the byte-compare proves that rendering from the parse is rendering the bytes, and signing the original bytes proves the signature covers what was rendered. An implementation that skips the byte-compare and signs the re-serialization has the same bytes in the honest case and different bytes exactly in the adversarial one. The reference implementations ship adversarial vectors for this check (duplicate keys, non-shortest numbers, permuted key order, whitespace injection, unicode escapes of ASCII, unknown fields, sub-second timestamps), and an independent implementation copies them wholesale.
Canonical serialization is defined per payloadTyp, and for every DFOS
envelope family it is one rule: the payload object serialized as minimal UTF-8
JSON (no insignificant whitespace), members in the exact order of the target
specification's payload table, absent optional members omitted, timestamps in
.000Z whole-second form. A composer that builds payloads with the target's
reference implementation produces canonical bytes by construction; the signer-side
check exists for composers that do not.
A stale request is refused and re-composed, never patched. Stateless envelopes (a
credit claim, a countersign payload, a sign-in challenge) carry no reference to a
chain head and cannot go stale. Chain operations embed a previousOperationCID,
so if the chain advances while the request sits in a mailbox the produced
operation names a position that is no longer the head, and what that costs
depends on the chain. On an identity chain the sequence is linear and
first-seen wins, so the produced operation is invalid at ingest and harmless
everywhere. On a content chain it is a valid fork: the relay verifies it
against the chain state at that fork point, accepts it, and it can win head
selection (Content-chain forks and head selection,
PROTOCOL, Chain validity). Either
way the remedy is composer-side re-composition; nothing in the courier retries,
rebases, or mutates bytes.
The response
There is no response envelope. The response to a sign request is the produced
JWS: a complete, self-authenticating artifact of the requested payloadTyp,
verifiable by anyone under its own rules with no reference to the request that
solicited it.
Correlation is by request CID, and the binding is byte-equality: the response's payload segment decodes to exactly the requested bytes, checkable by anyone holding both with no courier trust involved.
Declines are courier-level and advisory. A subject that wants to say no tells
the courier, unsigned. A composer MAY surface a decline as user-facing text and
MUST NOT treat it as authoritative: the courier is untrusted and could fabricate
or suppress declines. Expiry is the only real terminator. A composer MUST NOT
irreversibly cancel real-world state on the strength of a decline, because a
response may still arrive from another of the subject's devices until
expiresAt.
Approving twice is harmless: Ed25519 is deterministic, so the same key over the same bytes yields a byte-identical artifact. Two different enrolled keys produce two different valid artifacts, which is why the response slot is first-write-wins.
Routes
All routes live under /signing/v0 at the relay root. Errors use the relay's
uniform error body; callers branch on status codes, never on message text.
POST /signing/v0/requests (deposit, credentialed)
{
"request": "<sign-request JWS>",
"credential": "<DFOS credential JWS>",
"chain": ["<identity operation JWS>", "..."]
}| Field | Required | Description |
|---|---|---|
request |
yes | The sign-request envelope |
credential |
yes | The deposit credential, see Deposit authorization |
chain |
no | Identity-chain operations for identities the relay cannot resolve locally |
The relay verifies the envelope (the full algorithm above, wall-clock now),
verifies the deposit credential, and stores the request keyed by its CID.
Responses: 201 with { "cid": "...", "expiresAt": "..." }; 200 with the
same body for an idempotent re-deposit of the identical token; 409 when the
CID already holds a different request token; 400 for an invalid or expired
envelope; 404 when the subject is not resolvable on this relay; 403 when
the credential fails the deposit rule; 413 over the caps; 429 when the
subject's pending set is at the relay's cap (relay policy, reference cap 1024).
The aggregate deposit body MUST NOT exceed 524288 bytes (512 KiB).
The deposit is self-contained. Verifying it requires resolving the requester's
identity chain and every issuer in the credential chain, identities the subject's
relay may not host. The relay resolves locally first, and chain fills the gaps
for foreign identities. Bundled operations are verified exactly as chain
verification always verifies them, then used ephemerally for key resolution and
discarded: never ingested, stored, gossiped, or folded. A deposit MUST NOT
become a cross-relay resolution dependency: if the bundle plus local state does not
suffice, the deposit is refused, not deferred. Local state is always the chain's
true head and always wins, and a lookup that fails refuses the deposit rather than
falling through to the bundle.
For an identity resolved from the bundle, three checks degrade to "as attested by the depositor", because a prefix of an append-only chain is itself a valid chain: current-state key resolution (a bundle truncated before a rotation presents a rotated-out key as current), deletion of a credential issuer, and revocation of a mid-chain delegation. None of these reaches what the subject signs: the signer runs the full verification algorithm on every polled request against its own resolution path. What a truncated bundle buys is mailbox-slot spam, bounded by the deposit credential's own root and expiry. A deployment that will not accept that bound requires the requester and every credential issuer to be locally resolvable and rejects bundle-only deposits; the reference relays accept the bundle.
GET /signing/v0/requests (poll, subject only)
Authenticated with an identity proof: a request-bound possession proof self-signed by a current key on the subject's identity chain, host-bound to this relay. Reading your own mailbox is a "prove you are currently this DID" question, and no credential exists for it. Revoking a device's mailbox access is key rotation.
The subject is the proof's kid DID. The response lists pending requests,
deposited, unexpired, and unresponded, oldest first:
{
"requests": [
{
"cid": "bafyrei...",
"request": "<sign-request JWS>",
"depositedAt": "...",
"declined": false
}
],
"next": "..."
}Pagination is the shared envelope: after, limit (default 100, max 1000, values
above the max clamped), and next. The pending set is ordered by deposit time
ascending, tiebroken by request CID, and because that is a composite key after
and next are opaque cursor tokens. Resumption is strictly past the composite
key, so a decodable cursor whose request has since expired or been responded
resumes at the next key. An undecodable token, or one minted for a different
subject's mailbox, is a 400: the cursor is bound to the mailbox it came from,
and reusing it across subjects would silently skip the new subject's older pending
requests. Reference relays cap a mailbox's pending set (relay policy, reference cap
1024), and the deposit that would exceed it is refused with 429. Polling is the
transport in full: there is no server push, no SSE, and no delivery callback. A
push notification may prompt a poll, but delivery is always the poll.
A signer MUST run the full verification algorithm and every signer obligation on each polled envelope. The mailbox's own checks are anti-abuse, not delegated trust: a signer treats a polled request exactly as one that arrived by QR code from a stranger.
POST /signing/v0/requests/{cid}/response (respond)
{ "response": "<produced artifact JWS>" }Unauthenticated, deliberately. A valid response is unforgeable, since only a holder of the subject's key can produce it, so proving who couriers it adds nothing.
The aggregate request body MUST NOT exceed 8704 bytes, the 8192-byte token cap
plus JSON-wrapper headroom, checked before any decode (413 over it). The relay
accepts the response if and only if all of the following hold; otherwise 400,
or 404 for an unknown or expired cid:
- A pending request with this CID exists and is unexpired.
- The token does not exceed 8192 bytes (checked before any decode).
- The JWS decodes, passes the profile header gates, and its
typequals the request'spayloadTypexactly. - The
kid's DID portion equals the request'ssubject. - The
kidnames a key that has appeared in the subject's identity chain. The courier gate resolves against every key the chain has held, because the artifact will be judged by its own rules downstream and the gate must not be stricter than the artifact's own verifier. - The payload segment decodes to exactly the request's target bytes. Its base64url spelling MUST be canonical (unpadded, no non-zero trailing bits): the stored artifact is served back verbatim and must round-trip identically across implementations.
- The signature verifies under the resolved key.
The slot is first-write-wins: at most one response per request, the first valid one stored. A re-put of the byte-identical token is idempotent (200); a different valid artifact, possible when the subject holds multiple enrolled keys, is refused (409), and the composer reads the one that won. 201 on first acceptance. Responding to a declined request is legal, and the response simply wins.
GET /signing/v0/requests/{cid}/response (requester poll)
Unauthenticated. Knowledge of the request CID is the capability: it is held by the composer that deposited the request and the subject that polled it, and it is not enumerable. The CID is a hash of the requester-signed payload and those inputs are not secret, so this route protects against enumeration, not against a guesser who already knows what was asked; and what it guards is a self-authenticating artifact, not a secret.
{ "status": "pending" }
{ "status": "declined" }
{ "status": "responded", "response": "<artifact JWS>" }404 for an unknown or expired CID: after expiry a request and its response cease to be served at all, and a composer that wants the artifact keeps it.
A composer MUST verify a fetched response itself, under the artifact's own rules. The courier's checks are not a verification the composer inherits.
POST /signing/v0/requests/{cid}/decline (advisory)
Unauthenticated, no body. A non-empty body under the cap is a 400, and a body over 512 bytes is a 413. Sets the advisory decline flag on a pending request: 204, idempotent on repeat; 409 if a response already exists; 404 unknown or expired. Unauthenticated is coherent because the flag carries no authority, and requiring subject auth would lend it a credibility this document denies it. No reason text is carried.
Deposit authorization
Depositing into a mailbox requires a DFOS credential: standard verification, plus one rule that makes the mailbox the subject's own.
The resource form is mailbox:<id>, where <id> is the subject DID's 31-character
identifier with the did:dfos: prefix stripped, exactly as chain:<contentId>
does not repeat its scheme. The action is deposit.
A relay MUST verify, at deposit time:
- The credential chain verifies in full: signatures, schema, CID integrity, linear delegation, depth, monotonic attenuation, and audience linkage at every hop, with expiry evaluated against the wall clock (a deposit is a live decision) and revocation checked at every level against the relay's current knowledge.
- The chain roots at the subject. The root credential's
issMUST equal the request'ssubjectDID. Only the subject is original authority over its own mailbox. This rule is what makes a mailbox belong to its subject on an untrusted relay. - The leaf reaches the requester. The leaf credential's
audMUST equal the request'sdid, or be"*". A public deposit credential is the subject opting into an open mailbox, carrying the same bearer-grant caution as every public credential: anyone may deposit, and the caps and rate limits are what stand between an open mailbox and a flooded one. The default posture is a named audience. - Attenuation covers the deposit. Some
attentry on the leaf MUST cover resourcemailbox:<subject id>with actiondeposit, under the credential specification's action-canonicalization rules. The resource match is exact only: no wildcard form is defined formailbox, and a relay MUST NOT honormailbox:*as covering a deposit.
There is no collect action. Credentials delegate authority to others, and being
yourself is not a delegation: key possession is necessary and sufficient to read
your own mailbox. A credential attenuated to collect on a mailbox grants nothing.
The deposit credential falls out of consent moments that already exist: a sign-in
authorization returns one alongside its proof, and a subject-rooted grant to a
platform sub-delegates onward through linear chains without re-touching the
subject's key. Each of those presupposes a standing relationship, and that is the
bound: a stranger with no prior grant cannot deposit at all, by any route. How a
stranger obtains a subject's public deposit credential is unspecified here, but
the convention is that a subject MAY publish its aud: "*" deposit credential as
a public credential on its own chain, where any composer discovers it through
GET /index/v0/credentials?issuer=<subject id>.
The deposit gate treats a discovered grant exactly as a directly-delivered one.
Caps are load-bearing on a public relay. The 8 KiB envelope cap, the 4 KiB payload cap, the 512 KiB deposit body cap, and the deposit credential gate are the difference between a public courier and unauthenticated durable storage of arbitrary bytes. Relays SHOULD add per-requester rate limits on deposit.
Peering (convention)
Peering replicates proof-plane data across relays. It is a convention, not a wire contract: it adds no route a relay must serve, and it runs entirely over the read and write contracts above.
| 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, which
prevents 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 it locally
under full verification. Sync-in uses cursor-based pagination against the peer's
global log.
interface PeerConfig {
url: string;
gossip?: boolean; // default: true
readThrough?: boolean; // default: true
sync?: boolean; // default: true
}There are 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, and one with gossip: false, readThrough: true, sync: false is a read-only
cache.
Admission is the peering rule. Everything a peer sends enters through the same door as a client submission, with the same admission ladder and the same linearity rule: a peer-log identity operation that conflicts with a locally-committed extension is refused like any other, and committed order in this relay's log is never re-arbitrated. Peer-log ingestion inherits the peer's admission discipline, so choosing peers is a trust decision. Choosing which peers to sync from is also choosing which view of a divergent identity this relay ends up serving.
Sync discipline for pullers. A puller persists only peer-supplied next
values, never a cursor fabricated from an entry CID. On next: null it retains its
last persisted cursor and cheaply re-fetches the final partial page next cycle, so
a peer whose whole log fits in one page is re-read each cycle: a bounded,
dedup-idempotent cost accepted in exchange for never fabricating. Every log fetch
MUST surface a peer's 400 cursor rejection as a distinguishable invalid-cursor
outcome, distinct from transport failure, because a client that collapses the 400
into a generic failure leaves the puller retrying a dead cursor forever after a
peer wipes or rebuilds its log. On invalid-cursor the puller resets at most once
per peer per sync cycle, 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. A content-chain head switch mid-walk invalidates a read-through
cursor the same way, and the correct response is one restart of that walk from the
beginning.
Peer log pages are decoded under a byte bound. A peer's response is untrusted input.
Peers converge by exchanging operations, and nothing bounds when. Given the same set of operations, every relay computes the same content-chain head. Which operations a relay has is a function of its peers, its retention, and its admission verdicts, and no relay's log is the corpus.
Content plane (capability content)
The content plane is the relay's read and write face for document bytes, the
preimages of the documentCIDs content chains commit to. It does two things: it
stores bytes addressed by a committed documentCID, and it serves bytes to
readers it can verify are authorized, where authorized is a judgment re-derived
live from the proof plane on every request and never trusted from a stored flag.
Everything that gives a document meaning, which chain it belongs to, who committed
it, who may read it, lives in the proof plane.
The content plane has no chains of its own, no signatures of its own, no operation log, and no replication: blobs are never pushed on the operation log, and a blob enters a relay by upload to the relay that holds the chain. Capability flows up from the proof plane; the proof plane never reaches down.
A reverse proxy can split the planes across origins: the proof node owns
GET /proof/v1/content/:contentId and its /log, and the content plane owns the
/content/:contentId/blob* sub-paths.
Terminal and referential documents
A document is either terminal, where the { $schema, … } blob is the content,
or referential, where the document describes how to fetch external bytes: an
ipfs:// CID, or an opaque attachment://<id> resolved by an out-of-protocol
signed-CDN API, optionally carrying a hash of the target bytes so a consumer can
re-bind delivery to the committed reference. The relay serves the document blob
either way and never resolves a referential pointer: dereferencing is delivery,
and delivery lives outside the protocol. There is no media server here: no range
requests, no partial content, no streaming, no minting of CDN URLs. Media is a
content-schema convention
(CONTENT-MODEL, Media object),
never a relay primitive.
Discovery
A reader finds a content-plane host through the identity's services vocabulary
(PROTOCOL, Services). The core
registers no service type for it, so a host names its own consumer type and
validates it in its own spec. Discovery and authorization stay orthogonal.
Authorization is one routine
The content plane holds no authoritative authorization state: every decision is re-derived live from the proof plane, so nothing it stores can be served stale. A relay MAY keep a materialized index of ingested public grants as a performance optimization, an O(1) candidate lookup whose every candidate is re-verified live before it can authorize anything. The index is a cache over the operation log, fully re-derivable from it, never a source of truth.
Both the public path and the delegated path reduce to the same verification, and the only difference is where the credential came from:
verify(credential, resource, action):
resolve issuer keys from the proof plane # required for any signature check
check the credential signature
check the delegation chain roots at the content creator
check not expired
check not revoked — for EVERY link in the prf delegation chain
→ authorized iff all checks pass- Public path. The reader presents no credential. The relay derives the public
credentials (
aud: "*") covering the chain from the proof plane it already reads, and runs each through the verifier. A surviving public grant authorizes the read. The relay works from the credentials themselves, never a pre-chewedpubliclyReadable: true. - Delegated path. The reader presents a DFOS credential in the
X-Credentialheader. The same verifier runs over it, including the same per-link revocation check.
A public grant may name chain:<contentId> (this chain) or chain:* (all of the
issuer's chains); either way it MUST root at the content creator to authorize.
Public credentials SHOULD be read-scoped: a public write grant is a
world-writable bearer token
(CREDENTIALS).
A check the relay cannot complete denies. An unreachable revocation source or a failed store read is not an absence of revocation.
Standing authorization
Instead of presenting a read credential on every request, a credential with
aud: "*" can be ingested by the relay as a standing authorization: once
ingested, matching content-plane requests are authorized without an X-Credential
header. Ingestion is ordinary, through POST /proof/v1/operations, and the
credential is stored in the operation log like any other operation, addressable by
CID and carried in the global log as kind: "credential".
Authority is re-derived live, not read from a stored flag. On every access the relay re-verifies the standing credential against current proof-plane state: signature, issuer-key resolution, expiry, revocation at every link, and a delegation chain rooted at the content creator, through the same verifier the per-request path uses. The two paths differ only in where the credential came from and an audience check that public credentials skip.
A standing authorization stops granting access the moment any live check fails: the credential expires, the credential or any parent in its delegation chain is revoked, or the issuer's identity (or any delegating identity) is deleted. These are evaluated live per request, so the effect is immediate and no cache invalidation is required for correctness.
Access
Non-public content-plane requests carry an identity proof in the
Authorization: DFOS header, verified against the presenter's current identity
state with the relay's own configured authority as the host binding. There is no
lifetime knob: a proof lives inside the verifier-owned freshness window, seconds
rather than hours, and binds one exact request.
The content creator, the DID that signed the genesis content operation, can always read their own blobs with just an identity proof.
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.
- A valid identity proof (
Authorization: DFOS). - 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, which is what enables delegated uploads.
- The uploaded bytes MUST hash to the operation's
documentCID(dag-cbor plus sha-256 verification).
Blobs are stored by (creatorDID, documentCID), always keyed to the chain creator
regardless of who uploads. If several content chains by the same creator reference
the same document, the blob is shared.
Blob download (GET /content/:contentId/blob[/:ref])
- If a standing authorization exists for the content (a public credential with
aud: "*"covering the resource), access is granted anonymously: no proof, no per-request credential. - Otherwise a valid identity proof (
Authorization: DFOS) is required, plus: if the caller is the chain creator, no further credential; if the caller is not the creator, a DFOS credential withaction: "read"in theX-Credentialheader, with a delegation chain rooting at the creator.
The optional :ref parameter selects which operation's document to return: head
(default) is the current document at chain head, and an operation CID is the
document that operation committed.
A public (aud: "*") grant covers only the document at the chain's CURRENT head.
The operative test is document-CID equality: the selected :ref resolves to a
documentCID, and the grant applies only when that CID equals the chain state's
current document CID. This holds however the grant arrives — a standing
credential the relay holds and a public credential presented in X-Credential
are both refused for a superseded document — so reading a superseded revision
requires a credential whose audience names the requester, with a delegation chain
rooting at the creator, or an identity proof as the creator.
There is deliberately no relay-side document list route. Fetch
GET /content/:contentId/blob for the document at head,
GET /content/:contentId/blob/:ref for the document any specific operation
committed, and GET /proof/v1/content/:contentId/log to enumerate the chain's
operations, each carrying its documentCID. That composition is strictly more
verifiable: every blob is checked against its committed documentCID, and the
operation log is the proof-plane enumeration.
What a 200 from the content plane means
A 200 is an endorsement: a cooperating host verified against the live proof plane
that a grant authorizes this read. Every input to that decision is public and
re-derivable, so a caller MAY re-run the verifier itself and reach the same yes
independently.
| Property | Guarantee |
|---|---|
| Can't forge | A reader checks served bytes against the known documentCID by re-canonicalizing (decode-JSON, dag-cbor, sha-256), not by hashing the served bytes directly. Integrity is cryptographic even against a malicious host. |
| Can withhold / leak | The host holds plaintext. Content-plane access control is host-cooperative: it protects an honest host from mis-serving. It is not a cryptographic vault. |
What is not re-verifiable is the host's serve discipline, whether an honest host actually withholds bytes from an unauthorized reader. That is unprovable for any content host. Anything that must stay confidential against a hostile host is withheld or encrypted above the protocol.
Blobs are unsigned, and that is correct. A blob's integrity is its CID, and the
CID is already signed in the proof plane. Note the byte encoding: stored and served
blob bytes are the bytes as received, canonically a JSON document, and NOT a
re-canonicalized form. A naive sha256(servedBytes) will NOT equal documentCID;
a verifier re-canonicalizes through the same decode and dag-cbor path the upload
check uses.
Conformance
packages/relay-conformance is the definition. It is a Go integration suite
that runs against any live relay over HTTP: set RELAY_URL and run it. A passing
run is the conformance claim, and there is no central authority that grants one.
RELAY_URL=http://localhost:4444 go test -v -count=1 ./...Authenticated requests sign an identity proof bound to the relay's own configured
authority, so the target is booted with one (authority /
RelayOptions.Authority / dfos serve --authority) or every authenticated route
answers 503. The suite binds proofs to the authority of the URL it dials; set
RELAY_AUTHORITY when the relay is configured for a different host than the one
you reach it at.
Capability-gated variants self-skip unless the relay advertises the matching flag,
and the gated families have posture-specific scripts that boot a relay in that
posture and assert it: scripts/run-write-disabled.sh and
scripts/run-index-disabled.sh assert 501 on every route of a family whose
flag is off, with adjacent surfaces unaffected; scripts/run-signing.sh runs the
signing mailbox against both reference relays; scripts/run-proof-required.sh
asserts the admission ladder's gated posture, an anonymous submission refused
403 at request level and the same batch admitted with an identity proof.
scripts/run-conformance.sh starts a TypeScript relay on a random port and runs
the full suite. scripts/run-parity.sh compares
the two reference relays over the same fixture: given the same accepted operations
and held blobs, they serve byte-identical canonicalized-JSON rows, ordering,
filters, and cursor pages.
A read-only node cannot be seeded by the suite, since its POSTs answer 501, so the write-disabled variant verifies it by recomputing from the log: it pulls a served chain's log and independently re-derives the head and state, then asserts the served state matches. The served state is reproducible from the served operations alone.
Declare your capability flags honestly in /.well-known/dfos-relay. A
proof-plane-only relay is a conformant relay. State which corpora you ran: the
protocol-level verifier and signer rules are proven separately by
packages/protocol-verify
and packages/protocol-verify/vectors.json.
Not defined here
Present absences, stated so a reader does not infer them:
- Peer discovery. Static configuration only.
- Server push. No SSE or realtime push; reads poll
GET /proof/v1/log. - Fork visibility. No endpoint lists a content chain's tips or branches.
- Search. No tokenization, ranking, or fuzzy matching.
nameContainsandtitleContainsare the index's ceiling, and anything beyond them is its own family, never an index extension. - Branch termination. No operation kills a content-chain fork branch.
- Rate limiting and anti-spam. An operational concern, set per deployment.
- Blob size limits. No protocol enforcement; deployments add limits at the middleware layer.
- Artifact
$schemaregistry. Schema names are free-form strings, with no validation beyond structural checks. - Credentials-by-resource query. Reverse discovery ("what can DID X read") serves no part of the read path.
- Content-plane replication. A relay serves the bytes it stores. What a fork of an identity carries to another relay is the proof of history, not the bytes.
Source
The reference TypeScript relay is
packages/dfos-web-relay/,
published as
@metalabel/dfos-web-relay.
Its Go twin, the full reference relay, is
packages/dfos-web-relay-go/.
The conformance suite is
packages/relay-conformance/.
Construction, storage, and peer-client interfaces are package documentation.
Related specifications
- Protocol: encoding, chains, views, time basis, key possession, and the signature verification profile
- Credentials: authorization credentials and revocation
- Content Model: document schemas and the credit vocabulary
- Integrations: sign-in, API authentication, and origin binding
- DID Method:
did:dfos: the W3C DID method registration - Guarantees: what holds without trusting a server, what is a chosen view, and what the operator can read