# DFOS Protocol — Full Content Dump > All protocol site content as plain text. Specifications, overview, and FAQ. > Source: https://protocol.dfos.com --- # Why This Exists Identity on the internet is platform-granted. Your account, your content history, your social graph — all exist at the discretion of the service you're using. If the platform changes its rules, locks your account, or shuts down, your identity goes with it. This is structural, not a policy failure. The architecture of platform identity means someone else always holds the keys. The DFOS Protocol inverts this. Identity derives from cryptographic keys you control. Content authorship is verifiable without trusting the source. Proofs survive the platform. ## The Dark Forest The internet is not a public square. The most meaningful creative and social coordination happens in private groups, closed communities, invite-only spaces. This is where real work gets done, real relationships form, real culture develops. The topology is private-first. The major social protocols (AT Protocol, nostr, Farcaster) are public-by-default: posts and profiles are public documents, and to verify a piece of content you generally have to be able to read it. The proof and the content travel together. DFOS separates them. The proof surface is public — signed chains of cryptographic commitments, verifiable by anyone with a public key and any standard Ed25519 library, offline, in any language. The content surface is access-controlled — documents live in member-governed spaces, undisclosed by default and served only to participants. The protocol defines the proof surface. It commits to content hashes, never the documents themselves. **For high-entropy content, you can prove you authored something without revealing what it is** — the public chain carries only the CID, and the CID is the only thing the protocol exposes. This separation is architectural, not a privacy setting. It is an engineering response to the structural condition of where the internet actually lives. ### Privacy Considerations The protocol commits to content via an unsalted `dag-cbor` → SHA-256 CID — it does not encrypt documents and does not hide low-entropy content. Anyone who can guess the document can recompute its CID and confirm the match, so the "prove without revealing" property holds only for content with enough entropy to be unguessable. Confidentiality of the underlying documents is enforced at the application layer by whoever serves the space: a relay operator can read what it stores, and there is no end-to-end encryption. The proof surface is undisclosed-by-default access control, not cryptographic secrecy. ## What the Protocol Is The DFOS Protocol specifies how [Ed25519 signed chains](https://protocol.dfos.com/spec) establish identity, commit content, and produce proofs. It defines [self-certifying identifiers](https://protocol.dfos.com/did-method) (`did:dfos`) derived from genesis operations, [content-addressed commitments](https://protocol.dfos.com/content-model) via CID, and a [relay network](https://protocol.dfos.com/web-relay) of verifying HTTP endpoints that distribute proofs without trusting each other. Identity chains carry an optional discovery vocabulary — controller-signed `services` that say where to reach an identity and what stable content it anchors, projected into verified identity state alongside its keys. Witnesses can attach a standalone countersignature to any CID-addressable operation, carrying an optional open-namespace `relation` tag (`endorses`, `coauthors`, `witnessed`) that names the nature of the attestation. Verification is a pure function. Given a chain and a public key, any Ed25519 implementation returns valid or invalid. The chain carries everything needed — public keys, signatures, content-addressed hashes. There is no registry to query, no blockchain to sync. A proof exported today is verifiable by code that doesn't exist yet. The reference implementation is in [TypeScript](https://www.npmjs.com/package/@metalabel/dfos-protocol). Cross-language verification exists in Go, Python, Rust, and Swift — all running against the same [deterministic test vectors](https://protocol.dfos.com/spec#deterministic-reference-artifacts) from the specification. ## What It Isn't - **Not a social protocol.** No federation model, no feeds, no application semantics. The protocol operates on keys and document hashes. [Application semantics](https://protocol.dfos.com/content-model) are a separate concern, free to evolve without protocol changes. - **Not a blockchain.** No consensus layer, no gas fees, no chain state to sync. Forks are valid. Convergence is deterministic without coordination — highest `createdAt` timestamp among tips, with lexicographic CID as tiebreaker. - **Not an encryption system.** Privacy comes from separation, not obscurity. The proof surface is fully public. The content surface is governed by application-layer access control. The protocol doesn't encrypt anything. - **Not coupled to the DFOS platform.** [DFOS](https://dfos.com) is one implementation. Any system implementing the same chain primitives produces interoperable, cross-verifiable proofs. ## Design Principles - **Self-certifying.** Identity derives from cryptographic operations. The DID is a deterministic hash of the genesis operation. No external authority needed. - **DAG-native.** Chains are directed acyclic graphs. Forks are valid. Convergence is deterministic without consensus. Given the same set of operations, any relay computes the same head regardless of ingestion order. - **Transport-agnostic.** No privileged registry, blockchain, or API. A proof obtained from an API, a USB drive, or a peer-to-peer exchange verifies the same way. - **Offline-first.** Verification requires no network. The chain carries everything needed. - **Protocol-only.** Signed chains, CID derivation, [DID resolution](https://protocol.dfos.com/did-method), credentials, countersignatures. Application semantics are a [separate concern](https://protocol.dfos.com/content-model). ## Status The protocol's v1 surface is **feature-complete and frozen** — the core wire (chain mechanics, DAG-CBOR encoding, identifier derivation, validity bounds) is settled and will not change in shape, while independent implementation experience accrues; the reference packages remain on their own `0.x` release line. It is open source under the [MIT license](https://github.com/metalabel/dfos/blob/main/LICENSE). The [CLI](https://protocol.dfos.com/cli) ships pre-built binaries for Linux, macOS, and Windows — installable via Homebrew, Docker, or a single curl command. The [DFOS platform](https://dfos.com) runs on this protocol in production. Discussion happens in the [DFOS](https://nce.dfos.com) space. Read the [full specification](https://protocol.dfos.com/spec), explore the [FAQ](https://protocol.dfos.com/faq), or browse the [source on GitHub](https://github.com/metalabel/dfos). --- # DFOS Protocol Verifiable identity and content chains — Ed25519 signatures, content-addressed CIDs, W3C DIDs. Cross-language verification in TypeScript, Go, Python, Rust, and Swift. > **Status — Protocol v1: feature-complete and frozen.** The v1 surface is **frozen**: the core primitives — chain mechanics, canonical DAG-CBOR encoding, identifier derivation, and the validity bounds — are settled and will not change in shape. Build on the wire as specified. v1 is frozen but not yet declared final — we are still gathering independent implementation experience, and changes from here are limited and disciplined: > > - **Clarifications** — where the prose was ambiguous but conformant implementations already agree — are corrected in place. > - **Additive** capability — new optional fields, new service types, the [document gateway](https://protocol.dfos.com/document-gateway) — lands atop frozen v1, never as a break. > - A genuine **breaking** change to a frozen field is never a silent v1 edit; it becomes v1.1 or v2. > > v1 is declared final once independent implementations confirm the spec verifies byte-for-byte from the prose alone. The protocol version is independent of the reference packages: the `@metalabel/dfos-protocol` and `dfos-protocol-go` releases stay on their own `0.x` semver line, so freezing v1 commits the **wire**, not yet a library API. Discuss in the [DFOS](https://nce.dfos.com) space. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## Philosophy DFOS is a dark forest operating system. Content lives in access-controlled spaces — undisclosed by default, governed by the communities that create it. The cryptographic proof layer is public: signed chains of commitments that anyone can independently verify with a public key and any standard EdDSA library. The proof is public; the content is access-controlled. The protocol commits to content hashes, not plaintext — it does not encrypt. Confidentiality of the underlying documents is enforced at the application layer by whoever serves them; a relay operator can read what it stores. This is undisclosed-by-default, not end-to-end encrypted. Two chain types — identity and content — use the same mechanics: Ed25519 signatures, JWS compact tokens, content-addressed CIDs. The protocol operates on keys and document hashes. Application semantics — posts, profiles, feeds — are a separate concern, free to evolve without protocol changes. Any system implementing the same chain primitives produces interoperable, cross-verifiable proofs. An identity created on one system can sign content on another. No platform dependency, no coordination required. --- ## Protocol Overview The DFOS protocol has five components: | Component | Concern | | --------------------- | ---------------------------------------------------------------------------------------------------------------- | | **Crypto core** | Identity chains + content chains — Ed25519 signatures, JWS tokens, CID links | | **Credentials** | Auth tokens and DFOS credentials for authorization — see [CREDENTIALS.md](https://protocol.dfos.com/credentials) | | **Services** | Identity discovery vocabulary — controller-signed relay locators and stable content anchors | | **Artifacts** | Standalone signed inline documents — immutable, CID-addressable structured data | | **Countersignatures** | Standalone witness attestation — signed references to any CID-addressable op | > **Note:** The credential format (auth tokens, read/write credentials, revocation) is specified in [CREDENTIALS.md](https://protocol.dfos.com/credentials). This document covers the crypto core, chain primitives, services, artifacts, and countersignatures. The crypto core is the trust boundary — everything below it is cryptographically verified. Documents are flat content objects, content-addressed directly: `documentCID = CID(dagCborCanonicalEncode(contentObject))`. What goes inside the content object is application-defined — see the [DFOS Content Model](https://protocol.dfos.com/content-model) for the standard schema library. ### Crypto Core: Two Chain Types | | Identity Chain | Content Chain | | -------------- | -------------------------- | -------------------------------- | | Commits to | Key sets (embedded) | Documents (by CID reference) | | Identifier | `did:dfos:` | `` (bare) | | Operations | create, update, delete | create, update, delete | | JWS typ | `did:dfos:identity-op` | `did:dfos:content-op` | | Self-sovereign | Yes (signs own operations) | No (signed by external identity) | Both chains are signed linked lists of state commitments. Identity chains embed their state (key sets). Content chains reference their state via `documentCID` — a content-addressed pointer to a flat content object. ### Addressing Three addressing modes, self-describing by format: | Thing | Form | Example | | --------------------- | ------------------------ | ------------------------------------------ | | Operation or document | CID (dag-cbor + SHA-256) | `bafyrei...` (base32lower) | | Content chain | contentId (31-char hash) | `cv7n8vkvr64cctf3294h9k4eanhff8z` | | Identity chain | DID | `did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr` | CIDs are specific immutable artifacts — a pointer to an exact operation or document. Content IDs are living content chain entities — the 31-char bare hash derived from the genesis CID. DIDs are living identity chain entities. Operations and documents are CIDs — standard IPLD content addresses. Content chains and identity chains use derived identifiers — `customAlpha(SHA-256(genesis CID bytes))`. Same derivation for both. Identity chains prepend `did:dfos:` (W3C DID spec). Content identifiers are bare — just the 31-char hash, no prefix. Application code may add prefixes for routing (e.g., `post_xxxx`) — these are strippable semantic sugar, not part of the protocol identifier. --- ## Protocol Rules ### Commitment Scheme Both operations and documents are content-addressed via **CID** (`dagCborCanonicalEncode(payload)` → SHA-256 → CIDv1). Operations are additionally signed via **JWS**. | Representation | Encoding | Purpose | | -------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | CID | `dagCborCanonicalEncode(payload)` → SHA-256 → CIDv1 | Deterministic content addressing for operations and documents | | JWS | `base64url(JSON.stringify(header))` + `.` + `base64url(JSON.stringify(payload))` → EdDSA signature covers both | Signature verification for operations | CID uses [dag-cbor canonical encoding](https://ipld.io/specs/codecs/dag-cbor/spec/) for determinism — given the same logical payload, the CID MUST be identical regardless of implementation language or platform. JWS uses standard JSON for library interoperability. The dag-cbor hex test vectors in this document allow byte-level verification. ### Chain Validity A valid chain is a **directed acyclic graph (DAG)** of operations rooted at a genesis. Each operation (after genesis) links to a predecessor via `previousOperationCID`. The chain provides structural ordering independent of timestamps. **Forks are valid.** Two operations referencing the same `previousOperationCID` constitute a fork — both branches are accepted. The chain log stores all branches. A **deterministic head selection** rule ensures convergence across implementations given the same set of operations: 1. Find all **tips** — operations with no children. 2. Select the tip with the **highest `createdAt`** string (descending order). 3. If two or more tips share an identical `createdAt`, break the tie by the **highest CID multibase string** (descending order). Two distinct operations cannot collide on both `createdAt` and CID: distinct payloads yield distinct CIDs (the CID is the SHA-256 of the dag-cbor payload), so this tiebreak is total. **Comparison basis (normative).** Both ordering comparisons — `createdAt` for timestamp ordering and head selection, and the CID for the head-selection tiebreak — MUST be performed as a byte-wise (Unicode code-point) comparison of the raw strings, equivalent to comparing the UTF-8 byte sequences left to right. An implementation MUST NOT parse `createdAt` to an epoch, a floating-point value, or a broken-down time before comparing, and MUST NOT apply any locale-aware or collation-aware comparison (e.g. ICU collation, JavaScript `String.prototype.localeCompare`) to either field. This is sound, not merely convenient: `createdAt` is the fixed-width grammar `YYYY-MM-DDTHH:MM:SS.sssZ` (see Timestamp Grammar) — UTC, a literal `Z`, exactly three fraction digits, zero-padded throughout — so byte-wise order is identical to chronological order; and a CID is a base32-lower multibase string (`b`-prefixed, ASCII `[a-z2-7]`), so code-point order is identical to byte order. Locale collation has no determinism contract across engines or locales and would let two conforming relays select different heads from the same operation set; parsing-to-number discards sub-millisecond byte identity and admits format-dependent drift. The reference implementations use the relational string operators directly (TypeScript `a < b`/`a > b` on the UTF-16 code units, which for this ASCII grammar equals byte order; Go string comparison, which is byte-wise) and are byte-for-byte equivalent on this input domain. This is deterministic: any implementation with the same operations computes the same head, regardless of ingestion order. Semantic interpretation of forks (concurrency glitch, intentional recovery, etc.) is application-defined — the protocol stores the DAG, clients interpret it. **Timestamp ordering**: `createdAt` MUST be strictly greater than the `createdAt` of the parent operation (the operation referenced by `previousOperationCID`). This is enforced per-branch, not globally — a fork branch's timestamps are validated against its own parent, not the other branch's operations. **Future timestamp bound**: Relays, and any component that performs deterministic head selection, MUST reject identity and content operations with a `createdAt` more than 24 hours in the future relative to the verifier's clock. Since deterministic head selection favors the highest `createdAt`, a far-future timestamp would otherwise permanently dominate head selection — this guard prevents temporal denial-of-service. Bare linear chain verification (`verifyIdentityChain` / `verifyContentChain`) does not select a head and does not enforce this bound; it validates only that each operation's `createdAt` is strictly greater than its parent's (below). The reference relays enforce the 24-hour bound at ingest. ### Identity Chain Signer Validity An identity chain operation is valid only if the signing key was a **controller key in the immediately prior state**. For genesis operations, the signing key MUST be one of the controller keys declared in that same operation — this is the bootstrap: the genesis operation introduces and simultaneously authorizes its own keys. This is a self-sovereign invariant: the identity chain defines its own valid signers via `controllerKeys`, and the protocol enforces this. No external authority is consulted. ### Content Chain Signer Model Content chain verification requires a **valid EdDSA signature** and delegates key resolution to the caller. The `kid` in each operation's JWS header is a DID URL (`did:dfos:#`). The verifier calls `resolveKey(kid)` to obtain the raw Ed25519 public key bytes for that key on that identity. How the resolver obtains and validates the identity's key state is application-defined. **Creator sovereignty**: The DID that signs the genesis (create) operation is the **chain creator** and permanently owns the chain. The creator can sign subsequent operations directly — no credential needed. Other DIDs require a **DFOS credential with write access** in the operation's `authorization` field, issued by the creator DID. See [CREDENTIALS.md](https://protocol.dfos.com/credentials) for the credential format. **Signer-payload consistency**: The `kid` DID in the JWS header MUST match the `did` field in the content operation payload. This enables discrimination between author operations and countersignatures — if the kid DID differs from the payload `did`, it is a countersignature (witness attestation), not a chain operation. **What the protocol enforces:** - The EdDSA signature on each operation is valid against the key returned by `resolveKey(kid)` - Chain integrity (CID links, timestamp ordering, terminal state) - The `kid` DID matches the payload `did` for chain operations - Creator-sovereignty authorization (when `enforceAuthorization` is enabled): non-creator signers must present a valid DFOS credential with `action: "write"` issued by the creator **What the protocol does NOT enforce (application concerns):** - Which key role (auth, assert, controller) the signing key must have - Ownership or attribution semantics beyond creator sovereignty ### Terminal States and Special Operations **`delete` is the only terminal state.** No valid operations may follow a delete. An implementation MUST reject any operation after a delete. This is enforced per-branch: a delete seals further linear extension of its own branch, but forks rooted at a pre-delete operation remain valid, and deterministic head selection may make a non-deleted branch the head — see the `did:dfos` DID Method specification, Deactivation. Delete prevents future operations but does NOT remove data — the complete chain remains intact for verification. Data removal is an application concern. **Controller key requirement:** `update` operations on identity chains MUST include at least one controller key. If decommissioning is intended, `delete` is the correct terminal operation. **Content-null:** An `update` on a content chain with `documentCID: null` means the content exists but its document is cleared. The chain continues — a subsequent update can set content again. ### `typ` Header The JWS `typ` header uses protocol-specific values (not IANA media types): | `typ` value | Usage | | ---------------------- | --------------------------------------------- | | `did:dfos:identity-op` | Identity chain operations | | `did:dfos:content-op` | Content chain operations | | `did:dfos:artifact` | Standalone signed inline documents | | `did:dfos:countersign` | Standalone witness attestations | | `did:dfos:revocation` | Credential revocation artifacts | | `did:dfos:credential` | DFOS authorization credentials | | `JWT` | Auth tokens (DID-signed relay authentication) | Protocol-specific `typ` values are non-standard per JOSE convention, documented intentionally. `JWT` follows IANA conventions. The `typ` header aids routing but is not security-critical. Implementations SHOULD validate it but MUST NOT rely on it for security decisions. See [CREDENTIALS.md](https://protocol.dfos.com/credentials) for credential `typ` values and format. ### Operation Versioning Every proof-plane operation payload (identity, content, artifact, countersign, revocation) carries a top-level integer `version` field. This document specifies version `1`; verifiers MUST reject any operation whose `version` is not exactly `1`. Both reference implementations pin `version: 1` and reject all other values. A future wire-incompatible revision of the operation format would increment this field, and implementations declare which versions they accept. The operation `version` is distinct from content-document `$schema` versioning (see [CONTENT-MODEL.md](https://protocol.dfos.com/content-model)), which versions application payloads independently and does not affect operation-level verification. ### Operation Size and Cardinality Limits The protocol bounds operations with **one aggregate size cap** plus a small set of **cardinality caps** — not a per-field string-length table. The single bound is measured over the exact CBOR bytes the CID commits to, so it is identical-by-construction across implementations; a per-field length table (`did ≤ 256`, `label ≤ 256`, …) would instead invite Unicode/length-counting divergence between implementations. **Aggregate operation size (size cap):** | Bound | Value | Applies to | | ---------------------------------- | ------------------------ | --------------------------------------- | | dag-cbor-encoded operation payload | **65536 bytes** (64 KiB) | identity operations, content operations | Verifiers MUST reject an identity or content operation whose `dagCborCanonicalEncode(payload)` exceeds 65536 bytes, **measured with any embedded `authorization` credential excluded** (see below). The cap is measured over the canonical CBOR bytes the operation CID commits to, so every implementation computes it identically (no Unicode/length-counting ambiguity). It is generous by design — a legitimate proof-layer operation is far smaller — and bounds decode/verify cost as a DoS guard. Credentials are NOT subject to this cap; they carry their own larger 262144-byte (256 KiB) ceiling (a maximum-depth delegation chain embeds each parent token in `prf` and legitimately exceeds 64 KiB — see [CREDENTIALS.md](https://protocol.dfos.com/credentials)). Artifacts keep their own 16384-byte cap (below); the `services` array keeps its 32768-byte cap (above). A delegated content `update`/`delete` carries its authorizing credential in the operation's `authorization` field, and that credential — itself bounded by the 262144-byte credential cap — can legitimately approach 256 KiB at maximum delegation depth. Counting it against the 64 KiB operation cap would conflate two independent limits and reject a valid deep-delegation write, so the operation-size cap is measured over the payload **with the `authorization` field removed**; the `authorization` credential is bounded separately by the credential cap. Total operation bytes are therefore bounded by the sum (≤ 64 KiB + 256 KiB). The operation CID still commits to the complete payload including `authorization`. **Cardinality caps (structure, not byte length):** | Field | Max | Rationale | | -------------------------------------------- | --------- | ----------------------------------------------- | | `authKeys` / `assertKeys` / `controllerKeys` | 256 items | Generous ceiling; op-size cap is the real bound | | `services` entries | 256 items | (see Services, above) | | countersignature `relation` | 64 chars | Open-namespace tag (min 1 when present) | The protocol does NOT limit individual field string lengths, **document content size** (the protocol commits to a CID, not the document — large binary media is referenced, not inlined), **chain length**, or **number of chains per identity**. These are application/transport concerns. **Resource policy (non-validity, MAY differ per node).** Beyond the validity-determining bounds above, a node SHOULD apply a **decoder recursion-depth guard** when canonicalizing/encoding a payload, as a DoS protection against pathologically nested input. Both reference implementations cap nesting at 1024 levels — generous enough that it never binds a legitimate operation (real payloads are a handful of levels deep). This is a local resource guard, **not** a chain-validity rule: it bounds a node's own stack cost and never changes which operations are part of the canonical chain. Nodes MAY apply stricter local ingress limits (max bytes decoded off the wire, rate limits) provided they never accept an operation the validity rules reject, nor reject one they accept. --- ## Standards and Dependencies | Component | Standard / Library | | ------------------- | ----------------------------------------------------------------------------------- | | Key generation | Ed25519 (RFC 8032) via `@noble/curves/ed25519` | | Signature algorithm | EdDSA over Ed25519 (pure, no prehash — Ed25519 handles SHA-512 internally) | | Key encoding | W3C Multikey (multicodec `0xed01` + base58btc multibase) | | Signed envelopes | JWS Compact Serialization (RFC 7515) with `alg: "EdDSA"` | | Content addressing | CIDv1 with dag-cbor codec (`0x71`) + SHA-256 multihash (`0x12`) | | ID encoding | SHA-256 → custom 19-char alphabet, 31 characters | | Timestamp encoding | Strict ISO-8601 / RFC 3339 UTC, fixed millisecond precision (see Timestamp Grammar) | ### ID Alphabet ``` Alphabet: 2346789acdefhknrtvz (19 characters) Length: 31 characters Entropy: ~131.6 bits (19^31) ``` Process: `SHA-256(input) → for each of first 31 bytes: alphabet[byte % 19]`. The modulo introduces a ~0.3% bias (256 is not evenly divisible by 19) — not security-relevant for identifiers. DIDs: `did:dfos:` + 31-char ID derived from `SHA-256(genesis CID raw bytes)` There is a single canonical identifier width. Verifiers MUST reject any `did:dfos:` identifier that is not exactly 31 characters over this alphabet — whether it appears in an operation's signing-key `kid`, in an operation payload, or as the DID of a resolved identity state. Key IDs: `key_` + 31-char ID. Convention: derive from public key hash (`key_` + `customAlpha(SHA-256(publicKey))`), making key IDs deterministic and verifiable. Not a protocol requirement — key IDs can be any string. ### Multikey Encoding (W3C Multikey for Ed25519) ``` Encode: 1. Take 32-byte Ed25519 public key 2. Prepend multicodec varint prefix [0xed, 0x01] (unsigned varint for 0xed = 237 = ed25519-pub) 3. Base58btc encode the 34-byte result 4. Prepend 'z' multibase prefix → "z6Mk..." Decode: 1. Strip 'z' multibase prefix 2. Base58btc decode → 34 bytes 3. First 2 bytes must be [0xed, 0x01] (ed25519-pub multicodec varint) 4. Remaining 32 bytes = raw Ed25519 public key ``` **Worked example:** ``` Public key (hex): ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Prefix + key (hex): ed01 ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Base58btc + 'z': z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb ``` Note: `[0xed, 0x01]` is the unsigned varint encoding of 237 (`0xed`). Since `0xed > 0x7f`, it requires two bytes in varint format: `0xed` (low 7 bits + continuation bit) then `0x01` (high bits). This is NOT big-endian `[0x00, 0xed]`. ### Timestamp Grammar Every operation's `createdAt` field MUST match exactly the grammar: ``` YYYY-MM-DDTHH:MM:SS.sssZ ``` That is, in order, with no other characters and no surrounding or internal whitespace: - a 4-digit zero-padded calendar **year** (`0000`–`9999`), - a literal `-`, a 2-digit zero-padded **month** (`01`–`12`), - a literal `-`, a 2-digit zero-padded **day** of month, - a literal uppercase **`T`** date/time separator, - a 2-digit zero-padded **hour** (`00`–`23`), `:`, a 2-digit **minute** (`00`–`59`), `:`, a 2-digit **second** (`00`–`59`), - a literal `.` followed by **exactly three** decimal digits of fractional seconds (millisecond precision — no more, no fewer), - a literal uppercase **`Z`** designating UTC. The value MUST be a real calendar instant: the month/day combination MUST be valid (leap years are honored — e.g. `2024-02-29` is valid, `2023-02-29` is not). A **timezone offset** (e.g. `+00:00`, `-05:00`) MUST NOT appear; only the literal `Z` is permitted. **Leap seconds** (`:60`) MUST be rejected. A lowercase `z`, a missing or differently-sized fractional part, a space in place of `T`, non-zero-padded fields, or any leading/trailing/embedded whitespace MUST be rejected. A verifier MUST reject any operation whose `createdAt` does not match this grammar. This applies on the read/verify path, not only at write time. This grammar is load-bearing for ordering. Per-branch timestamp ordering (Chain Validity → **Timestamp ordering**) and deterministic head selection (Chain Validity, step 2 — "highest `createdAt`") are implemented as a **lexicographic string comparison** of the `createdAt` field. Because the grammar is fixed-width and zero-padded, lexicographic byte order is identical to chronological order, so the comparison is correct without parsing. An implementation that accepted a looser grammar — variable fractional-second width, an offset form, a space separator, etc. — would compare strings whose lexicographic order no longer tracks time, forking ordering and head selection from conforming implementations. The grammar is therefore a validity rule, not a formatting suggestion. > The reference implementations gate this identically: the TypeScript library validates `createdAt` with `z.iso.datetime({ offset: false, precision: 3 })` and the Go library with `time.Parse("2006-01-02T15:04:05.000Z", …)` on the verify path. Both are exercised against a shared 22-case accept/reject vector set asserted verdict-for-verdict across the two implementations. ### CID Construction (dag-cbor + SHA-256) ``` 1. JSON payload → dag-cbor canonical encoding → CBOR bytes 2. SHA-256(CBOR bytes) → 32-byte hash 3. Construct CIDv1: - Version: 1 (varint: 0x01) - Codec: dag-cbor (varint: 0x71) - Multihash: SHA-256 (function: 0x12, length: 0x20, digest: 32 bytes) 4. CID binary = [0x01, 0x71, 0x12, 0x20, ...32 hash bytes] 5. Base32lower multibase encode → "bafyrei..." ``` dag-cbor canonical ordering: map keys sorted by encoded byte length first, then lexicographic. Strings to CBOR text strings. Null to CBOR null. Arrays to CBOR arrays. Objects to CBOR maps with sorted keys. #### Number Encoding (Critical for CID Determinism) JSON has a single number type (IEEE 754 double). CBOR has distinct integer and floating-point types with different byte encodings. This difference is the most common source of CID divergence across implementations. **Rule: JSON numbers that are mathematically integers (no fractional part) MUST be encoded as CBOR integers (major type 0/1), never as CBOR floats.** This is consistent with the [IPLD data model](https://ipld.io/docs/data-model/) integer/float distinction and required by the [dag-cbor codec spec](https://ipld.io/specs/codecs/dag-cbor/spec/). Why this matters: CBOR integer `1` encodes as a single byte `0x01`. CBOR float `1.0` encodes as three bytes `0xf9 0x3c 0x00` (half-precision). Same logical value, different bytes, different SHA-256, different CID. An implementation that encodes `version: 1` as a float will produce a valid CBOR document but a wrong CID — silent, undetectable without cross-implementation testing. **Common trap**: Languages that decode JSON into untyped maps (Go's `map[string]any`, Python's `dict`, etc.) typically represent all JSON numbers as floating-point. When this decoded value is then CBOR-encoded, it becomes a CBOR float instead of an integer. Implementations MUST normalize number types after JSON deserialization and before CBOR encoding. **Number bounds (normative)**: a canonicalizable number MUST be an integer in the range `[-(2^53 - 1), 2^53 - 1]` (JSON's safe-integer range). Implementations MUST reject — at CID derivation, before CBOR encoding — any payload containing a non-integer number, `NaN`, `±Infinity`, or an integer outside that range. Applications that need fractional or larger-magnitude values MUST encode them as strings. Bounding numbers to this single form is what makes the encoding deterministic across implementations: it eliminates both the shortest-float divergence (`1.5` encoded as `0xf9…` half-float by one library vs `0xfb…` double by another) and the integer-vs-`float64` split for values above `2^53`. The reference implementations enforce this in `dagCborCanonicalEncode` (TypeScript) and `DagCborEncode` (Go); a non-conforming number is a verification failure, not a silently-divergent CID. #### String Encoding (no Unicode normalization) String values are committed as their exact UTF-8 byte sequence. Implementations MUST NOT apply Unicode normalization (NFC, NFD, NFKC, NFKD) or any other transformation to string values before dag-cbor encoding or signing — the CID and signature commit to the bytes as received. Two strings that are Unicode-equivalent but byte-distinct (for example a precomposed `é` versus an `e` followed by a combining accent) produce different CIDs and are different protocol values. The reference implementations pass strings through verbatim (no `.normalize()` step); any normalization inserted by an implementation is a CID divergence, not an interoperable transformation. #### JSON Payload Canonicalization The signed JWS payload is decoded as JSON, then re-encoded as dag-cbor for CID derivation. Producers MUST emit canonical JSON: object keys unique within each object, no insignificant whitespace dependence (dag-cbor re-encodes from the decoded value, so whitespace and key order in the source JSON do not affect the CID). Producers MUST NOT emit duplicate object keys. Where duplicate keys are nonetheless present, both reference implementations decode via standard JSON parsers that retain the final occurrence (last value wins) before dag-cbor encoding — but this is a recovery behavior, not a guarantee: the signature commits to the raw payload bytes while the CID derives from the decoded value, so a duplicate-key payload can desync signature-input from CID across non-conforming parsers. Treat any payload containing duplicate keys as malformed. **Verification test vector** — encodes `{"version": 1, "type": "test"}`: ``` Integer encoding (CORRECT): CBOR: a2647479706564746573746776657273696f6e01 CID: bafyreihp6omsp6icc6ee63ox2ovsaxm6s7ikd2a7k5eh2qz2qd5soh5bsa Float encoding (WRONG — different bytes, different CID): CBOR: a2647479706564746573746776657273696f6ef93c00 CID: bafyreiawbms4476m5jlrmqtyvtwe5ta3eo2bh7mdprtomfgfype7j57o4q ``` If your implementation produces the float CID, your number encoding is incorrect. The byte at offset 19 in the CBOR output is the discriminator: `0x01` = correct (CBOR integer), `0xf9` = wrong (CBOR float16 header). **Worked example (genesis identity operation):** ``` CBOR bytes (468 bytes, hex): a66474797065666372656174656776657273696f6e0168617574684b65797381a362696478236b 65795f72396576333466766332337a39393976656161667438336e6e32397a7668656474797065 684d756c74696b6579727075626c69634b65794d756c74696261736578307a364d6b727a4c4d4e 776f4a5356345033596363576362746b387664394c74674d4b6e4c6561444c55714c7541536a62 696372656174656441747818323032362d30332d30375430303a30303a30302e3030305a6a6173 736572744b65797381a362696478236b65795f72396576333466766332337a3939397665616166 7438336e6e32397a7668656474797065684d756c74696b6579727075626c69634b65794d756c74 696261736578307a364d6b727a4c4d4e776f4a5356345033596363576362746b387664394c7467 4d4b6e4c6561444c55714c7541536a626e636f6e74726f6c6c65724b65797381a362696478236b 65795f72396576333466766332337a39393976656161667438336e6e32397a7668656474797065 684d756c74696b6579727075626c69634b65794d756c74696261736578307a364d6b727a4c4d4e 776f4a5356345033596363576362746b387664394c74674d4b6e4c6561444c55714c7541536a62 CID bytes (hex): 017112204e31ea9cb6ab4516ebdd812f7937e61601db07a16afb45723d286906f5181b69 CID string: bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne ``` ### DID Derivation (worked example) ``` Input: CID bytes (hex) = 017112204e31ea9cb6ab4516ebdd812f7937e61601db07a16afb45723d286906f5181b69 Step 1: SHA-256(CID bytes) = c66d21f27dceea0b05534c225ad7018ac7d4dfded0609dcd18022a3739a5488c Step 2: Take first 31 bytes: c6 6d 21 f2 7d ce ea 0b 05 53 4c 22 5a d7 01 8a c7 d4 df de d0 60 9d cd 18 02 2a 37 39 a5 48 Step 3: For each byte, alphabet[byte % 19]: c6=198 → 198%19=8 → 'c' 6d=109 → 109%19=14 → 'n' 21=33 → 33%19=14 → 'n' f2=242 → 242%19=14 → 'n' ... Result: cnnnft9f8a2rn938d6nkz38r847v2kr DID: did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr ``` --- ## Operation Schemas ### Identity Operations ```typescript // Genesis — starts the identity chain { version: 1, type: "create", authKeys: MultikeyPublicKey[], assertKeys: MultikeyPublicKey[], controllerKeys: MultikeyPublicKey[], // must have at least one services?: ServiceEntry[], // discovery vocabulary (optional) createdAt: string } // ISO 8601, ms precision, UTC // Key rotation / modification { version: 1, type: "update", previousOperationCID: string, // CID of previous operation authKeys: MultikeyPublicKey[], assertKeys: MultikeyPublicKey[], controllerKeys: MultikeyPublicKey[], // must have at least one services?: ServiceEntry[], // full-state — REPLACES the prior set createdAt: string } // Permanent destruction { version: 1, type: "delete", previousOperationCID: string, createdAt: string } ``` The optional `services` array is full-state discovery vocabulary projected into verified identity state — see [Services](#services). Omitting it encodes identically to a service-less operation (CID-neutral); an `update` carrying it REPLACES the entire prior set; a `delete` carries the last set unchanged. ### Content Operations ```typescript // Genesis — starts the content chain, commits initial document { version: 1, type: "create", did: string, // author DID, committed to by CID documentCID: string, // CID of flat content object baseDocumentCID: string | null, // committed-but-uninterpreted provenance createdAt: string } // Content change (null documentCID = clear content) { version: 1, type: "update", did: string, // author DID previousOperationCID: string, documentCID: string | null, baseDocumentCID: string | null, // committed-but-uninterpreted provenance createdAt: string, authorization?: string } // DFOS credential for delegated operations // Permanent destruction { version: 1, type: "delete", did: string, // author DID previousOperationCID: string, createdAt: string, authorization?: string } // DFOS credential for delegated operations ``` `baseDocumentCID` is committed-but-uninterpreted provenance — validated as CID-or-null, no verification meaning; lets the public proof plane express content-version lineage without exposing the private document. ### MultikeyPublicKey ```typescript { id: string, // e.g. "key_r9ev34fvc23z999veaaft83nn29zvhe" type: "Multikey", // literal discriminator publicKeyMultibase: string } // e.g. "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" ``` --- ## JWS Envelope Format ### Signing ``` signingInput = base64url(JSON.stringify(header)) + "." + base64url(JSON.stringify(payload)) signature = ed25519.sign(UTF8_bytes(signingInput), privateKey) token = signingInput + "." + base64url(signature) ``` ### kid Rules | Context | kid format | Example | | ------------------------- | ----------- | ------------------------------------- | | Identity create (genesis) | Bare key ID | `key_r9ev34fvc23z999veaaft83nn29zvhe` | | Identity update/delete | DID URL | See below | | All content ops | DID URL | See below | DID URL examples: ``` did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8 ``` ### `cid` Header Every operation JWS (identity-op and content-op) includes a `cid` field in the protected header. This is the CIDv1 string of the operation payload, derived from `dagCborCanonicalEncode(payload) → SHA-256 → CIDv1 → base32lower`. The `cid` is computed before signing and embedded in the protected header, so it is covered by the EdDSA signature. **Signing order:** 1. Construct the operation payload 2. Derive the operation CID: `dagCborCanonicalEncode(payload) → CIDv1` 3. Build the protected header including `cid` 4. Sign: `ed25519.sign(UTF8(base64url(header) + "." + base64url(payload)), privateKey)` **Verification rule:** After verifying the JWS signature and deriving the operation CID from the parsed payload, implementations MUST reject operations where: - `header.cid` is missing - `header.cid` does not match the derived CID A CID mismatch between header and derived value immediately surfaces dag-cbor encoding disagreements across implementations. Note: JWT auth tokens do NOT include a `cid` header. DFOS credentials DO include a `cid` header (for revocation addressability). This field is present on operation JWS tokens, artifacts, countersignatures, credentials, and revocations. ### CID Derivation ``` operation CID = dagCborCanonicalEncode(operation_payload) → SHA-256 → CIDv1 → base32lower string ``` The CID is derived from the JWS payload (the unsigned operation JSON), NOT from the JWS token itself. ### DID Derivation ``` DID = "did:dfos:" + idEncode(SHA-256(genesis_CID_raw_bytes)) ``` Where `idEncode` is the 19-char alphabet encoding described above. --- ## Signature Verification Profile DFOS pins a deliberately narrow profile of the JOSE/JWS surface so that **all conformant verifiers accept and reject the same signatures byte-for-byte**. The rules below are normative and apply to **every** verification path: identity-op JWS, content-op JWS, artifacts, countersignatures, DFOS credentials, credential revocations, and auth-token JWTs. A verifier MUST apply §1–§3 to the protected header **before** performing any signature computation, and MUST apply §4 as part of (or before) the signature check. A token that violates any rule MUST be rejected regardless of whether its signature would otherwise verify. There is no algorithm agility: the verifier never branches on `alg` to select a primitive. Ed25519 (`EdDSA`) is the only signature algorithm. ### 1. Algorithm pinning (`alg`) The protected header `alg` member MUST equal the exact string `"EdDSA"`. Any other value MUST be rejected before any signature check, including (non-exhaustively) `"none"`, `"HS256"`, `"RS256"`, `"ES256"`, the lowercase `"eddsa"`, or an absent `alg`. Verifiers MUST NOT use `alg` to choose a verification primitive; it is checked only for exact equality. ### 2. `crit` rejection The protected header MUST NOT contain a `crit` member. DFOS emits no critical header parameters, so any token whose protected header carries `crit` (with any value) MUST be rejected. Verifiers MUST observe the member's presence directly — decoding into a fixed header shape that silently discards unknown members is not sufficient. ### 3. No header-key-trust The verifier MUST NOT read key material from the protected header. The signing key is resolved exclusively from `kid` against the signer's identity chain (current state). A protected header that carries an embedded public key — specifically a `jwk` or `x5c` member — MUST be rejected. (DFOS emits neither; the resolved key from `kid` is the only trusted key material.) A header-supplied key is never trusted, even if it happens to match the resolved key. ### 4. Canonical signature scalar (`S < L`) An Ed25519 signature is `R || S` (64 bytes). The scalar `S` (the trailing 32 bytes, little-endian) MUST be canonical: strictly less than the group order ``` L = 2^252 + 27742317777372353535851937790883648493 = 0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed ``` A signature whose `S >= L` MUST be rejected (classic Ed25519 malleability). A signature that does not decode to exactly 64 bytes MUST also be rejected. Most Ed25519 libraries enforce `S < L` already; implementations on libraries that do not (notably `ed25519-dalek`, where even `verify_strict` accepts non-canonical `S`) MUST add an explicit constant-time `S < L` gate. ### Reserved for a future revision The following hardening axes are intentionally **deferred** to a later profile revision and are NOT part of v1. v1 verifiers inherit whatever behavior their Ed25519 library provides on these axes: - **Cofactorless verification equation pinning** — requiring the specific `[S]B == R + [k]A` (cofactorless) equation rather than the batch/cofactored form. - **Full-order public key check** — the out-of-band `[L]A == identity` torsion test confirming `A` is a full-order point. - **Canonical point encoding (`y < p`)** — rejecting non-canonical `y`-coordinate encodings of `R` and `A`. - **Small-order public key rejection** — beyond whatever the underlying library already rejects. - **Strict base64url tightening** — rejecting non-canonical base64url padding/alphabet beyond what the decoder already enforces. These axes only matter for adversarially-constructed keys. Honest DFOS keys are full-order and canonically encoded, and honest signers produce canonical `S`, so honest participants are unaffected by the deferral. Any residual cross-implementation divergence on these axes is reachable only with adversarial keys and is addressed when this profile is next revised. --- ## Credentials Credentials handle authentication and authorization for relay access and content chain delegation. The full credential format, verification rules, and revocation mechanism are specified in [CREDENTIALS.md](https://protocol.dfos.com/credentials). Summary of credential types: | Credential Type | Purpose | | --------------- | ------------------------------------------------------------- | | Auth token | DID-signed JWT proving identity (relay AuthN) | | DFOS credential | Authorize actions on resources (read, write) via attenuations | ### Content Chain Authorization When `enforceAuthorization` is enabled on content chain verification: 1. **Genesis operation**: The signer is the chain creator, always authorized 2. **Creator signs subsequent ops**: Authorized directly — no credential needed 3. **Different DID signs**: Must include an `authorization` field containing a valid DFOS credential where: - The delegation chain roots at the chain creator DID - The credential's `att` includes an entry with `action: "write"` covering this chain's resource - The credential is temporally valid (`iat <= now_s < exp`, where `now_s = floor(op.createdAt_ms / 1000)` — the operation's own timestamp, not wall clock; see CREDENTIALS.md) The `authorization` field is available on `update` and `delete` content operations. It is absent for creator-signed operations. ### Credential Revocation Credentials can be revoked by publishing a **revocation artifact** — a signed proof plane primitive with `typ: did:dfos:revocation`. Revocation is immediate and permanent. See [CREDENTIALS.md](https://protocol.dfos.com/credentials) for the revocation payload format and verification rules. --- ## Services `services` is an identity's **discovery vocabulary** — a controller-signed, full-state array carried in identity-chain `create`/`update` operations and projected into verified identity state. It answers "given a DID, where do I reach this identity, and what stable content does it publish?" Services are not a standalone primitive: they live inside identity operations, inherit the chain's signer rules (only a current controller key may change them), and inherit the chain's equivocation resolution (services are a pure projection of the winning head, so a forked log resolves to exactly one services set via the same deterministic head selection used for keys). ### Service Entry ```typescript { id: string, // did-core fragment, unique within the set (deref did:dfos:xxx#) type: string, // open namespace — recognized types are structurally validated ... // type-specific fields (see below) } ``` Every entry carries the common envelope `{ id, type }`. The namespace is **open**: two types are recognized and structurally validated; any other `type` is an opaque extension that verifiers MUST preserve verbatim and otherwise ignore (MUST-ignore-unknown). New service types therefore never require a protocol or cross-language change. **Recognized types:** ```typescript // Transport locator — where to reach a relay serving this identity { id: string, type: "DfosRelay", endpoint: string } // endpoint: bare URL string // Stable content reference under a client-defined semantic label { id: string, type: "ContentAnchor", label: string, anchor: string } ``` A `ContentAnchor`'s `anchor` references a **stable** content identifier, dispatched by structural form: | Anchor shape | Resolves to | | ----------------------------- | --------------------------------- | | `^[2346789acdefhknrtvz]{31}$` | content chain (mutable, gateable) | | `^bafyrei[a-z2-7]{52}$` | artifact (immutable, public) | These two shapes are the ONLY valid anchors, and the structural dispatch above is normative. The contentId anchor is the exact 31-char form `^[2346789acdefhknrtvz]{31}$`. The artifact anchor is the exact 59-char CIDv1(dag-cbor + SHA-256) base32 form `^bafyrei[a-z2-7]{52}$` — artifact payloads are always dag-cbor + SHA-256, so every artifact CID has the fixed `bafyrei` prefix and this exact length. An anchor matching NEITHER shape MUST be rejected (`AnchorInvalid`); verifiers do not accept other CID codecs or lengths. New anchor KINDS arrive via a new service `type`, never a new anchor shape — the dispatch surface stays closed. The `label` is an opaque client-semantic key (e.g. `"profile"`, `"avatar"`) — the protocol assigns it no meaning, leaving applications free to define their own namespaces while still resolving anchors uniformly. A chain HEAD CID is also a `bafyrei…` dag-cbor CID, so it dispatches to "artifact" and then fails the resolution-time `type: "artifact"` check — "never anchor a head CID" holds without a mode flag. ### Bounds - ≤ 256 entries per identity; entry `id`s MUST be unique within the set - `id`, `type`, and the recognized string fields (`endpoint`, `label`) MUST be non-empty (`anchor` MUST match the contentId/CID shape). Individual field lengths are NOT separately capped — the aggregate byte cap below, plus the operation-size cap, bound entry size (no per-field length zoo) - The CBOR-encoded `services` array MUST NOT exceed **32768 bytes**. Verifiers enforce this over the same canonical encoding used on the wire, so the bound is identical across implementations - An entry whose **recognized** type is structurally malformed (e.g. a `DfosRelay` without an `endpoint`) MUST be rejected at verification. A malformed **unrecognized** type is preserved and ignored (envelope + byte cap only) ### Full-state semantics `services` is full-state, not a delta. A `create` sets the initial set; an `update` REPLACES the entire set (omit the field to clear it); a `delete` carries the last set unchanged into terminal state. Omitting `services` encodes identically to a service-less operation (CID-neutral). ### Worked Example: Services `examples/identity-services.json` is a genesis publishing a relay locator and two content anchors (one content-chain, one artifact). Signed by reference key 1: ``` did: did:dfos:krhcznk98f7r2r4a6ktafcv77f7k6e2 typ: did:dfos:identity-op cid: bafyreiasjg3vqs4b3vepwy5qc4oy4f4vpkcahmwi64jtuei5cf7zqpdxjy services: [ { id: "relay", type: "DfosRelay", endpoint: "https://relay.dfos.com" }, { id: "profile", type: "ContentAnchor", label: "profile", anchor: "8n8fnzhrrefkrde6h72kfvff43r8c63" }, { id: "avatar", type: "ContentAnchor", label: "avatar", anchor: "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu" } ] ``` The full JWS token is in [`examples/identity-services.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/examples/identity-services.json). --- ## Artifacts Artifacts are standalone signed inline documents — immutable, CID-addressable proof plane primitives. Unlike chain operations which extend a sequence, an artifact is a single signed statement with no predecessor or successor. ### Payload ```json { "version": 1, "type": "artifact", "did": "did:dfos:...", "content": { "$schema": "https://schemas.dfos.com/profile/v1", "name": "Example" }, "createdAt": "2026-03-25T00:00:00.000Z" } ``` The `content` object MUST include a `$schema` string that identifies the artifact's schema. The schema acts as a discriminator — consumers use it to determine how to interpret the artifact's content. Schema names are free-form strings (no protocol-level registry). ### Constraints - **JWS `typ` header**: `did:dfos:artifact` - **Max payload size**: 16384 bytes CBOR-encoded. Protocol constant — not configurable - **Immutability**: Once published, an artifact is never updated or replaced - **CID-addressable**: Each artifact is addressed by the CID of its CBOR-encoded payload ### Verification 1. JWS signature verification against the signing DID's current key state 2. CID integrity — `header.cid` matches the CID computed from dag-cbor canonical encoding the raw payload 3. Payload schema validation — `version`, `type: "artifact"`, `did`, `content` with `$schema`, `createdAt` 4. Size limit — CBOR-encoded payload does not exceed 16384 bytes --- ## Countersignatures A countersignature is a standalone witness attestation — a signed statement that references a target operation by CID. Each countersignature has its own `typ` header (`did:dfos:countersign`), its own payload, and its own CID distinct from the target. It is the protocol's only **inter-subjective** primitive. Every other operation is monadic — a self-sovereign identity acting on its own chain. A countersignature is the signed trace of one subject witnessing another: an endorsement, a co-authorship, a solemnization. Where an artifact is the work, countersignatures are the collective attesting "we made this" — authorship rendered as a social act rather than a private claim. ### Payload ```json { "version": 1, "type": "countersign", "did": "did:dfos:witness...", "targetCID": "bafy...", "relation": "endorses", "createdAt": "2026-03-25T00:00:00.000Z" } ``` The `did` field is the witness identity — the DID signing the attestation. The `targetCID` references the operation being attested to. The optional `relation` field names the nature of the attestation. **`relation`** is an OPEN-namespace tag — an arbitrary 1–64 character string. A handful of values carry conventional social meaning (`endorses`, `coauthors`, `witnessed`, `holds`, `received`), but the namespace is unbounded: recognized values inform clients, unrecognized values MUST be preserved and ignored (MUST-ignore-unknown). The field is optional, so a bare witness attestation (no relation) encodes identically to one before this revision (CID-neutral). When present, `relation` is part of the canonical payload and therefore changes the countersignature's CID. ### Properties - **JWS `typ` header**: `did:dfos:countersign` - **Own CID**: Each countersignature has its own CID derived from its own payload, distinct from the target. This avoids the ambiguity of multiple JWS tokens sharing the same CID - **Stateless verification**: Signature + CID integrity + payload schema. No chain state required to verify the cryptographic validity of a countersignature - **Composable**: The `targetCID` can reference any CID-addressable operation — content ops, artifacts, identity ops, even other countersignatures - **Immutable**: Once published, a countersignature is permanent. There is no withdrawal primitive; consumers weight recency and may honor a newer attestation that supersedes an older relation ### Verification 1. Decode JWS, verify `typ` is `did:dfos:countersign` 2. Parse and validate countersign payload (`version`, `type: "countersign"`, `did`, `targetCID`, optional `relation` (1–64 chars when present), `createdAt`) 3. Verify the `kid` DID matches the payload `did` (the witness must sign with their own key) 4. CID integrity — `header.cid` matches the CID computed from dag-cbor canonical encoding the raw payload 5. Verify EdDSA JWS signature against the witness's public key Relay-level semantic checks (target exists, witness ≠ author, deduplication) are enforcement concerns, not protocol verification. Countersignatures live on the **proof plane** (public, gossiped). A countersignature is therefore unsuitable for crossing a public/private boundary: witnessing a target permanently and publicly links the witness DID to it. --- ## Verification Every signature check below is performed under the [Signature Verification Profile](#signature-verification-profile): `alg` is pinned to `"EdDSA"`, a `crit` member or any embedded header key (`jwk`/`x5c`) causes rejection before the signature is checked, and signatures with a non-canonical scalar (`S >= L`) or a non-64-byte length are rejected. ### Identity Chain 1. Decode each JWS, parse payload as IdentityOperation 2. First op MUST be `type: "create"` — this is the genesis bootstrap: - The controller keys declared in the genesis payload are trusted because the identity does not exist before this operation. There is no prior state to verify against. - The signing key (resolved from `kid`) MUST be one of the controller keys declared in this same operation. The genesis simultaneously introduces and authorizes its own keys. - Derive the operation CID via dag-cbor canonical encoding. Verify `header.cid` matches the derived CID. Derive the DID from the CID. 3. For each subsequent op: verify `previousOperationCID` matches previous op's derived CID. Verify `createdAt` is strictly greater than the parent operation's `createdAt` (MUST — see Chain Validity). 4. Verify the chain is not in a terminal state (deleted) before applying any operation. 5. Resolve `kid` — genesis uses bare key ID, non-genesis uses DID URL (extract DID, verify it matches the derived DID; extract key ID). 6. Find controller key matching key ID **in the current state** (i.e., the state after all preceding operations). Decode multikey → raw Ed25519 public key. 7. Verify EdDSA JWS signature over the signing input bytes. 8. Apply state change: `create` initializes key state, `update` replaces key state (must have at least one controller key), `delete` marks terminal. ### Content Chain 1. Decode each JWS, parse payload as ContentOperation 2. First op must be `type: "create"` — the signer is the chain creator 3. For each subsequent op: verify `previousOperationCID` matches, verify `createdAt` is strictly greater than the parent operation's `createdAt` (MUST) 4. Derive the operation CID via dag-cbor canonical encoding. Verify `header.cid` matches the derived CID. 5. Verify the `kid` DID matches the payload `did` field 6. Resolve `kid` via external key resolver (caller provides) 7. Verify EdDSA JWS signature 8. If `enforceAuthorization` is enabled and the signer DID differs from the chain creator: verify the `authorization` field contains a valid DFOS credential with `action: "write"` covering this chain, with a delegation chain rooting at the creator DID, and not expired at `op.createdAt` 9. Apply state change (set document, clear, or delete) --- ## Deterministic Reference Artifacts All artifacts below are deterministic and reproducible from fixed seeds. An independent implementer can verify every value using standard Ed25519 + dag-cbor libraries. Private keys are derived from `SHA-256(UTF8("dfos-protocol-reference-key-N"))`. ### Key 1 (Genesis Controller) ``` Seed: SHA-256("dfos-protocol-reference-key-1") Private key: 132d4bebdb6e62359afb930fe15d756a92ad96e6b0d47619988f5a1a55272aac Public key: ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Multikey: z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb Key ID: key_r9ev34fvc23z999veaaft83nn29zvhe ``` ### Key 2 (Rotated Controller) ``` Seed: SHA-256("dfos-protocol-reference-key-2") Private key: 384f5626906db84f6a773ec46475ff2d4458e92dd4dd13fe03dbb7510f4ca2a8 Public key: 0f350f994f94d675f04a325bd316ebedd740ca206eaaf609bdb641b5faa0f78c Multikey: z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK Key ID: key_ez9a874tckr3dv933d3ckdn7z6zrct8 ``` ### Identity Chain: Create (Genesis) Operation: ```json { "version": 1, "type": "create", "authKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "assertKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "controllerKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "createdAt": "2026-03-07T00:00:00.000Z" } ``` JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne" } ``` JWS Signature (hex): ``` 4dece71e7cebb4a3864ebd05ce40cbdb3fa5b8c5a701b297ae60db8be131830ff130f0a7630187391323c3e04cdbc7f44684e2ac801e0fb776d16e514ae1ae06 ``` JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJjaWQiOiJiYWZ5cmVpY29naHZqem52bGl1bG94eG1iZjU0dHB6cXdhaG5xcGlsazduY3hlcGppbmVkcGtnYTNuZSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlYXRlIiwiYXV0aEtleXMiOlt7ImlkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJ0eXBlIjoiTXVsdGlrZXkiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rcnpMTU53b0pTVjRQM1ljY1djYnRrOHZkOUx0Z01LbkxlYURMVXFMdUFTamIifV0sImFzc2VydEtleXMiOlt7ImlkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJ0eXBlIjoiTXVsdGlrZXkiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rcnpMTU53b0pTVjRQM1ljY1djYnRrOHZkOUx0Z01LbkxlYURMVXFMdUFTamIifV0sImNvbnRyb2xsZXJLZXlzIjpbeyJpZCI6ImtleV9yOWV2MzRmdmMyM3o5OTl2ZWFhZnQ4M25uMjl6dmhlIiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa3J6TE1Od29KU1Y0UDNZY2NXY2J0azh2ZDlMdGdNS25MZWFETFVxTHVBU2piIn1dLCJjcmVhdGVkQXQiOiIyMDI2LTAzLTA3VDAwOjAwOjAwLjAwMFoifQ.TeznHnzrtKOGTr0FzkDL2z-luMWnAbKXrmDbi-Exgw_xMPCnYwGHORMjw-BM28f0RoTirIAeD7d20W5RSuGuBg ``` Operation CID: ``` bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne ``` **Derived DID: `did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr`** ### Identity Chain: Update (Key Rotation) JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyreibfuh63uv33i2i5eooe3boit2ruyjehubsryemuuz6mrtlej26rei" } ``` Operation: ```json { "version": 1, "type": "update", "previousOperationCID": "bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne", "authKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "assertKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "controllerKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "createdAt": "2026-03-07T00:01:00.000Z" } ``` JWS Signature (hex): ``` edfaaf586115616f5ab40d6eaa9a7b94850e5a9e1d0132e92e33a6156cc937ef204cbf909d70c27b219c06ee405e11f33b9d9f6aec146af8752ab07ac0162e0b ``` JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciNrZXlfcjlldjM0ZnZjMjN6OTk5dmVhYWZ0ODNubjI5enZoZSIsImNpZCI6ImJhZnlyZWliZnVoNjN1djMzaTJpNWVvb2UzYm9pdDJydXlqZWh1YnNyeWVtdXV6Nm1ydGxlajI2cmVpIn0.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoidXBkYXRlIiwicHJldmlvdXNPcGVyYXRpb25DSUQiOiJiYWZ5cmVpY29naHZqem52bGl1bG94eG1iZjU0dHB6cXdhaG5xcGlsazduY3hlcGppbmVkcGtnYTNuZSIsImF1dGhLZXlzIjpbeyJpZCI6ImtleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa2ZVZDY1SnJBaGZkZ0Z1TUNjY1U5VGhRdmpCMmZKQU1VSGt1dWFqRjk5MmdLIn1dLCJhc3NlcnRLZXlzIjpbeyJpZCI6ImtleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa2ZVZDY1SnJBaGZkZ0Z1TUNjY1U5VGhRdmpCMmZKQU1VSGt1dWFqRjk5MmdLIn1dLCJjb250cm9sbGVyS2V5cyI6W3siaWQiOiJrZXlfZXo5YTg3NHRja3IzZHY5MzNkM2NrZG43ejZ6cmN0OCIsInR5cGUiOiJNdWx0aWtleSIsInB1YmxpY0tleU11bHRpYmFzZSI6Ino2TWtmVWQ2NUpyQWhmZGdGdU1DY2NVOVRoUXZqQjJmSkFNVUhrdXVhakY5OTJnSyJ9XSwiY3JlYXRlZEF0IjoiMjAyNi0wMy0wN1QwMDowMTowMC4wMDBaIn0.7fqvWGEVYW9atA1uqpp7lIUOWp4dATLpLjOmFWzJN-8gTL-QnXDCeyGcBu5AXhHzO52fauwUavh1KrB6wBYuCw ``` Operation CID: ``` bafyreibfuh63uv33i2i5eooe3boit2ruyjehubsryemuuz6mrtlej26rei ``` Post-rotation: DID unchanged (`did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr`), controller rotated to `key_ez9a874tckr3dv933d3ckdn7z6zrct8`. ### Content Chain: Document + Create Document (flat content object): ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "publishedAt": "2026-03-07T00:02:00.000Z", "title": "Hello World", "body": "First post on the protocol.", "credits": [ { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "label": "author" } ] } ``` Document CID: ``` bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu ``` Content Create JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:content-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8", "cid": "bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i" } ``` Content Create Payload: ```json { "version": 1, "type": "create", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "documentCID": "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu", "baseDocumentCID": null, "createdAt": "2026-03-07T00:02:00.000Z" } ``` Content Create JWS Signature (hex): ``` 069523331dffceae6af9bc4e40dd29978b5f81ffee648b7deebedf33f76e909afefb307483bb311bd21c06ccb4451dcffc3b482b8181d7b8a4c035030c79ee03 ``` Content Create JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmNvbnRlbnQtb3AiLCJraWQiOiJkaWQ6ZGZvczpjbm5uZnQ5ZjhhMnJuOTM4ZDZua3ozOHI4NDd2MmtyI2tleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwiY2lkIjoiYmFmeXJlaWJzM3ZsdmFpbmZqZnVldDZ4NHVkczNwaXZibWJvaHk3ZjY0aWVnYnV3M2dwc3VxdG1hNmkifQ.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlYXRlIiwiZGlkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciIsImRvY3VtZW50Q0lEIjoiYmFmeXJlaWU2eGZrcnR3YXgyZHE1Z2R3M3Jwc3VyejJnbHNkdXh5Y2ZoazdqamxsZXdpd2l2a2thZnUiLCJiYXNlRG9jdW1lbnRDSUQiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDMtMDdUMDA6MDI6MDAuMDAwWiJ9.BpUjMx3_zq5q-bxOQN0pl4tfgf_uZIt97r7fM_dukJr--zB0g7sxG9IcBsy0RR3P_DtIK4GB17ikwDUDDHnuAw ``` Content Operation CID: ``` bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i ``` ### Content Chain: Update Content Update Payload: ```json { "version": 1, "type": "update", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "previousOperationCID": "bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i", "documentCID": "bafyreiaoinzo2ai4hx56b7244zahnfqmgurcd3rppqbawhv32xzlvct5m4", "baseDocumentCID": "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu", "createdAt": "2026-03-07T00:03:00.000Z" } ``` Updated document (flat content object): ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "publishedAt": "2026-03-07T00:02:00.000Z", "title": "Hello World (edited)", "body": "Updated content.", "credits": [ { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "label": "author" } ] } ``` Document CID (edited): ``` bafyreiaoinzo2ai4hx56b7244zahnfqmgurcd3rppqbawhv32xzlvct5m4 ``` Content Update CID: ``` bafyreied5cjgjjt2pdz52k6pgipcjg3i4xl7txbrbdedscejvqhtgltxdi ``` ### Content Chain Verified State ``` Content ID: 8n8fnzhrrefkrde6h72kfvff43r8c63 Genesis CID: bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i Head CID: bafyreied5cjgjjt2pdz52k6pgipcjg3i4xl7txbrbdedscejvqhtgltxdi ``` --- ## Verification Checklist (For Independent Implementers) Given the artifacts above, verify: 1. **Multikey decode**: strip `z`, base58btc decode, strip `[0xed, 0x01]` prefix → raw public key: ``` z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb → ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 ``` 2. **Genesis JWS verify**: split token on `.`, take first two segments as signing input (UTF-8 bytes), base64url-decode third segment as 64-byte signature, `ed25519.verify(signature, signingInputBytes, publicKey)` → true. The header contains `cid` alongside `alg`, `typ`, and `kid`. 3. **Genesis CID**: base64url-decode JWS payload → parse JSON → dag-cbor canonical encode → SHA-256 → CIDv1 → should be: ``` bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne ``` 4. **CID header**: Verify each operation JWS header contains `cid` matching the derived operation CID 5. **DID derivation**: take raw CID bytes of genesis CID → SHA-256 → first 31 bytes → `byte % 19` → alphabet lookup → should be `cnnnft9f8a2rn938d6nkz38r847v2kr` → DID = `did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr` 6. **Rotation JWS**: signed by OLD controller key (key 1). Verify with key 1's public key. kid: ``` did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe ``` 7. **Content create JWS**: signed by NEW controller key (key 2, post-rotation). Verify with key 2's public key. kid: ``` did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8 ``` 8. **Document CID**: dag-cbor canonical encode the flat content object → SHA-256 → CIDv1 → should be: ``` bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu ``` 9. **Content operation `did` field**: verify the `did` field in each content operation matches the `kid` DID in the JWS header 10. **Content chain integrity**: update's `previousOperationCID` matches create's operation CID 11. **Chain completeness**: all operation CIDs, DID derivation, key rotation, and content chain linkage verified end-to-end. 12. **Credential verify**: using the issuer's public key, verify a DFOS credential with write or read access: check EdDSA signature, expiration, `kid` DID URL format, `kid` DID matches `iss`, credential type matches expected DFOS type. See [CREDENTIALS.md](https://protocol.dfos.com/credentials) for format details. Test vectors in [`examples/credential-write.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/examples/credential-write.json) and [`examples/credential-read.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/examples/credential-read.json). 13. **Delegated content chain verify**: using [`examples/content-delegated.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/examples/content-delegated.json), verify a content chain where the genesis is signed by the creator and a subsequent update is signed by a delegate with an embedded DFOS write credential in the `authorization` field. The credential must be issued by the creator DID, with `aud` matching the delegate DID. 14. **Number encoding determinism**: dag-cbor encode `{"version": 1, "type": "test"}` and verify: - CBOR hex is `a2647479706564746573746776657273696f6e01` (20 bytes) - CID is `bafyreihp6omsp6icc6ee63ox2ovsaxm6s7ikd2a7k5eh2qz2qd5soh5bsa` - Byte at offset 19 is `0x01` (CBOR integer 1), NOT `0xf9` (CBOR float header) - If your implementation decodes this payload from JSON (e.g., from a JWS token) and then re-encodes to dag-cbor, the CID MUST still match. This catches the JSON `float64` → CBOR float trap. --- ## Source and Verification All source lives in [`packages/dfos-protocol/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol) — self-contained, zero monorepo dependencies. Cross-language test counts are listed in the [table below](#cross-language-verification). - [`crypto/ed25519`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/ed25519.ts) — `createNewEd25519Keypair`, `importEd25519Keypair`, `signPayloadEd25519`, `isValidEd25519Signature` - [`crypto/jws`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/jws.ts) — `createJws`, `verifyJws`, `decodeJwsUnsafe` - [`crypto/jwt`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/jwt.ts) — `createJwt`, `verifyJwt` - [`crypto/base64url`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/base64url.ts) — `base64urlEncode`, `base64urlDecode` - [`crypto/multiformats`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/multiformats.ts) — `dagCborCanonicalEncode`, `dagCborCanonicalEqual` - [`crypto/id`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/crypto/id.ts) — `generateId`, `generateIdNoPrefix`, `isValidId` - [`chain/multikey`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/multikey.ts) — `encodeEd25519Multikey`, `decodeMultikey` - [`chain/schemas`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/schemas.ts) — `IdentityOperation`, `ContentOperation`, `ArtifactPayload`, `CountersignPayload`, `MultikeyPublicKey`, `VerifiedIdentity` - [`chain/identity-chain`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/identity-chain.ts) — `signIdentityOperation`, `verifyIdentityChain`, `verifyIdentityExtensionFromTrustedState` - [`chain/content-chain`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/content-chain.ts) — `signContentOperation`, `verifyContentChain`, `verifyContentExtensionFromTrustedState` - [`chain/derivation`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/derivation.ts) — `deriveChainIdentifier`, `deriveContentId` - [`chain/services`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/services.ts) — `classifyAnchor`, `relayEndpoints`, `anchorsByLabel` - [`chain/artifact`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/artifact.ts) — `signArtifact`, `verifyArtifact` - [`chain/countersign`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/countersign.ts) — `signCountersignature`, `verifyCountersignature` - [`credentials/auth-token`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/credentials/auth-token.ts) — `createAuthToken`, `verifyAuthToken` - [`chain/revocation`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/chain/revocation.ts) — `signRevocation`, `verifyRevocation` - [`credentials/dfos-credential`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/credentials/dfos-credential.ts) — `createDFOSCredential`, `verifyDFOSCredential`, `decodeDFOSCredentialUnsafe` - [`credentials/schemas`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/credentials/schemas.ts) — `AuthTokenClaims`, `DFOSCredentialPayload`, `Attenuation` ### Related Specifications - [DID Method: `did:dfos`](https://protocol.dfos.com/did-method) — W3C DID method specification for identity chains - [Credentials](https://protocol.dfos.com/credentials) — Auth tokens, DFOS credentials, and revocation - [Content Model](https://protocol.dfos.com/content-model) — Standard content schemas (post, profile) for document content objects - [Web Relay](https://protocol.dfos.com/web-relay) — HTTP relay specification for ingestion, state, and content plane ### Cross-Language Verification | Language | Tests | Source | | ---------- | ----- | -------------------------------------------------------------------------------------------------------- | | TypeScript | 246 | [`dfos-protocol/tests/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/tests) | | TypeScript | 73 | [`protocol-verify/ts/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify/ts) | | Go | 19 | [`protocol-verify/go/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify/go) | | Rust | 19 | [`protocol-verify/rust/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify/rust) | | Python | 63 | [`protocol-verify/python/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify/python) | | Swift | 18 | [`protocol-verify/swift/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify/swift) | --- # DID Method: `did:dfos` W3C DID Method specification for DFOS identity chains. Self-certifying, transport-agnostic, Ed25519-based decentralized identifiers. > **Status — Protocol v1: feature-complete and frozen.** The method core — `did:dfos` derivation, chain resolution, and the DID-document mapping — is **frozen** as part of the v1 surface; build on it as specified. Per the [core protocol status](https://protocol.dfos.com/spec), v1 is frozen but not yet final: clarifications are corrected in place and new capability lands additively, while a genuine break to a frozen field becomes v1.1 or v2 — never a silent edit. The reference packages stay on their own `0.x` semver line. Discuss in the [DFOS](https://nce.dfos.com) space. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol) · [Protocol Specification](https://protocol.dfos.com/spec) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## Abstract `did:dfos` identifiers are self-certifying — derived deterministically from the genesis operation of a cryptographically signed identity chain. No registry, no blockchain, no resolution service. The identifier itself is the trust anchor. Conforms to [W3C Decentralized Identifiers (DIDs) v1.0](https://www.w3.org/TR/did-core/). --- ## Conformance The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). --- ## 1. Introduction DFOS is a protocol for verifiable identity and content chains using Ed25519 signatures and content-addressed CIDs. Every identity in DFOS is an append-only chain of signed operations — a self-sovereign log of key management events. The DID for an identity is derived deterministically from the hash of the chain's genesis operation, making `did:dfos` identifiers **self-certifying**: given the chain, anyone can independently verify the DID without trusting the source. This property makes `did:dfos` fundamentally transport-agnostic. There is no privileged registry, blockchain, or consensus layer. The chain can be obtained from any source — an HTTP API, a peer-to-peer exchange, a local file, a USB drive — and the verifier can independently confirm the chain belongs to the claimed DID. For full protocol details including cryptographic primitives, chain mechanics, and test vectors, see the [DFOS Protocol Specification](https://protocol.dfos.com/spec). ### 1.1 Design Goals - **Self-certifying** — The DID is a deterministic derivation of the genesis content. No external authority is needed to verify the binding between identifier and chain. - **Transport-agnostic** — Resolution requires obtaining and verifying a chain, not querying a specific endpoint. Any system that stores and serves identity chains is a valid source. - **Key rotation** — Identity chains support full key rotation via signed update operations. Keys can be added, removed, and replaced without changing the DID. - **Deactivation** — Identities can be deactivated via a signed delete operation. The `delete` record is permanent, but deactivation itself is reversible only by the controller via fork (see §5.4). - **Minimal** — The method defines identifiers and verification. It deliberately does not define discovery, gossip, or consensus mechanisms. --- ## 2. DID Method Name The method name is `dfos`. A DID using this method MUST begin with the prefix `did:dfos:`. --- ## 3. Method-Specific Identifier The method-specific identifier is a 31-character string derived from the genesis operation CID of an identity chain. ### 3.1 ABNF ```abnf dfos-did = "did:dfos:" dfos-id dfos-id = 31dfos-char dfos-char = "2" / "3" / "4" / "6" / "7" / "8" / "9" / "a" / "c" / "d" / "e" / "f" / "h" / "k" / "n" / "r" / "t" / "v" / "z" ``` The alphabet is 19 characters: `2346789acdefhknrtvz`. The identifier is exactly 31 characters, providing ~131.6 bits of entropy. An identifier that does not match `dfos-id` — the exact 31-character form over this alphabet — is not a valid `did:dfos` identifier. Resolvers and verifiers MUST reject any operation that references, and any resolved state that yields, a `did:dfos` identifier of any other length or character set. ### 3.2 Derivation The method-specific identifier is derived deterministically from the genesis identity operation: ``` 1. Construct the genesis identity operation payload (type: "create") 2. Canonical-encode the payload as dag-cbor → CBOR bytes 3. Hash: SHA-256(CBOR bytes) → 32-byte digest 4. Construct CIDv1: [0x01, 0x71, 0x12, 0x20, ...32 digest bytes] → CID bytes 5. Hash the CID: SHA-256(CID bytes) → 32-byte digest 6. Encode: for each of the first 31 bytes → alphabet[byte % 19] ``` The resulting 31-character string is the method-specific identifier. The full DID is `did:dfos:` prepended to this string. ### 3.3 Example ``` Genesis CID bytes (hex): 017112204e31ea9cb6ab4516ebdd812f7937e61601db07a16afb45723d286906f5181b69 SHA-256 of CID bytes: c66d21f27dceea0b05534c225ad7018ac7d4dfded0609dcd18022a3739a5488c First 31 bytes encoded: cnnnft9f8a2rn938d6nkz38r847v2kr DID: did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr ``` See the [DFOS Protocol Specification](https://protocol.dfos.com/spec) for the complete worked example with key material, CBOR encoding, and CID construction. --- ## 4. DID Document A resolved `did:dfos` DID Document is constructed from the current state of the identity chain — specifically, the key sets declared in the most recent non-terminal operation. ### 4.1 DID Document Structure ```json { "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "controller": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "verificationMethod": [ { "id": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "controller": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "authentication": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ], "assertionMethod": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ], "capabilityInvocation": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ] } ``` ### 4.2 Verification Method Mapping Identity chain operations declare three key sets. These map to W3C verification relationships as follows: | Identity Chain Key Set | W3C Verification Relationship | Purpose | | ---------------------- | ----------------------------- | -------------------------------------------------------------------- | | `authKeys` | `authentication` | Prove control of the DID (e.g., login, session establishment) | | `assertKeys` | `assertionMethod` | Issue verifiable assertions (e.g., sign content chain operations) | | `controllerKeys` | `capabilityInvocation` | Manage the DID itself (sign identity chain update/delete operations) | Each key in the identity chain state becomes a `verificationMethod` entry. The `id` is constructed as a DID URL: `did:dfos:#`. The `type` is `Multikey`. The `publicKeyMultibase` is the W3C Multikey encoding (multicodec `0xed01` prefix + base58btc + `z` multibase prefix). **Key-id uniqueness and cross-role keys.** Within a single key set (`authKeys`, `assertKeys`, or `controllerKeys`) on any operation that declares key sets (`create` or `update`), key ids MUST be unique — the verifier rejects repeated ids in the same usage section. The _same_ key id MAY appear across different sets; this is the common case, where one key serves authentication, assertion, and control simultaneously (as in the reference document above, where one key id is referenced from `authentication`, `assertionMethod`, and `capabilityInvocation`). When building the DID Document, verification methods are keyed by their DID-URL `id`, so a key id appearing in multiple roles yields a single `verificationMethod` entry referenced from each relationship rather than three duplicate entries. ### 4.3 Controller `did:dfos` identities are self-sovereign. The `controller` property of the DID Document is always the DID itself. Only keys within the identity chain's `controllerKeys` set can sign operations that modify the chain. ### 4.4 Key Rotation When an identity chain includes `update` operations that change the key sets, the DID Document reflects the **current state** — the key sets from the most recent operation. Previous keys are not included in the resolved DID Document. Historical key states can be recovered by walking the chain. ### 4.5 Services Identity chain `create`/`update` operations MAY carry a controller-signed `services` array — the identity's discovery vocabulary. It is full-state (an `update` replaces the entire set), bounded (≤256 entries, unique ids, a 32768-byte cap on the canonical encoding), and projected into verified identity state alongside the key sets. The complete normative definition is in [PROTOCOL.md → Services](https://protocol.dfos.com/spec#services). Each entry projects into the DID Document `service` array. Every entry carries the common envelope `{ id, type }`; the entry `id` becomes the DID-URL fragment (`did:dfos:#`). Two service types are recognized and structurally validated; the namespace is open, and unrecognized types are preserved verbatim and ignored. | Service `type` | Fields | DID Document mapping | | --------------- | ----------------- | -------------------------------------------------------------- | | `DfosRelay` | `endpoint` (URL) | `serviceEndpoint` = the relay URL | | `ContentAnchor` | `label`, `anchor` | `serviceEndpoint` = the anchor; `label` retained as a property | A `ContentAnchor`'s `anchor` is a **stable** content identifier dispatched by shape: a 31-character contentId resolves to a content chain; a `baf…` CIDv1 resolves to an artifact. Because the namespace is open, services beyond the proof plane define their own types additively. The document gateway adds `DfosDocumentGateway` (a gateway endpoint) and `DfosProfile` (a contentId-or-CID profile anchor); a relay that does not recognize them preserves and ignores them. See [DOCUMENT-GATEWAY.md → Discovery](https://protocol.dfos.com/document-gateway#discovery). ```json "service": [ { "id": "did:dfos:zhkrrzrd7z623ha8tt7dt699de8r3ar#relay", "type": "DfosRelay", "serviceEndpoint": "https://relay.dfos.com" }, { "id": "did:dfos:zhkrrzrd7z623ha8tt7dt699de8r3ar#profile", "type": "ContentAnchor", "label": "profile", "serviceEndpoint": "cv7n8vkvr64cctf3294h9k4eanhff8z" } ] ``` --- ## 5. Operations ### 5.1 Create Creating a `did:dfos` identifier means constructing and signing a genesis identity chain operation. 1. Generate one or more Ed25519 key pairs. 2. Construct the genesis operation payload with `type: "create"`, populating `authKeys`, `assertKeys`, and `controllerKeys`. At least one `controllerKeys` entry is REQUIRED. 3. Canonical-encode the payload as dag-cbor, derive the CID, and include it in the JWS protected header as `cid`. 4. Sign the operation as a JWS Compact Serialization token using one of the controller keys. The `kid` in the protected header is the bare key ID (not a DID URL, since the DID does not yet exist). 5. The DID is derived from the genesis CID as described in [Section 3.2](#32-derivation). The identity chain now exists as a single-operation chain. It can be stored in any system that serves identity chains. ### 5.2 Read (Resolve) Resolving a `did:dfos` DID means obtaining the identity chain and constructing a DID Document from its current state. #### 5.2.1 Resolution Algorithm Given a DID `did:dfos:`: 1. **Obtain** the identity chain from any available source. The method does not prescribe how chains are discovered or transported. 2. **Verify** the chain: a. Decode each JWS token and parse the operation payload. b. The first operation MUST be `type: "create"`. c. Derive the genesis operation CID via dag-cbor canonical encoding. d. Verify that `SHA-256(genesis CID bytes)` encoded with the ID alphabet produces ``. If it does not match, the chain does not belong to this DID — reject it. e. For each operation, verify the JWS EdDSA signature against the appropriate key (controller key from current chain state). f. Verify `previousOperationCID` linkage, `createdAt` ordering, and `header.cid` consistency. g. See the [DFOS Protocol Specification](https://protocol.dfos.com/spec) for complete verification rules. 3. **Construct** the DID Document from the terminal chain state using the mapping in [Section 4.2](#42-verification-method-mapping). #### 5.2.2 Resolution Metadata | Property | Value | | ---------------- | ------------------------------------------------------------ | | `contentType` | `application/did+ld+json` | | `created` | `createdAt` from the genesis operation | | `updated` | `createdAt` from the most recent operation | | `deactivated` | `true` if the chain's terminal operation is `type: "delete"` | | `operationCount` | Number of operations in the chain | #### 5.2.3 Self-Certification The critical property of `did:dfos` resolution: **the DID is verified against the chain, not the source.** Step 2d above is the self-certification check — it proves the chain belongs to the claimed DID using only the chain content and a hash function. This means: - A resolver does not need to trust the registry, server, or peer that provided the chain. - The same chain can be served by multiple independent sources with identical results. - Chains can be cached, replicated, and redistributed without loss of verifiability. - Offline resolution is possible if the chain is available locally. #### 5.2.4 Transport Bindings (Non-Normative) The `did:dfos` method is transport-agnostic. Any system that can deliver an ordered sequence of JWS tokens (the identity chain) is a valid transport. Examples include: - **HTTP API** — Any HTTP service that stores and retrieves ordered JWS logs can serve as a transport binding. - **Peer-to-peer exchange** — Chains can be exchanged directly between parties. - **Local storage** — Chains can be stored in local files, databases, or key-value stores. - **Bundle export** — Applications can export chains as portable bundles (e.g., JSON arrays of JWS tokens). ### 5.3 Update Updating a `did:dfos` DID means appending a signed `update` operation to the identity chain. 1. Construct an update operation payload with `type: "update"`, the new key sets, and `previousOperationCID` set to the CID of the current chain tip. 2. Sign the operation using a key from the **current** `controllerKeys` set. The `kid` is a DID URL: `did:dfos:#`. 3. Append the signed JWS token to the chain. The DID does not change. The resolved DID Document now reflects the new key sets. ### 5.4 Deactivate (Delete) Deactivating a `did:dfos` DID means appending a signed `delete` operation to the identity chain. 1. Construct a delete operation payload with `type: "delete"` and `previousOperationCID` set to the CID of the current chain tip. 2. Sign the operation using a key from the current `controllerKeys` set. The `kid` is a DID URL. 3. Append the signed JWS token to the chain. After deactivation: - The resolved head reports the identity as deactivated. Resolution MUST return a DID Document with `deactivated: true` in the resolution metadata, and the DID Document SHOULD contain an empty set of verification methods, as the identity no longer has active keys. - The `delete` operation is a **permanent, auditable fact** in the chain log — it is gossiped and retained like any other operation and never removed. - **Linear** extension is sealed: appending a new operation from the deleted head is rejected. Deactivation is **reversible by the controller, and only by the controller**. Because the web relay accepts forks (see WEB-RELAY.md, _Fork Acceptance_), a current controller MAY supersede a delete by forking from a pre-delete operation with a higher `createdAt`; deterministic head selection then makes the non-deleted branch the head and resolution reports `deactivated: false`. The original `delete` remains permanently in the log on a non-head branch. This is a deliberate consequence of the fork-convergence model: a controller cannot permanently brick an identity it controls by a mistaken delete, while **no external party can ever reactivate (or extend) an identity** — every operation, `delete` and undelete alike, must be signed by a current controller key (Section 6.3). Treating deactivation as a true protocol-level seal against the holder's own forks — and the adversarial cases that motivate it — is out of scope for this revision and deferred to future work. --- ## 6. Security Considerations ### 6.1 Self-Certifying Identifiers `did:dfos` identifiers are derived from a cryptographic hash of the genesis operation content. This binding is verified during resolution (Section 5.2.1, step 2d). An attacker cannot present a forged chain for a given DID — the genesis content would hash to a different identifier. The identifier is 31 characters over a 19-symbol alphabet, so the encoded identifier space is `19^31 ≈ 2^131.6`. The relevant attack costs are: - **Birthday collision** (two genesis chains that derive the same identifier): `≈ 2^65.8` work. - **Targeted second-preimage** (forge a chain that derives a _specific_ victim identifier): `≈ 2^131.6` work, bounded by the identifier space rather than the full 256-bit SHA-256 output. Both costs sit comfortably above the 128-bit security floor, so the self-certification binding is not the weakest link relative to the Ed25519 signatures (≈128-bit) or SHA-256 (256-bit) primitives it composes. ### 6.2 Key Compromise If a controller key is compromised, the legitimate holder should immediately sign a key rotation (`update`) operation removing the compromised key. The protocol does not support key pre-rotation — there is no mechanism to pre-commit to a future key. The window of vulnerability exists between compromise and rotation. Because each role set holds up to 256 keys and any one current key in a set can authorize an operation, an identity can hold controller and auth keys on multiple devices (1-of-N availability). Any one held key can independently act, so a single lost or destroyed device key is not loss of the identity — a key on a surviving device can still rotate out the lost one. This is availability, not recovery: it requires registering additional keys in advance, while a controller key is still held. Note that 1-of-N availability is symmetric with the compromise surface — any held key in a set can authorize, so each additional device key is also an additional thing to keep safe. ### 6.3 Equivocation Because `did:dfos` has no global consensus layer, an identity holder could theoretically sign two different operations at the same chain position (same `previousOperationCID`, different payloads). This creates a **fork** — two valid chain branches. Equivocation is **detectable**: a verifier who encounters two valid operations sharing a `previousOperationCID` can identify the conflict. Resolution policy for equivocation (reject both branches, prefer one, flag for human review) is an application-level concern and is deliberately outside the scope of this method specification. In practice, equivocation requires the identity holder to act against themselves — no external party can extend an identity chain, since all operations must be signed by a current controller key. ### 6.4 Transport Security The `did:dfos` method does not mandate any specific transport security. Because resolution is verification-first (the chain is validated against the DID, not the source), transport-layer attacks (MITM, DNS hijacking) cannot produce a valid chain for a targeted DID. An attacker who intercepts a chain request can: - **Withhold** the chain (denial of service) — the resolver gets no result - **Serve a stale chain** — the resolver gets a valid but outdated DID Document - **Serve a completely different chain** — the self-certification check fails, the resolver rejects it An attacker **cannot** serve a modified or forged chain that passes the self-certification check. ### 6.5 Denial of Service A resolver that depends on a single source for chain retrieval is vulnerable to denial of service. Applications SHOULD support multiple chain sources and MAY cache verified chains locally to mitigate this. ### 6.6 Cryptographic Agility The current specification uses Ed25519 exclusively. The protocol does not currently support multiple signature algorithms. Future versions MAY introduce additional algorithms via new multicodec identifiers and verification method types. Implementations MUST reject operations signed with unrecognized algorithms. --- ## 7. Privacy Considerations ### 7.1 Correlation `did:dfos` identifiers are persistent and globally unique. Any content chain signed by a DID can be correlated to the same identity. Users who require unlinkability across contexts should use distinct identities (distinct identity chains and DIDs) for each context. ### 7.2 Key Material Identity chains contain only public keys. Private key material is never included in the chain and MUST NOT be transmitted during resolution. ### 7.3 Chain History The full identity chain is available to any resolver. This reveals the history of key rotations, including timestamps (`createdAt`). Applications that consider key rotation history sensitive should be aware that this metadata is inherently public as part of the chain. ### 7.4 Herd Privacy Because `did:dfos` resolution can happen through any transport (including local storage), a resolver does not necessarily reveal which DIDs it is interested in. However, when using a shared registry API, the registry operator can observe resolution patterns. Applications with strong privacy requirements SHOULD resolve chains through privacy-preserving transports or maintain local chain caches. --- ## 8. Reference Implementation A complete reference implementation is available as the `@metalabel/dfos-protocol` npm package: - **npm**: [@metalabel/dfos-protocol](https://www.npmjs.com/package/@metalabel/dfos-protocol) - **Source**: [github.com/metalabel/dfos](https://github.com/metalabel/dfos) - **Cross-language verification**: Go, TypeScript, Python, Rust, and Swift implementations verify the same deterministic test vectors --- ## 9. References ### 9.1 Normative References | Reference | URI | | --------------------------- | --------------------------------------------------- | | W3C DID Core 1.0 | https://www.w3.org/TR/did-core/ | | W3C Multikey | https://www.w3.org/TR/controller-document/#multikey | | RFC 2119 (Key Words) | https://www.rfc-editor.org/rfc/rfc2119 | | RFC 7515 (JWS) | https://www.rfc-editor.org/rfc/rfc7515 | | RFC 8032 (Ed25519) | https://www.rfc-editor.org/rfc/rfc8032 | | DFOS Protocol Specification | https://protocol.dfos.com/spec | ### 9.2 Informative References | Reference | URI | | ----------------------- | ------------------------------------------- | | W3C DID Spec Registries | https://w3c.github.io/did-spec-registries/ | | Multicodec Table | https://github.com/multiformats/multicodec | | CIDv1 Specification | https://github.com/multiformats/cid | | dag-cbor Codec | https://ipld.io/specs/codecs/dag-cbor/spec/ | --- # DFOS Content Model Standard content schemas for documents committed to DFOS content chains. JSON Schema (draft 2020-12) definitions for content objects committed by CID. > **Status — encoding rule frozen; vocabulary on its own clock.** The one normative constraint here — the integer-only number-encoding rule below — is part of the frozen [Protocol v1](https://protocol.dfos.com/spec) wire and will not change. The schema _vocabulary_ these documents define is additive and evolves on its own `0.x` content-schema line, independent of the v1 freeze. The protocol commits to content by hash — it never inspects what's inside, beyond one canonicalization constraint. Any JSON object with a `$schema` field can be committed, with a single rule on numbers: every number MUST be an integer in JSON's safe range (`[-(2^53 - 1), 2^53 - 1]`) — no fractions, `NaN`, or `±Infinity`. Encode fractional or larger-magnitude values as strings. This keeps the content CID byte-identical across implementations (see Number Encoding in the [protocol spec](https://protocol.dfos.com/spec)). These schemas define the vocabulary DFOS uses internally and serve as the starting vocabulary for applications built on the protocol. [Protocol Specification](https://protocol.dfos.com/spec) · [schemas.dfos.com](https://schemas.dfos.com) · [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/schemas) --- ## Schema Convention Content objects are committed directly to a content chain by CID. The CID is derived from the canonical dag-cbor encoding of the content object itself: ``` documentCID = CID(dagCborCanonicalEncode(contentObject)) ``` The protocol requires one thing of the content object: it must include a `$schema` property identifying its content type. ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "body": "Hello world." } ``` Because `$schema` is part of the content object, it is behind the `documentCID` — cryptographically committed in the content chain. Any verifier can resolve the document, read `$schema`, and validate against the schema. Documents are self-describing. --- ## Schema Evolution Schemas are versioned via the URI path (`/post/v1`, `/post/v2`). Evolution rules: - **Strictly additive within a version** — new optional fields can be added to an existing version at any time without breaking existing documents - **Breaking changes require a new version** — removing fields, changing types, or adding new required fields means a new version URI - **Implementations declare which versions they understand** — a registry or application can accept `post/v1` and `post/v2` simultaneously, or only `post/v1` Immutability here is twofold, and the two senses are deliberately distinct: 1. **Document immutability** — every committed document is CID-addressed and byte-immutable. A specific document, once published, can never change; an edit is a new document with a new CID appended to the content chain. 2. **Schema-version immutability** — a published schema version (e.g. `post/v1`) evolves only additively. Adding optional fields to `post/v1` never invalidates documents already committed against it. Removing or retyping a field is a new version (`post/v2`), never an in-place change. A document's own field _values_ (e.g. `format`) are fixed at the operation that set them — see the `format` field below. --- ## Standard Schemas Schema files live in [`schemas/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/schemas) in the protocol package. Each is a standalone JSON Schema (draft 2020-12) definition, served at `https://schemas.dfos.com`. ### Post (`https://schemas.dfos.com/post/v1`) The primary content type. Covers short posts and long-form posts via the `format` discriminator. Comments and replies are deliberately **not** `post/v1` surface: threaded content needs signed target linkage (see [Intra-Chain References](#intra-chain-references-targetoperationcid)) and arrives as its own schema rather than as additional `format` values. | Field | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/post/v1"` | | `format` | enum | yes | `"short-post"`, `"long-post"` — fixed at chain genesis and not changed by later revisions | | `publishedAt` | string | no | Asserted original publication time (ISO 8601) — see **Two clocks** below | | `title` | string | no | Post title (typically for long-post format) | | `body` | string | no | Post body content — markdown (CommonMark) text | | `cover` | media | no | Cover image as a [Media object](#media-object) | | `attachments` | media[] | no | Attached media as [Media objects](#media-object) | | `credits` | credit[] | no | Ordered authorship credits — `{ did, label? }` entries; see below and [Authorship](#authorship) | **Two clocks.** A post document and its operations carry two distinct times, and conflating them corrupts both. The operation's `createdAt` records **when the operation was signed** — it is load-bearing protocol state (head selection orders by it) and always tells the truth about chain history. `publishedAt` records **when the content was originally published**, as asserted inside the signed document: a chain anchored long after the fact carries the original publication time here while its genesis operation truthfully records the later anchoring time. `publishedAt` is assertion-tier (like `credits` — the protocol verifies the signer, never the claim), and author-revisable: a later revision MAY change it — deliberate back-dating or correction is an ordinary content edit, and the operation log preserves every previously committed value, so re-dating is always auditable. Never backdate operation `createdAt` to encode publication time. **Why no `topics`.** Topic/category labels are deliberately absent from the document. They are mutable organizational taxonomy — renameable and re-assignable without any authorial act — and a signed, CID-committed document that embeds them turns every taxonomy rename into a corpus-wide revision wave. The document commits to what the author published; discovery-plane organization (topics, collections, routing) belongs to [index](#index-httpsschemasdfoscomindexv1) entries and host-side projections, which are mutable and cheap to rebuild. **Credits.** Each credit is `{ did, label? }`: `did` (REQUIRED) is the credited identity; `label` (OPTIONAL) is a free-text role — display vocabulary, not an enum (`"author"`, `"editor"`, `"photography"`, …), the same display-string register as the `label` in [index](#index-httpsschemasdfoscomindexv1) entry metadata. Array order is display order, and the **first entry is the primary author**. Omit `credits` entirely for unattributed content (system or imported posts). Credits are the **assertion tier** of the [authorship lattice](#authorship): the operation signer asserts them, and the protocol verifies the signer, never the credits. A credited DID can upgrade its credit from assertion to proof with a [claim operation](#authorship). **Body ↔ attachment binding.** A post body MAY embed `attachment://` refs inline (for example, an image reference inside markdown). Each inline ref SHOULD have a corresponding entry in `attachments` whose `uri` is that same `attachment://` ref — the Media object is where the integrity commitment (`cid`) for an inline ref lives. The body names media; `attachments` carries the verifiable reference. > **Pre-adoption amendments (2026-07).** This shape is the product of two breaking amendments made **in place**, deliberately without minting `post/v2`, both inside the pre-adoption window. The first replaced an earlier draft — a `createdByDID` string in place of `credits`, and a legacy `{ id, uri? }` media shape predating the [Media object](#media-object) — while zero `post/v1` documents existed on any chain. The second (this revision) removed `topics`, narrowed `format` to the two post formats, and added `publishedAt`, while every existing `post/v1` document lived only on shadowed (never-served) chains that were wiped and re-anchored under the amended shape — equivalent, from any external observer's standpoint, to the documents never having existed. **The window closes for good when post chains are first publicly served**: from that point `post/v1` evolves only additively, and any further breaking change is a `post/v2`. ### Profile (`https://schemas.dfos.com/profile/v1`) The displayable identity for any agent, person, group, or space. | Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/profile/v1"` | | `name` | string | no | Display name | | `description` | string | no | Short bio or description | | `avatar` | media | no | Avatar image as a [Media object](#media-object) | | `links` | link[] | no | External links — up to 20 `{ uri, label?, description? }` items | `avatar` is an **additive** `profile/v1` field (per the schema-evolution rules above — no `profile/v2`): existing avatar-less profile documents remain valid, and implementations that predate the field ignore it. It is the first consumer of the [Media object](#media-object) shape: ```json { "$schema": "https://schemas.dfos.com/profile/v1", "name": "Alice", "avatar": { "uri": "attachment://media_abc123", "cid": "bafkreibovzpnn2y6dquvxhidhx64hg7smduemox7drjs4vprjhlbmivfli" } } ``` ### Index (`https://schemas.dfos.com/index/v1`) An **index chain** is a curated map of content refs — a space's catalog, an author's works, a reading list, a set of pinned items. It is an LWW-Map folded via the [canonical fold](#canonical-fold): each operation commits an `index/v1` document carrying deltas, and the resolved index is the fold over every operation in the log. An index document carries an **array of deltas** — matching the delta-per-event shape of the [reference content stream](#reference-content-stream-schema). A single append can set or remove several entries at once, and the index accumulates through many small delta documents instead of re-committing a whole catalog each time. (Note the deltas live in the document blob, which the operation-size cap does not measure — content operations commit only the `documentCID`. The protocol does not bound document blob size; any blob limit is gateway or application policy.) | Field | Type | Required | Description | | --------- | ------- | -------- | -------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/index/v1"` | | `deltas` | delta[] | yes | Ordered deltas contributed by this operation | Each delta is one of two shapes: | Delta | Effect | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `{ "op": "set", "key", "value"? }` | Add or replace entry `key`. `value` is optional metadata (see below); omit it (or use `{}`) for a pure set-membership entry | | `{ "op": "remove", "key" }` | Drop entry `key` | - **`key`** is a **content ref** — a 31-char content chain id or a CID — consistent with how refs are named elsewhere in the content model. - **`value`** is an optional entry-metadata object `{ label?, order?, … }`. `label` is a display string; `order` is an integer ordering hint (integers only, per the number-encoding rule above). A pure set-membership index uses the degenerate `value: {}`. Unknown metadata fields are preserved (additive forward compat). ```json { "$schema": "https://schemas.dfos.com/index/v1", "deltas": [ { "op": "set", "key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "value": { "label": "First Release", "order": 1 } }, { "op": "remove", "key": "ccccccccccccccccccccccccccccccc" } ] } ``` **Fold semantics.** The resolved index is the [canonical fold](#canonical-fold) as an LWW-Map: 1. **Linearize** every operation in the log (all branches) into the canonical total order. 2. **Flatten** each `index/v1` document's `deltas` array in array order, producing one ordered delta stream. 3. **Fold** the stream: `set` writes `key → value`, `remove` deletes `key`. The **last delta touching a key wins** at its linearized position — so a `remove` supersedes an earlier `set`, and a later `set` re-adds a removed key. **Unknown delta shapes are skipped deterministically** — a delta whose `op` is neither `set` nor `remove`, whose `key` is not a string, or whose `set` `value` is present but not an object is ignored, not an error. This lets the vocabulary grow (new delta ops) without forking existing readers, and every reader skips the same deltas. The published JSON Schema mirrors this: schema validity covers the known vocabulary constraints only (a delta needs an object shape and a string `op`; a `set` or `remove` must carry a string `key`), and validators MUST NOT reject documents carrying additional delta shapes. Because the fold is branch-inclusive and last-applied-wins, an index **converges**: any ingest order of the same operation set folds to the same map, and two clients that concurrently append entries both keep their writes. If the chain's selected head is delete-terminal, the index is deleted and the fold is moot (see [Delete-terminality](#delete-terminality)). The `index/v1` fold is implemented as `foldIndexV1(ops)` in [`@metalabel/dfos-protocol/fold`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/fold). See [`schemas/index.v1.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/schemas/index.v1.json) and the worked chain in [`examples/index/`](https://github.com/metalabel/dfos/tree/main/examples/index). ### Media Object The standard representation of a reference to external media bytes. Defined once here; schemas that carry media reference this shape (consumed by the `profile/v1` `avatar` field above and the `post/v1` `cover` and `attachments` fields). ```json { "uri": "attachment://media_abc123", "cid": "bafkreibovzpnn2y6dquvxhidhx64hg7smduemox7drjs4vprjhlbmivfli", "href": "https://cdn.example.com/media/abc123.jpg" } ``` | Field | Type | Required | Description | | ------ | ------ | -------- | ------------------------------------------------------------------------------------------------ | | `uri` | string | yes | Canonical reference to the media — an `attachment://` ref or any other URI. Always present | | `cid` | string | no | Content commitment — CIDv1, raw codec (`0x55`), sha2-256, base32 lowercase, over the media bytes | | `href` | string | no | Resolution hint — a plain URL where the bytes may currently be fetched. Non-normative | - **`uri`** (REQUIRED) is the stable, canonical name of the media. It MAY be an `attachment://` ref (below) or any other URI scheme (`ipfs://`, `https://`, …). The `uri` identifies; it does not promise integrity. - **`cid`** (OPTIONAL) is a verifiable commitment to the bytes: a CIDv1 with the **raw codec (`0x55`)** and **sha2-256**, encoded base32 lowercase (a 59-char `bafkrei…` string), computed over the media bytes **exactly as stored and served**. Media bytes are opaque binary, so a consumer verifies by hashing the fetched bytes directly — unlike document blobs, no re-canonicalization is involved. `cid` is optional because a cid may not have been computed (yet, or ever) for some media; when present, a consumer SHOULD verify the bytes it ultimately receives against it. - **`href`** (OPTIONAL) is an implementation-dependent fallback: a plain URL where the bytes may currently be fetched. It is non-normative, carries no integrity promise, and MAY rot. Consumers prefer resolving `uri` and verifying with `cid`; `href` is a hint, never the reference. #### The `attachment://` ref `attachment://` is an **opaque, host-scoped media reference**: `` is an identifier meaningful to the host that committed the document, and nothing about the bytes can be derived from the ref itself. Resolution — turning the ref into fetchable bytes — is host- or gateway-dependent (for example, a [document-gateway](https://protocol.dfos.com/document-gateway) deployment may resolve it via an out-of-protocol signed-CDN API). The ref carries **no integrity**; integrity is exactly what `cid` is for. A media object is the canonical **referential** case: a document is either _terminal_ (the `{ $schema, … }` blob _is_ the content) or _referential_ (it describes how to fetch external bytes). A media object is a pointer, and resolving it (delivery of the actual media bytes) is **outside the protocol**. The document gateway serves the document that _contains_ the media object as opaque bytes; it never dereferences the pointer. There is no "media gateway": media lives at the application/delivery layer, bound to the proof plane only by the signed reference — with `cid` as the optional content hash that lets a consumer verify the bytes it ultimately receives. --- ## Authorship Who _wrote_ a thing and who _signed_ a thing are different claims. The protocol verifies **signers** — the `kid` DID in every operation's JWS header is cryptographic fact. Authorship is a content-layer statement, and the content model expresses it as a lattice of three tiers, each strictly additive over the one below. The tiers are custody-agnostic by construction: nothing changes shape when key custody moves from a host to the author. ### 1. Assertion — `credits` The document says who made it: the `credits` array in `post/v1` (and analogous authorship fields in other schemas). Costless and unverified — liner notes. The trust anchor is the operation signer: _the identity that signed this revision asserts these credits._ Nothing more is claimed, and consumers SHOULD display it as an assertion of the signer, not a verified fact. This is often exactly right: a custodial host or space signs content on behalf of the people it credits. ### 2. Proof — the claim operation A credited DID converts its credit into a verifiable, portable fact by **signing the chain itself**: 1. The chain creator (or a credential holder with grant authority) issues the credited DID a **write credential** scoped to that chain (see [CREDENTIALS.md](https://protocol.dfos.com/credentials)). 2. The credited DID appends an `update` operation that **re-commits the chain's current head `documentCID`** — a deliberate no-op revision (committing the same document again is legal; the operation's meaning is its signature, not a content change). 3. The operation carries the credential embedded in its `authorization` field, so verification is self-contained on replay: a consumer verifies the signature, the embedded credential's delegation chain rooting at the chain creator, and the timestamps — no out-of-band lookups. The claim proves, permanently and portably: _this DID, authorized by the chain's owner, signed onto this chain at this time._ Structural notes: - Genesis is undelegable — `create` operations never carry `authorization` — so a claim is always an `update`, and the earliest possible claim is the operation immediately after genesis. - Multiple credited DIDs claim independently; each claim is its own operation, each independently verifiable. - A claim binds the signer to the **chain** (and, via the re-committed `documentCID`, to a specific document state) — not to any single `label` in the credits. **Claims are forever.** Operations gossip: a signed operation served to even one peer must be assumed permanently copied. An author-signed claim is therefore an **irrevocable public link** between the author DID and the chain. Revoking the write credential afterward stops future writes but does not — and cannot — unwind a committed claim. A DID should sign a claim only when that permanent association is intended, and deployments SHOULD say so plainly at the moment of claiming. ### 3. Sovereign proof — reserved When authors hold their own keys, the same credentials and the same claim operations apply unchanged — the proof tier simply gains the strength of self-custody. No additional vocabulary is defined yet; future extensions (for example, embedded author-signed artifacts for content whose bytes are not publicly readable) are additive. --- ## Chain Interpretation A content chain is a signed append-only log. The protocol enforces ordering, authorship, and integrity. It does not prescribe what the chain _means_. How an application interprets a content chain depends on the content types committed to it. ### Living Document The chain represents a single evolving thing — a profile, a post, a policy document. Each operation is a **revision**. The resolved state is the latest `documentCID`. History is audit trail. The content _is_ the current version. This is the default interpretation for the standard schemas. Edit lineage is tracked via `baseDocumentCID` on the content operation payload — each new operation can reference the document CID it replaced. ### Stream The chain represents a sequence — a feed, a journal, a log. Each operation is a discrete emission, not a revision. There is no single "current state" — the chain _is_ the content. Previous documents aren't superseded, they're siblings in a series. A stream chain accumulates documents over time. The resolved content is the full ordered list of documents, not just the head. Applications read streams by walking the chain log and collecting each operation's `documentCID`. ### Event Fold The chain represents a sequence of events that fold into a computed state. Each operation contributes a delta or event. The resolved state is the result of replaying all events in order — similar to event sourcing. The `$schema` of the documents defines the event types and fold semantics. Unlike a living document (where the head document is the state) or a stream (where all documents are siblings), an event fold requires interpretation logic specific to the schema. The chain log is the source of truth; the projected state is derived. ### Projection Rules per Schema Each schema implies a default projection — how applications derive resolved state from the chain: | Schema | Projection | | ------------ | ------------------------------------------------------------------------------------------------------------------ | | `post/v1` | Living document — head `documentCID` is the current post. History is edit trail | | `profile/v1` | Living document — head `documentCID` is the current profile | | `index/v1` | Canonical fold — LWW-Map folded over all operations (every branch). See [Index](#index-httpsschemasdfoscomindexv1) | Stream and event fold schemas define their own projection rules in their schema documentation. The protocol does not enforce projections — these are reading conventions that applications agree on. ### Intra-Chain References (`targetOperationCID`) Content documents may reference specific operations within their own chain or other chains via `targetOperationCID`. This is a content-layer convention — the protocol does not validate or enforce it. Use cases for `targetOperationCID`: - **Comments and replies**: A reply document references the operation CID of the post being replied to - **Reactions**: A reaction document references the operation it reacts to - **Annotations**: A document annotates a specific version (operation) of another chain's content `targetOperationCID` is a content field (inside the document committed by CID), not an operation field. The protocol commits to it via `documentCID` but does not interpret it. Applications resolve the reference by looking up the target operation on the relay. --- ## Canonical Fold The [Event Fold](#event-fold) interpretation says a chain's resolved state is the result of replaying its operations "in order." The **canonical fold** makes that order precise: a single deterministic total order over **all** operations in a chain's log — every branch, not just the selected-head branch — so that any implementation holding the same set of operations computes the same folded state. ### Linearization The canonical order is the [web relay's deterministic head-selection comparator](https://protocol.dfos.com/web-relay#fork-acceptance) generalized from "pick one tip" to "order the whole log." Head selection prefers, among the chain's tips (operations with no child), the operation with the **highest `createdAt`**, breaking ties by the **highest operation CID** — both compared **byte-wise** over the multibase CID string and the ASCII ISO-8601 timestamp (a code-point comparison, never locale collation, so every implementation agrees; see [Threat Model → Fork head selection](https://protocol.dfos.com/threat-model)). The canonical linearization lays that same preference out in full, **ascending**, so the operation head selection would prefer sorts **last**: 1. **`createdAt` ascending** (byte-wise string comparison). 2. **Operation CID ascending** as tiebreak (byte-wise multibase string). Because the two orderings are exact reverses of one another, they can never disagree: the **last** operation of a full-log linearization is exactly the operation head selection picks. This holds structurally — any operation with a child has a strictly-greater-`createdAt` child (each write's `createdAt` must exceed its predecessor's), so the operation with the globally-maximal `createdAt` is always a tip. Sorting the head-preferred operation last is what makes the fold **last-applied-wins**: the newest write settles a contended key. Both the relay's head selection and the fold's linearization call the **same exported comparison function** ([`compareHeadPreference`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/fold/linearize.ts)), so the two cannot drift. ### Branch-inclusive is deliberate Folding **every branch**, rather than only the selected-head branch, is a deliberate divergence from the head-selection register semantics used by living-document schemas: - **Head selection answers "which single document is current."** A `profile/v1` or `post/v1` chain is a register — one head document is the state, and a losing fork is simply not the head. A concurrently-appended fork is _dropped_ from the resolved value. - **The canonical fold answers "what is the merged state of a CRDT chain."** An index (or any LWW-Map / event-fold schema) is not a register; its state is the accumulation of every operation. Here concurrent forks must **converge**, not compete — dropping a branch would silently lose the writes on it. So the two readings coexist on the same wire format: a register chain reads its head via head selection; a fold chain folds its whole log. This is what retro-solves the concurrent-append fork-drop hazard for accumulating schemas — two clients that append at the same chain position both keep their writes, and every reader converges on the same merged state regardless of ingest order. ### Delete-terminality The fold assumes a **live** chain. If the **selected head branch is delete-terminal** — the highest-ranked tip is a `delete` — the chain is deleted, resolution reports it as such, and **the fold is moot**: a consumer checks `isDeleted` (from chain verification) first and does not fold a deleted chain. (A `delete` on a _non-head_ branch is just another superseded operation and does not delete the chain — see [Undeletion](https://protocol.dfos.com/web-relay).) ### Library The fold is a set of **pure functions** over already-verified operations, published at [`@metalabel/dfos-protocol/fold`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/fold) (no cryptographic or network dependencies): - `linearize(ops)` — the deterministic total order above. - `foldLwwMap(deltas)` — the generic LWW-Map fold over an ordered delta stream. - `foldIndexV1(ops)` — the [`index/v1`](#index-httpsschemasdfoscomindexv1) fold built on the two. --- ## Reference Content Stream Schema The content stream is the canonical example of the stream interpretation pattern. A stream chain accumulates discrete entries — each operation appends a new document to the sequence rather than replacing the previous one. This is a **reference/example schema** — it illustrates the stream pattern and is not one of the hosted standard schemas. Its `$id` carries the `reference-content-stream/v1` URI to mark it as such; see [`schemas/reference-content-stream.v1.json`](https://github.com/metalabel/dfos/blob/main/schemas/reference-content-stream.v1.json) and the worked chain in [`examples/reference-content-stream/`](https://github.com/metalabel/dfos/tree/main/examples/reference-content-stream). ### Reference Content Stream (`https://schemas.dfos.com/reference-content-stream/v1`) A stream entry document. Each document in a content stream chain is a standalone entry in the sequence. | Field | Type | Required | Description | | -------------------- | ------- | -------- | ------------------------------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/reference-content-stream/v1"` | | `body` | string | no | Entry body content | | `attachments` | media[] | no | Attached media objects | | `targetOperationCID` | string | no | CID of an operation this entry references (reply, annotation, etc.) | | `createdByDID` | string | no | DID of the content author (distinct from the operation signer) | ```json { "$schema": "https://schemas.dfos.com/reference-content-stream/v1", "body": "This is a stream entry.", "createdByDID": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr" } ``` Content stream chains use the **stream** interpretation — the resolved content is the full ordered list of documents, not just the head. Applications read content streams by walking the chain log and collecting each operation's `documentCID`. --- ## Custom Schemas Any implementation can define custom document schemas following the same pattern — a JSON Schema with a `$schema` const field pointing to a unique URI. The protocol will commit to the document via CID regardless of what's inside. The standard schemas are conventions, not constraints. Custom schema URIs should use a namespace you control (e.g., `https://schemas.example.com/my-type/v1`) to avoid collisions with the standard library. --- # DFOS Web Relay An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, artifacts, countersignatures, and content blobs. The **proof plane** (`/proof/v1/*`) is **frozen with Protocol v1** — see the [core protocol status](https://protocol.dfos.com/spec). The relay's other surfaces — ingestion ergonomics, peering, and the content plane — are reference-implementation behavior on their own clock, not frozen. Discuss in the [DFOS](https://nce.dfos.com) space. > **Wire stability.** The non-frozen relay surfaces — well-known, countersignature reads, `/revocations/v1`, and the content plane — went through one deliberate pre-adoption breaking amendment to close the window on breaking changes. Countersignature reads now use one route, `/revocations/v1` is frozen at v1 with a bounded issuer feed, the redundant documents route is removed, and every list route uses one `limit` + `after` + `next` cursor paradigm. Breaking once now, while there are no external adopters, protects future adopters from integrating against transient shapes. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-web-relay) · [npm](https://www.npmjs.com/package/@metalabel/dfos-web-relay) · [Protocol](https://protocol.dfos.com) --- ## Philosophy Relays verify everything they receive and serve everything they've verified. They don't issue identity, grant permissions, or define content semantics. Give any two relays the same operations and they produce the same deterministic head state. No trust between relays, no coordination required. A relay is a library, not a service. `createRelay()` returns a portable Hono application — Node.js, Cloudflare Workers, Deno, Bun, Docker, a Raspberry Pi. You provide storage and peer configuration. The relay handles verification, peering, and HTTP semantics. --- ## Two Planes The relay serves two distinct planes of data with different access models: ### Proof Plane (public) Signed chain operations, artifacts, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently. All proof plane routes are unauthenticated. The operations themselves carry their own authentication (Ed25519 signatures). ### Content Plane (private) Raw content blobs — the actual documents that content chains commit to via `documentCID`. The content plane never **gossips**: blobs are never pushed on the operation log the way proof-plane operations are. A blob enters a relay one of two ways — it is uploaded to the relay that holds the chain, or it is **pulled** by a relay that is authorized to read it (content-addressed, behind a grant; see [Content Following](#content-following)). Either way a blob is served only to authorized readers, and its integrity is its `documentCID`, so a pulled blob is verified by hash regardless of where it came from. A relay's content plane **is** a document gateway — the same surface named as a standalone service on its own `0.x` clock. See [DOCUMENT-GATEWAY.md](https://protocol.dfos.com/document-gateway) for the gateway contract: a stateless, content-addressed blob store whose authorization is re-derived live from the proof plane. The served blob is the document itself — whether terminal (the bytes _are_ the content) or referential (the document points at external bytes, e.g. `ipfs://` or a signed-CDN reference). The relay never resolves a referential pointer; delivery of referenced media is out of protocol. Content plane access requires two credentials: - **Auth token**: A DID-signed JWT proving the caller controls an identity (AuthN) - **Read credential** (for non-creators): A DFOS credential with `action: "read"` attenuations, issued by the content creator (or delegated via chain), granting the caller read access (AuthZ). Can be presented per-request or ingested as a standing authorization (see [Standing Authorization](#standing-authorization) below) The content creator (the DID that signed the genesis content operation) can always read their own blobs with just an auth token. Content plane support is optional per relay. When disabled (`capabilities.content: false` in the well-known response), all content plane routes return **501 Not Implemented** — not 404 (resource doesn't exist), but 501 (capability not supported). --- ## Route Namespacing Every proof plane route is namespaced under a single prefix, **`/proof/v1`**: ``` /proof/v1/operations /proof/v1/operations/:cid /proof/v1/countersignatures/:cid /proof/v1/identities/:did /proof/v1/identities/:did/log /proof/v1/content/:contentId /proof/v1/content/:contentId/log /proof/v1/log ``` The prefix encodes the plane and its version (`{plane}/{version}`), so the proof plane's version clock is legible in the URL and the plane mounts or proxies as a unit by prefix. The proof routes are **frozen with protocol v1** — a relay MUST serve them at exactly these paths. Four route families deliberately stay at the root, on their own clocks: - **`GET /.well-known/dfos-relay`** — discovery (RFC 8615) lives at the root by convention; it announces the base and the relay's own release version. - **Content plane routes** (`/content/:contentId/blob[/:ref]`) — these belong to the **[document gateway](https://protocol.dfos.com/document-gateway)**, an optional service on a `0.x` clock independent of the protocol freeze. They remain at the root under `/content/:contentId` because they belong to the gateway's `0.x` clock, not the frozen proof plane. Note the resulting split: the proof node owns the bare chain-state paths `GET /proof/v1/content/:contentId` and `/proof/v1/content/:contentId/log`; the document gateway owns the `/content/:contentId/blob*` sub-paths. They are distinct namespaces that a reverse proxy can fan by prefix when the planes are split across origins. - **Universal resolver** (`GET /1.0/identifiers/:did`) — the DID-core / DIF Universal Resolver binding on its own `1.0` clock (the DIF driver interface version, [DID-METHOD.md](https://protocol.dfos.com/did-method) §5.2.4). It is an additive, read-only projection of the same self-certified terminal state the proof plane serves at `/proof/v1/identities/:did`, rendered as a W3C DID Document. It stays at the root because it tracks the DIF driver clock, not the frozen proof plane. - **Revocation status** (`GET /revocations/v1/credential/:credentialCID`, `GET /revocations/v1/issuer/:did`) — an indexed, read-only projection of the relay's revocation set on its own frozen `v1` clock. Revocations still _enter_ through the frozen proof plane (`POST /proof/v1/operations`); this family only exposes a read over the index the relay already maintains for its own credential enforcement. See [Revocation Status](#revocation-status-v1). - **Index** (`GET /index/v0/*`) — an optional, non-authoritative query surface over the current-state projections the relay already folds: enumerate identities, filter content chains, reverse-look-up countersignatures by witness. On its own unfrozen `0.x` clock, gated by `capabilities.index`. See [Index (v0)](#index-v0). --- ## Operation Ingestion All proof plane operations enter through a single endpoint: `POST /proof/v1/operations`. The request body is an array of JWS tokens — identity operations, content operations, artifacts, and countersignatures can be mixed freely in the same batch. ### Classification Each token is classified by its JWS `typ` header: | `typ` header | Classification | | ---------------------- | ------------------------ | | `did:dfos:identity-op` | Identity chain operation | | `did:dfos:content-op` | Content chain operation | | `did:dfos:artifact` | Artifact | | `did:dfos:countersign` | Countersignature | | `did:dfos:credential` | DFOS credential | | `did:dfos:revocation` | Credential revocation | Each operation type has its own `typ` header. Classification is unambiguous — no DID comparison needed. ### Dependency Sort Within a batch, operations are sorted by dependency priority before processing: 1. **Identity operations** — must be processed first so their keys are available 2. **Artifacts** — reference identity keys for signature verification 3. **Content operations** — reference identity keys, may have chain dependencies 4. **Countersignatures** — reference identity keys and existing operations (target must exist) Within each priority level, genesis operations (no `previousOperationCID`) are processed before extensions. This ensures that a single batch can bootstrap an entire identity-and-content lifecycle — including chained create + update operations — without multiple round trips. ### Verification Each operation is verified against the relay's stored state: - **Identity operations**: Extension operations are verified against the relay's current trusted state using O(1) extension verification — the trusted head state plus the new operation is sufficient. Genesis operations verify the single-operation chain. The relay uses `verifyIdentityChain()` / `verifyIdentityExtensionFromTrustedState()` from the protocol library - **Content operations**: Extension operations are verified against trusted state with `enforceAuthorization: true`. Non-creator signers must include a DFOS credential with `action: "write"` attenuations. The relay uses `verifyContentChain()` / `verifyContentExtensionFromTrustedState()` from the protocol library - **Revocations**: Signature is verified against the revoking DID's current identity state. The revocation payload must reference a valid credential CID. Once ingested, the revoked credential is no longer honored for authorization or content plane access - **Artifacts**: Signature is verified against the signing DID's current identity state. CID integrity is checked. Payload must conform to the declared `$schema`. CBOR-encoded payload must not exceed 16384 bytes - **Countersignatures**: Two-phase verification. Protocol-level (stateless): signature, CID integrity, payload schema. Relay-level (stateful): target CID must exist in the relay, witness DID must differ from the target's author DID, one countersign per witness per target ### Chain Resolution The relay must route each incoming operation to its chain. Resolution differs by type: - **Identity genesis**: No prior chain — the relay verifies the single-operation chain and creates a new `StoredIdentityChain` keyed by the new DID - **Identity extension**: The `kid` in the JWS header is a DID URL (`did:dfos:#`). The relay extracts the DID prefix (before `#`) and looks up the existing chain. A `kid` without `#` on a non-genesis operation is rejected — it cannot be routed - **Content genesis**: No prior chain — creates a new `StoredContentChain` keyed by the content ID derived from verification - **Content extension**: The `previousOperationCID` payload field is used to look up a `StoredOperation`, which carries the `chainId`. The relay then fetches the content chain by that `chainId`. If the previous operation doesn't exist or isn't a content operation, the extension is rejected - **Countersignatures**: The `targetCID` payload field is used to look up the target operation. The target's author DID is resolved from the stored operation to enforce the witness ≠ author rule This is relay-level machinery — the protocol library verifies chain integrity, but the relay decides how to locate the chain a given operation belongs to. ### Fork Acceptance Forks are accepted. If an incoming operation's `previousOperationCID` references any operation in the chain (not just the current head), the relay verifies the extension against the chain state at that fork point and accepts it. The chain log accumulates all branches. **Deterministic head selection**: after accepting a fork, the relay recomputes the head — highest `createdAt` among tips, lexicographic highest CID as tiebreaker. This is deterministic across relays given the same set of operations, regardless of ingestion order. As forks propagate via peering, all relays converge to the same head. **State at fork point**: to verify a fork extension, the relay computes chain state at the parent CID. The Store interface abstracts this via `getIdentityStateAtCID` / `getContentStateAtCID` — implementations choose the strategy (full replay, snapshot-backed, etc.). **Undeletion**: falls naturally from the fork model. An identity holder can fork from before a delete with a higher `createdAt`. The fork becomes the head. The delete remains visible in the log (auditable, gossiped) but is on a non-head branch. **Future timestamp guard**: Identity and content operations with a `createdAt` more than 24 hours in the future are rejected. Since head selection favors the highest timestamp, a far-future `createdAt` would permanently dominate head selection — a temporal denial-of-service. The 24-hour window accommodates clock drift while preventing abuse. ### Ingestion Statuses Three distinct outcomes from ingestion: | Status | Meaning | | ----------- | ------------------------------------------------ | | `new` | First time seen, verified, stored, state changed | | `duplicate` | Already had it, no state change (see note below) | | `rejected` | Verification failed | For chain operations, `duplicate` means the exact same CID and JWS token was already stored — a true idempotent resubmission. Submissions with the same CID but a different JWS token are rejected — since Ed25519 is deterministic, a different token for the same payload means a different signing key. Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated — one countersign per witness per target. The relay MUST NOT store multiple attestations from the same witness for the same target. Resubmission SHOULD return `duplicate` (idempotent). ### Deletion Semantics Deletion means the identity stops being an active participant. Historical operations remain verifiable — keys persist in state for signature verification — but no new acts flow from a deleted identity. Specifically: - **Identity operations after deletion (linear extension)**: Rejected. A `delete` seals the head against forward (linear) extension — appending a new operation from the deleted head is refused. This is the _linear_ path only: a current controller MAY still fork from a pre-delete operation with a higher `createdAt` to supersede the delete (see _Fork Acceptance → Undeletion_ above), in which case the resolved head reports `deactivated: false`. The `delete` remains permanently in the log on a non-head branch. - **Content operations after deletion**: Rejected. Both paths are checked: (a) the signer's identity is deleted — no operations from that DID are accepted, and (b) the content chain's creator identity is deleted — the chain is sealed regardless of who signs. - **Artifacts from deleted identities**: Rejected. A deleted identity MUST NOT publish new artifacts. - **Credentials from deleted issuers**: Rejected. Identity deletion revokes all authority, including outstanding DFOS credentials issued by the deleted identity. Credentials that were valid at time of issuance cease to be honored once the issuer is deleted. - **Countersignatures from deleted witnesses**: Rejected. A deleted identity MUST NOT publish new countersignatures. Countersignatures on operations by deleted authors are still accepted — deletion of the target's author does not prevent other identities from attesting. Self-countersignatures — where the witness DID matches the target's author DID — are rejected at the relay level. A countersignature's semantic is "a distinct witness attests." The protocol-level verifier is stateless and does not enforce this; the relay resolves the target's author and rejects self-attestation. ### Result Ordering Ingestion results are returned in the same order as the input `operations` array, regardless of internal processing order. `results[i]` corresponds to `operations[i]`. --- ## Artifacts Artifacts are standalone signed inline documents — immutable, CID-addressable proof plane primitives. Unlike chain operations which extend a sequence, an artifact is a single signed statement with no predecessor or successor. ### Payload ```json { "version": 1, "type": "artifact", "did": "did:dfos:...", "content": { "$schema": "https://schemas.dfos.com/profile/v1", "name": "My Relay", "description": "A relay for the dark forest" }, "createdAt": "2026-03-25T00:00:00.000Z" } ``` The `content` object MUST include a `$schema` string that identifies the artifact's schema. The schema acts as a discriminator — consumers use it to determine how to interpret the artifact's content. Schema names are free-form strings (no protocol-level registry). Communities may establish conventions for well-known schemas. ### Constraints - **JWS `typ` header**: `did:dfos:artifact` - **Max payload size**: 16384 bytes CBOR-encoded. This is a protocol constant — not configurable per relay - **Immutability**: Once ingested, an artifact is never updated or replaced. To "update" an artifact's content, publish a new artifact - **CID-addressable**: Each artifact is addressed by the CID of its CBOR-encoded payload ### Verification 1. JWS signature verification against the signing DID's current key state 2. CID integrity — the payload CID matches the computed CID from the raw payload bytes 3. Payload schema validation — the payload conforms to the artifact structure (`version`, `type`, `did`, `content` with `$schema`, `createdAt`) 4. Size limit — CBOR-encoded payload does not exceed 16384 bytes --- ## Countersignatures A countersignature is a standalone witness attestation — a signed statement that references a target operation by CID. Unlike the original operation primitives (which carry the data itself), a countersign is pure attestation: "I, witness W, attest to operation X." ### Payload ```json { "version": 1, "type": "countersign", "did": "did:dfos:witness...", "targetCID": "bafy...", "relation": "endorses", "createdAt": "2026-03-25T00:00:00.000Z" } ``` The optional `relation` field is an open-namespace tag (1–64 chars) naming the nature of the attestation (e.g. `coauthors`, `endorses`, `witnessed`, `holds`, `received`). Recognized values carry social meaning; unrecognized values MUST be preserved and ignored. Omitting `relation` is CID-neutral — a bare witness encodes identically to one signed before this field existed. ### Properties - **JWS `typ` header**: `did:dfos:countersign` - **Own CID**: Each countersign has its own CID, distinct from the target. This avoids the ambiguity of multiple JWS tokens sharing the same CID - **Stateless verification**: Signature + CID integrity + payload schema. No relay state required to verify the cryptographic validity of a countersign - **Composable**: The `targetCID` can reference any CID-addressable operation — content ops, artifacts, identity ops, even other countersigns - **Immutable**: Once published, a countersign is permanent ### Relay-Level Checks The relay enforces semantic rules beyond cryptographic validity: 1. **Target exists**: The `targetCID` must reference an operation already stored in the relay 2. **Witness ≠ author**: The countersign's `did` (witness) must differ from the target operation's author DID 3. **Deduplication**: One countersign per witness per target. If the same witness submits a second countersign for the same target, the relay accepts idempotently 4. **Deleted witness rejection**: Countersigns from deleted identities are rejected ### Endpoints One route serves countersignature data: - **`GET /proof/v1/countersignatures/:cid?after={cid}&limit=N`** — Primary lookup. Returns `{ cid, countersignatures: string[], next }`, sorted by each countersignature's own CID ascending and forward-only cursor-paginated. `after` is a countersignature CID, `limit` defaults to 100 and maxes at 1000, and `next` is the countersignature CID to resume from or `null` when caught up. Works for any CID-addressable target (operations, artifacts). Returns 404 only when the CID is neither a known operation nor has stored countersignatures. --- ## Relay Identity Every relay has a DID that resolves on its own proof plane. The relay DID serves as: - **Auth token audience**: Auth tokens are scoped to a specific relay via the JWT `aud` claim, preventing cross-relay token replay - **Peer identity**: When relays gossip proof plane data to each other, the relay DID identifies the peer - **Self-proof anchor**: The relay's identity chain lives in its own store, verifiable by anyone querying the relay ### Relay Profile The relay MUST publish a profile artifact signed by its own DID using the HEAD key state. The profile artifact uses the `https://schemas.dfos.com/profile/v1` schema: ```json { "$schema": "https://schemas.dfos.com/profile/v1", "name": "edge.relay.dfos.com", "description": "Cloudflare edge relay for the DFOS network", "links": [{ "uri": "https://dfos.com", "label": "operator", "description": "Metalabel" }] } ``` All fields are optional except `name`, which SHOULD be present. The optional `links` array carries up to 20 `{ uri, label?, description? }` entries (operator site, status page, contact). The profile JWS token is inlined in the well-known response — self-proving, no extra fetch needed. ### Well-Known Endpoint (`GET /.well-known/dfos-relay`) Returns relay metadata. The core discovery contract (`did`, `protocol`, `version`, `capabilities`, `profile`) is required — `profile` is the relay's proof of DID controllership (an artifact JWS signed by the relay DID's controller key). `peers` and extended `stats` fields are optional additive telemetry. ```json { "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 }, "profile": "eyJhbGciOiJFZERTQSIs...", "peers": [{ "endpoint": "https://peer.relay.example.com" }], "stats": { "pendingOps": 0, "opCount": 128, "countsByKind": { "identity": 12, "content": 30, "artifact": 5, "credential": 8, "countersign": 3, "revocation": 1 }, "oldestOpAt": "2026-03-25T00:00:00.000Z", "headCid": "bafy..." } } ``` | Field | Type | Description | | -------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `did` | string | The relay's DID, resolvable on this relay's proof plane | | `protocol` | string | Protocol identifier, always `"dfos-web-relay"` | | `version` | string | The relay's own release version (semver), independent of the frozen proof-plane clock — the proof version lives in the `/proof/v1` path prefix, not here | | `capabilities` | object | Capability flags for optional features | | `capabilities.proof` | boolean | MUST be `true`. A relay without proof plane capability is not a relay | | `capabilities.write` | boolean | Whether the relay accepts writes via `POST /proof/v1/operations` | | `capabilities.content` | boolean | Whether the relay supports the content plane (blob upload/download) | | `capabilities.log` | boolean | Whether the global operation log is available (`GET /proof/v1/log`) | | `capabilities.revocations` | boolean | Whether the [revocation status](#revocation-status-v1) index is served (`GET /revocations/v1/*`). Both reference relays always serve it | | `capabilities.index` | boolean | Whether the [index](#index-v0) query family is served (`GET /index/v0/*`). An absent flag (a relay predating the family) reads as `false` | | `profile` | string | The relay's profile artifact as a compact JWS token — self-proving payload | | `peers` | array | OPTIONAL additive telemetry. Configured peer relays surfaced for mesh discovery; reference relays emit `[]` when no peers are configured | | `peers[].endpoint` | string | OPTIONAL additive telemetry. The peer relay's base URL. A future `peers[].did` MAY appear once a relay resolves peer DIDs | | `stats` | object | Operational counters. `stats.pendingOps` is the count of operations pending processing (`-1` if unavailable) | | `stats.opCount` | number | OPTIONAL additive telemetry. Total entries in the global operation log | | `stats.countsByKind` | object | OPTIONAL additive telemetry. Global-log counts by primitive kind; reference relays emit `identity`, `content`, `artifact`, `credential`, `countersign`, `revocation` | | `stats.oldestOpAt` | string \| null | OPTIONAL additive telemetry. `createdAt` of the oldest-position global-log entry, or `null` when the log is empty | | `stats.headCid` | string \| null | OPTIONAL additive telemetry. CID of the global-log tip, or `null` when the log is empty | `capabilities.proof: false` is not a valid value. A compliant relay always serves the proof plane. When `capabilities.log: false`, `GET /proof/v1/log` returns **501 Not Implemented**. Per-chain logs are always available regardless of this setting. When `capabilities.content: false`, all content plane routes return **501 Not Implemented**. When `capabilities.revocations: false`, the `/revocations/v1/*` routes return **501 Not Implemented** — the same capability-not-supported semantics. When `capabilities.index` is `false` or absent, the `/index/v0/*` routes return **501 Not Implemented**. Credential and revocation ingestion are always enabled on the proof plane — they enter through `POST /proof/v1/operations` like all other operation types. ### Lite (pull-only) node — `capabilities.write: false` A relay MAY run as a **lite pull-only proof node**: it verifies, stores, and serves the proof plane, but accepts **no writes**. When `capabilities.write: false`, `POST /proof/v1/operations` returns **501 Not Implemented**. Because that endpoint is _both_ the client-write and the peer-gossip-ingest path (a gossiping peer POSTs operations here, and nothing in the request distinguishes a first-party submission from a peer push), refusing it disables **gossip-in along with client writes**. Such a node stays current by **pulling**: `syncFromPeers` polls its peers' `/proof/v1/log` and ingests verified operations locally. This is the smallest, safest mesh citizen — a tiny attack surface (no untrusted write endpoint) that still contributes verification and availability. All read routes behave normally. `dfos serve --no-write` runs this mode. `capabilities.write` governs the **proof-plane** write endpoint (`POST /proof/v1/operations`) only. Content-plane writes (blob upload, `PUT /content/:contentId/blob/:operationCID`) are governed independently by `capabilities.content`, which enables or disables the content plane as a whole. A node that should accept no writes of any kind runs with both `write: false` and `content: false`. --- ## Operation Log The relay maintains a global append-only operation log. Every successfully ingested operation (identity ops, content ops, artifacts, countersignatures) is appended to the log in ingestion order. ### Global Log (`GET /proof/v1/log?after={cid}&limit=N`) Returns log entries starting after the given CID cursor. ```json { "entries": [ { "cid": "bafy...", "jwsToken": "eyJhbGciOiJFZERTQSIs...", "kind": "identity-op", "chainId": "did:dfos:..." }, { "cid": "bafy...", "jwsToken": "eyJhbGciOiJFZERTQSIs...", "kind": "artifact", "chainId": "did:dfos:..." } ], "cursor": "bafy..." } ``` | Field | Type | Description | | -------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `entries[].cid` | string | Operation CID | | `entries[].jwsToken` | string | The full compact JWS token — makes the log self-contained for sync | | `entries[].kind` | string | Operation kind: `identity-op`, `content-op`, `artifact`, `countersign`, `revocation`, `credential` | | `entries[].chainId` | string | DID (`identity-op`, `artifact`, `credential`, and `revocation` — all keyed to the issuer/signer DID), contentId (`content-op`), or targetCID (`countersign`) | | `cursor` | string\|null | CID to pass as `after` for the next page. `null` means caught up | Parameters: - **`after`** (optional): CID cursor. Omit to start from the beginning of the log - **`limit`** (optional): Max entries to return. Default: 100. Max: 1000 > **`chainId` is not a per-chain partition key.** Because `credential` and `revocation` entries carry `chainId` = the issuer DID — the **same** value as that DID's `identity-op` and `artifact` entries — folding the global log per-chain on `chainId` alone silently co-mingles credentials, revocations, artifacts, and identity ops under a single DID. An indexer reconstructing a specific chain MUST filter by `kind` first (e.g. `kind === 'identity-op'` to rebuild the identity chain) rather than grouping on `chainId` alone. Pagination is forward-only. The log is ordered by ingestion time. JWS tokens are included in every entry because proof-plane JWS payloads are bounded (chain operations and artifacts have finite size), keeping the log self-contained — a syncing peer can replay the log without separate fetches. ### Per-Chain Logs Identity and content chains expose their own log views with the same cursor-based pagination: - `GET /proof/v1/identities/:did/log?after={cid}&limit=N` - `GET /proof/v1/content/:contentId/log?after={cid}&limit=N` Same cursor-based pagination parameters as the global log. Per-chain log entries include `{ cid, jwsToken }` — the chain-specific subset of the global log entry shape. Returns operations belonging to that chain in chain order. --- ## Identity and Content State State endpoints return projected state — the computed result of replaying the chain — without embedding the full operation log. ### Identity State (`GET /proof/v1/identities/:did`) ```json { "did": "did:dfos:abc123...", "headCID": "bafy...", "state": { "did": "did:dfos:abc123...", "isDeleted": false, "authKeys": [...], "assertKeys": [...], "controllerKeys": [...], "services": [...] } } ``` Resolved identity state includes the identity's `services` — the controller-signed discovery vocabulary (relay locators and stable content anchors) projected from the winning head. See [Services](https://protocol.dfos.com/spec#services) in the protocol spec. Read-through and sync replicate the underlying identity operations, so a peer that fetches an identity chain recomputes the same `services` set deterministically. ### Content State (`GET /proof/v1/content/:contentId`) ```json { "contentId": "abc123...", "genesisCID": "bafy...", "headCID": "bafy...", "state": { "contentId": "abc123...", "genesisCID": "bafy...", "headCID": "bafy...", "isDeleted": false, "currentDocumentCID": "bafy...", "length": 1, "creatorDID": "did:dfos:..." } } ``` This response is **frozen with protocol v1** and carries pure chain state — no derived authorization material. Public-read discovery (surfacing the `aud: "*"` credentials that currently authorize `read` on a chain) is a **document gateway** concern on the `0.x` clock, not a proof-plane field: keeping it off the frozen route lets that ergonomic evolve without touching the locked contract. See [DOCUMENT-GATEWAY.md → Public-read discovery](https://protocol.dfos.com/document-gateway#public-read-discovery-0x). Chain history is available via the per-chain log routes described above. --- ## Content Plane Access This section describes the content plane as the relay serves it today. The standalone gateway contract — the stateless, proof-plane-derived authorization model, where authority is re-derived live every request and any materialized public-credential index is a non-authoritative cache — is specified in [DOCUMENT-GATEWAY.md](https://protocol.dfos.com/document-gateway). Content plane requests carry a self-signed **auth token** in the `Bearer` header to prove caller identity (verified against the issuer's _current_ identity state — a rotated-out key cannot mint one; see [Key Resolution](#key-resolution)). **Auth-token lifetime ceiling**: the relay rejects an auth token whose declared lifetime (`exp − iat`) exceeds a configured maximum (default **24 hours**), returning `401`. Auth tokens are ephemeral by design (minutes); this ceiling stops a buggy or malicious signer from minting an effectively-permanent bearer token. It applies only to auth tokens — DFOS credentials (read/write/standing) are verified on a separate path and may carry hours-to-months lifetimes. Setting the maximum to `≤ 0` disables the ceiling. ### Blob Upload (`PUT /content/:contentId/blob/:operationCID`) The upload path mirrors the download path — the operation CID identifies which operation's document is being uploaded. Requirements: - Valid auth token (Bearer header) - The operation CID must reference an operation in this content chain that has a `documentCID` - The authenticated DID must be either the chain creator OR the signer of the referenced operation (enabling delegated uploads) - The uploaded bytes must hash to the operation's `documentCID` (dag-cbor + sha-256 verification) Blobs are stored by `(creatorDID, documentCID)` — always keyed to the chain creator regardless of who uploads. If multiple content chains by the same creator reference the same document, the blob is shared (deduplication). ### Blob Download (`GET /content/:contentId/blob[/:ref]`) Requirements: - If a standing authorization exists for the content (a public credential with `aud: "*"` covering the resource): access is granted without any auth token or per-request credential - Otherwise, a valid auth token (Bearer header) is required, plus: - If the caller is the chain creator: no further credentials needed - If the caller is not the creator: must present a DFOS credential with `action: "read"` in the `X-Credential` header, with a delegation chain rooting at the creator The optional `:ref` parameter selects which operation's document to return: - `head` (default): the current document at chain head - An operation CID: the document committed by that specific operation ### Enumerating a Chain's Documents The former relay-side list route was removed because it was only a relay-decoded convenience over state the client can re-derive verifiably. Fetch `GET /content/:contentId/blob` for the document at head, `GET /content/:contentId/blob/:opCID` for the document any specific operation committed (immutable), and `GET /proof/v1/content/:contentId/log` to enumerate the chain's operations, each carrying its `documentCID`. This composition is strictly more verifiable: every blob is checked against its committed `documentCID`, and the op log is the frozen proof-plane enumeration. ### Standing Authorization Instead of presenting a read credential on every request, a DFOS credential with `aud: "*"` (public) can be ingested by the relay as a **standing authorization** — once ingested, matching content plane requests are authorized without an `X-Credential` header. Credential ingestion uses `POST /proof/v1/operations`: DFOS credentials are submitted as JWS tokens alongside other proof plane operations and stored in the op log like any other operation (addressable by CID, carried in the global log as `kind: "credential"`). **Authority is re-derived live, not read from a stored flag.** On every content plane access the relay re-verifies the standing credential against current proof-plane state — signature, issuer-key resolution, temporal validity, **revocation**, and a delegation chain rooted at the content creator — through the **same verifier the per-request (`X-Credential`) path uses**. The two paths differ only in where the credential came from and an audience check that public (`aud: "*"`) credentials skip; revocation is checked **symmetrically** on both, at every link of the delegation chain. See [DOCUMENT-GATEWAY.md → The unified verifier](https://protocol.dfos.com/document-gateway#the-unified-verifier). A relay MAY keep a materialized index of ingested public credentials (resource → candidate credentials) to make standing-auth lookup O(1). **That index is a performance optimization, not authority** — every candidate it yields is re-verified live before it can authorize, so a stale or revoked entry cannot grant access. It is a re-verified, non-authoritative cache over the op log, fully re-derivable from it. A standing authorization stops granting access the moment any live check fails: - The credential expires (temporal validity) - The credential — or any parent in its delegation chain — is revoked - The issuer's identity (or any delegating identity) is deleted These are evaluated live per request, so the effect is immediate; no cache invalidation is required for correctness. ### Revocation Ingestion Revocation artifacts (`typ: did:dfos:revocation`) are ingested via `POST /proof/v1/operations` alongside other proof plane operations. When a revocation is accepted: 1. The revoked credential's CID is recorded against its issuer 2. Standing authorization backed by that credential stops granting — the live per-request revocation check denies it on the next read (a relay that keeps a candidate index MAY also evict the entry eagerly, but the live check is what guarantees immediacy) 3. Future content chain operations embedding the revoked credential as `authorization` are rejected 4. Future content plane requests presenting the revoked credential are rejected Revocation is permanent and immediate. See [CREDENTIALS.md](https://protocol.dfos.com/credentials) for the revocation payload format. A relay MAY additionally expose a read over the revocation index it keeps for this enforcement — see [Revocation Status](#revocation-status-v1). ### Content Following The operation log federates the **proof plane**: identity chains, content chains, public-read credentials, and revocations are all pushed and gossiped between peers. The **content plane** — the document _bytes_ — is deliberately not on that wire. A relay MAY nonetheless make those bytes available locally by **following**: pulling the documents of the content chains it is authorized to read, content-addressed and gated by the grant. This turns a relay from a proof mirror into a true edge cache that serves public content **independently of the origin** that authored it. Following is a per-relay, optional behavior on the content plane's own `0.x` clock; it adds **no wire surface** and changes nothing for a relay that does not opt in. The reference Go relay exposes it as `CONTENT_FOLLOW=eager` (default `none`). The shape, normatively: - **Pull, not push.** A follower fetches blobs from its peers over the existing public blob route (`GET /content/:contentId/blob[/:ref]`). Blobs are never gossiped; there is no new endpoint. - **The materialize gate is the serve gate.** A follower materializes a chain's bytes only while a standing public-read grant authorizes anonymous read of it — the same predicate (`hasPublicStandingAuth`) the serve path checks. So a chain that is private, revoked, or deleted is never followed. - **Verified by hash, trustless in source.** Every pulled blob is checked against the `documentCID` the chain committed (its content address) before it is stored. A follower may therefore pull from any peer; a byte that does not hash to its committed CID is rejected. - **Eventually consistent.** Authorization arrives instantly (the grant rides the log); the bytes arrive asynchronously. Between the two, a follower that is authorized but has not yet materialized a blob returns `404 blob not found` on `GET /content/:contentId/blob[/:ref]` — the **honest "authorized-but-not-yet-materialized" state**, not an error. A conforming follower converges to serving the bytes; it need not do so instantaneously. See [DOCUMENT-GATEWAY.md → Follower materialization](https://protocol.dfos.com/document-gateway#follower-materialization-0x). - **Convergent, ordering-immune.** Following is driven by a sweep over the chains the follower already holds in local state, so it cannot be raced by op-ingest ordering (a credential op sequences before the content op it grants). The sweep is the correctness backbone; low-latency triggers, if any, are an optimization over it. - **Revoke is correctness-free; GC is reclamation.** When a grant is revoked, the per-request serve gate immediately makes any cached bytes unreachable — correctness needs nothing more. Reclaiming the now-orphaned bytes (deleting them) is a separate, convergent garbage-collection pass keyed on the same gate, and is purely a storage concern. --- ## Revocation Status (v1) A relay that enforces revocation already maintains an `(issuerDID, credentialCID)` revocation set (see [Revocation Ingestion](#revocation-ingestion)). The **revocation status** route family exposes an indexed, read-only projection of that set, so a client can ask "has this credential been revoked?" or "what has this issuer revoked?" with one GET instead of replaying the operation log. Like the universal resolver, this family is a **frozen `v1` contract at the relay root** on its own v1 clock — it is NOT part of, and is not mounted under, the frozen `/proof/v1` proof plane. Nothing about ingestion changes: revocations remain ordinary proof-plane operations (`typ: did:dfos:revocation`), submitted via `POST /proof/v1/operations` and gossiped like everything else. A relay advertises support via `capabilities.revocations` in the well-known; when unsupported, the routes return **501 Not Implemented** (capability not supported, consistent with `content`/`log`). Both reference relays serve the index unconditionally. ### Credential Status (`GET /revocations/v1/credential/:credentialCID`) ```json { "credentialCID": "bafyrei…", "revoked": true, "revocation": "eyJhbGciOiJFZERTQSIs…" } ``` This credential-status route is frozen AS-IS. - **`200` revoked** — includes `revocation`, the full revocation JWS token. - **`200` not revoked** — `{ "credentialCID": "…", "revoked": false }`, no `revocation` key. - **`400`** — the param is not a well-formed credential CID (CIDv1 dag-cbor + sha256, `bafyrei` + 52 base32 chars). **The JWS is the proof; the boolean is a convenience.** A zero-trust caller does not take the relay's word for it: it re-verifies the returned revocation token itself — signature against the issuer's identity chain, CID integrity, kid-DID == payload `did`, and the issuer-only rule (only the credential's issuer can revoke it). The relay serves the index; the operation carries its own authority. **Absence is NOT proof of non-revocation.** `revoked: false` is the honest known-nothing answer: it attests only that _this relay_ has not ingested a revocation for that CID. A relay can be behind, partitioned, or adversarially withholding. A caller that needs stronger assurance queries a quorum of independent relays (and gossip makes withholding progressively harder) — the same trust posture as every other read in the protocol. If more than one issuer has revoked the same credential CID (possible, since the set is scoped per issuer and the issuer-only rule is enforced at credential verification rather than ingest), the relay MUST answer deterministically: the revocation with the lexicographically smallest `issuerDID`. Callers re-verifying the JWS against the credential's actual issuer are unaffected either way. ### Issuer Revocations (`GET /revocations/v1/issuer/:did?after={cid}&limit=N`) ```json { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "revocations": [{ "credentialCID": "bafyrei…", "revocation": "eyJhbGciOiJFZERTQSIs…" }], "next": null } ``` - **`200`** — every revocation this relay has ingested for the issuer, sorted by revocation `createdAt` ascending (tiebreak `credentialCID`), forward-only cursor-paginated: `after` (a `credentialCID` cursor, omit to start from the first) and `limit` (default 100, max 1000); the response carries `next` — the `credentialCID` to pass as `after` for the next page, `null` when caught up. An issuer with none returns an empty array — same honest-absence semantics as above. - **`400`** — the param is not a canonical 31-char `did:dfos` identifier. --- ## Index (v0) A relay that verifies and folds chains already holds current-state projections — terminal states, standing public-read grants, per-chain logs. The **index** route family exposes read-only, cursor-paginated _queries_ over those projections: enumerate identities, filter content chains, reverse-look-up countersignatures by witness, enumerate held public credentials. It exists so a light client can browse and discover without replaying the global operation log — the same role the revocation status family plays for credential state, generalized. The family lives at **`/index/v0/*`** on its own **`0.x` clock** — NOT part of the frozen `/proof/v1` proof plane, and (unlike `/revocations/v1`) not yet frozen itself. A relay advertises support via `capabilities.index` in the well-known; when unsupported (or when the flag is absent — relays predating this family), the routes return **501 Not Implemented**. Nothing about ingestion changes. ### Hints, Not Authority Index responses are **discovery hints, never authority**. Every row carries the identifiers needed to re-derive its claims from the frozen proof plane — a client that cares fetches `GET /proof/v1/identities/:did` / `GET /proof/v1/content/:contentId` (and the per-chain logs) and folds the chain itself. The trust posture is asymmetric and worth stating precisely: - **The index cannot lie by assertion.** Every claim in a row is verifiable against the proof plane. A fabricated row fails the client's fold. - **The index CAN lie by omission.** A relay can be behind, partitioned, or adversarially withholding rows — and a light client cannot detect a recall gap. Absence of a row is NOT proof of absence. A caller that needs stronger recall queries a quorum of independent relays, or replays `GET /proof/v1/log` and folds locally — the full-log replay remains the audit posture that keeps every index honest. - **Index output MUST NOT be used as an authorization input.** The `publicRead` field is a discovery hint; content plane access is always re-derived live by the gateway's unified verifier (see [DOCUMENT-GATEWAY.md](https://protocol.dfos.com/document-gateway)). ### The Structural Seam The index is bounded by one invariant: > **Every index field and filter MUST be computable from protocol-defined fields and verification outcomes alone. Document payloads are opaque**, except the [well-known projections](#well-known-projections) below. The invariant has a semantic reading that bounds the query vocabulary itself: > **The index knows who acted, when, and what things call themselves. Nothing else.** Every index field and filter is an instance of one of three axes — **actor** (creator, signer, witness, issuer), **clock** (`genesisAt` / `headAt`), or **name** (the [display-name registry](#well-known-projections)). A query that cannot be phrased under these axes is not an index feature: it is a client-composed filter over them, a client-side fold over verified bytes, or `/search` (deferred, on its own clock). Concretely, the index may serve: - **Structural facts** the relay already computes to verify: chain kind, genesis/head CIDs, op counts, creator DIDs, operation signer sets, deletion state, countersign witnesses and targets, credential issuer/subject/scope, revocation status, standing public-read grants, identity service entries. - **Declared labels, matched as opaque strings**: an artifact or document `$schema`, a `ContentAnchor` `label`. The relay matches these byte-for-byte the way an HTTP server serves a `Content-Type` — it never interprets what they mean. What the index MUST NOT do: interpret document payloads, join across application semantics, rank, or reify application concepts. There is no "posts" endpoint and never will be — an application-level notion like _post_ is a **client-composed filter expression** over structural parts (`docSchema=… & publicRead=true & creator=…`), not an index concept. This keeps the relay's query vocabulary closed while the application vocabulary composed on top stays open. ### Well-Known Projections The **display-name registry** is the sole exception to payload opacity, enumerated here rather than left to implementations. Its rule: **one display-name field per enumerated `$schema` — the index may know what a thing calls itself, never what it says.** A registry row names an exact `$schema`, the single payload field that schema uses as its display name, and the index surface where the projection appears. Nothing else is ever extracted — no descriptions, bodies, summaries, credits, or payload timestamps; what a document _says_ is client-fold or `/search` territory, never an index axis. | # | Projection | Definition | | --- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `profile/v1 → name` | For an identity whose terminal `services` contain a `ContentAnchor` whose `label`, lowercased, equals `"profile"` and whose `anchor` is a content-chain identifier: if the relay holds the bytes of that chain's current head document, and the decoded document declares `$schema: "https://schemas.dfos.com/profile/v1"`, and its `name` is a non-empty string — the index surfaces that `name` on the identity row's `profile` projection. | | 2 | `post/v1 → title` | For a content chain whose current head document bytes the relay holds: if the decoded document declares `$schema: "https://schemas.dfos.com/post/v1"` and its `title` is a non-empty string — the index surfaces that `title` on the content row. | Every registry row carries the same structural **circuit breakers** — every escape hatch resolves to the honest unknown, never a guess: - A document under any `$schema` NOT in this table is never field-extracted, no matter how tempting its shape. - A listed schema whose document is malformed, whose extracted field is missing, empty, or not a string — the projected field is `null`. No partial parses, no coercion. - Bytes the relay does not hold — `docSchema: null` and the projected field `null` (see coverage, below). - **A chain that is not publicly readable** — the projected `publicRead` is `false` — never surfaces its extracted display-name field: the projected value is `null`. Confidentiality of the underlying documents is enforced at the application layer by whoever serves them (see [PROTOCOL.md](https://protocol.dfos.com/spec)); the index MUST NOT project a non-public document's extracted fields onto its anonymous surface. Only the extracted display-name value is withheld — the structural `anchor` / `publicRead` / `docSchema` fields (a declared label matched as an opaque string, never document content) still project. Extracted values are **attribution-tier claims**: the value is whatever the signed head document says (row 1 is additionally reached through a controller-signed anchor). Clients verify by fetching the chain and re-hashing the served bytes to the committed `documentCID`. Additions to this table require an amendment to this spec — one display-name field per schema, and a relay MUST NOT ship extraction rules that are not listed here. ### Determinism and Coverage - **Deterministic enumeration.** By default every index list is ordered lexicographically ascending by its cursor key (identity rows by `did`, content rows by `contentId`, countersign rows by countersignature `cid`); identity and content enumerations additionally accept a time ordering via `order=` (below). Two relays holding the same operations serve identical page ordering — in either mode — and identical structural fields — the same convergence property the proof plane guarantees for head state, extended to enumeration. Held-bytes-dependent fields (`docSchema`, projected values) additionally require the same held blobs to agree, per coverage below. - **Keyset pagination is the normative shared envelope.** Every index route paginates identically — `after` (keyset cursor), `limit` (default 100, max 1000), `next` (the resume cursor, or `null` when caught up) — over the route's enumeration order. New index routes inherit this envelope; none may invent another. In the default lexical mode `after` is a **strictly-greater** cursor: a page returns the rows whose (post-filter) cursor key is lexicographically **greater than** `after`, ascending, capped at `limit`; `next` is the last returned row's key, or `null` when the page was not full (caught up). The cursor need not be a currently-present key — a value that falls between keys, or names a row that was mutated out of the active filter between pages, resumes at the next greater key rather than truncating to an empty page. This makes enumeration stable under concurrent row changes: keys are immutable natural identifiers, so a row's filtered membership or projected values may change between pages without dropping or duplicating other rows. - **Time-ordered enumeration (`order=`).** `/identities` and `/content` accept `order=genesisAt.desc` (newest chains first) or `order=headAt.desc` (most recently active first). The sort key is the composite `(timestamp descending, cursor key ascending)` — the timestamps are the same author-claimed, head-selection-trusted `createdAt` values already surfaced as `genesisAt` / `headAt`, so ordered pages are exactly as deterministic and convergent across relays as the lexical default: an ordering of _claimed_ times, never receipt times, inheriting precisely the trust posture of the fields it sorts by. In ordered mode `after` and `next` are **opaque cursor tokens** — a client resumes by passing `next` back verbatim and MUST NOT parse or construct one; the token encoding is implementation-internal. Envelope semantics carry over (`limit` bounds, strictly-past-the-composite-key resumption), with one honest weakening of the lexical mode's stability guarantee: `genesisAt.desc` sorts by an immutable key and is fully stable under concurrent row changes, but `headAt` is a **mutable** sort key. It is monotonically non-decreasing (per-branch timestamp ordering plus max-over-tips head selection — an accepted operation can only raise it), so a chain updated mid-enumeration moves strictly toward the top of `headAt.desc` — into pages already served. An in-flight `headAt.desc` enumeration therefore never duplicates a row but MAY miss one that was updated while paginating; the row is not gone, it has moved to the front of a fresher enumeration. This is the correct contract for a recency feed — clients refresh from the top; completeness remains the job of the lexical enumeration (immutable keys, fully stable) or the log replay. Absent `order`, enumeration is the lexical default above — existing clients are untouched. Only the two enumerated values exist; an unrecognized value is a `400`. - **Coverage is bounded by held bytes.** `docSchema` and projected fields are computable only for chains whose current head document bytes the relay holds (uploaded, or pulled via [content following](#content-following)). A chain whose bytes are absent reports `docSchema: null` — honest unknown, not a claim of schemalessness. A `docSchema` filter therefore matches only chains with held, decodable head bytes; callers MUST treat the result as a lower bound. - **Timestamps are author-claimed.** `genesisAt` / `headAt` surface the `createdAt` fields signed inside the operations — the same values head selection uses — not relay receipt times. - **Maintenance.** The index is fully re-derivable from the operation log plus held blobs. Reference implementations maintain it incrementally at ingestion and expose a rebuild path for pre-existing corpora; either way the serving contract is identical. - **`publicRead` is a last-touch snapshot, and MAY lag time-based transitions.** A materialized `publicRead` reflects whether a standing public-read grant authorized anonymous read **at the moment the row was last recomputed** (its content's most recent op, or a rebuild). One input to that predicate — a grant credential's `exp` — is wall-clock-relative and crosses **without emitting any operation**, so incremental maintenance has no event to react to: a row can continue to advertise `publicRead: true` after the grant that made it public has expired, until the next op dirties that content or a rebuild reruns the projection. This is deliberately tolerated because the index is a discovery hint, never an authorization input (see [Hints, Not Authority](#hints-not-authority)) — the content plane re-derives `hasPublicStandingAuth` live on every read, where `exp` is always evaluated against the current clock. A relay MAY additionally re-sweep public rows near expiry to tighten the hint, but is not required to. ### Identities (`GET /index/v0/identities?hasPublicProfile=&nameContains=&order=&after={did}&limit=N`) Enumerates identity chains, `did` ascending by default; `order=genesisAt.desc` / `order=headAt.desc` select time-ordered enumeration (recently arrived / recently active) per [Determinism and Coverage](#determinism-and-coverage). ```json { "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, branch-inclusive (log length, not head-branch length) | | `genesisAt` / `headAt` | string | Author-claimed `createdAt` of the genesis and current head operations | | `profile` | object \| null | The [well-known projection](#well-known-projections), or `null` when the identity declares no profile-labeled content-chain anchor | | `profile.anchor` | string | The anchored contentId — the client's verification pointer | | `profile.publicRead` | boolean | Whether a standing public-read grant currently authorizes anonymous read of the anchored chain, per this relay's fold — a hint, never an access decision | | `profile.docSchema` | string \| null | `$schema` declared by the held head document; `null` when bytes are not held or not decodable | | `profile.name` | string \| null | Extracted per the projection table; `null` on any circuit breaker — including when `profile.publicRead` is `false` (a non-public profile never projects its name) | Parameters: `hasPublicProfile` (optional boolean filter on the predicate "`profile` is non-null AND `profile.publicRead` is true" — `true` keeps only rows where it holds, `false` keeps only rows where it does not, absent applies no filter), `nameContains` (optional case-insensitive substring filter over projected `profile.name`; non-authoritative/amber; applied before keyset pagination), `order` (optional time ordering — `genesisAt.desc` or `headAt.desc`; `400` on any other value), `after` (a `did` keyset cursor in the lexical default — returns rows with `did` strictly greater — or an opaque token in ordered mode), `limit` (default 100, max 1000). Multiple profile-labeled anchors resolve deterministically to the one with the lexicographically smallest service `id`. ### Content Chains (`GET /index/v0/content?creator={did}&signer={did}&docSchema=&documentCID=&publicRead=&order=&after={contentId}&limit=N`) Enumerates content chains, `contentId` ascending by default; `order=genesisAt.desc` / `order=headAt.desc` select time-ordered enumeration per [Determinism and Coverage](#determinism-and-coverage). All filters are ANDed exact matches. ```json { "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](#well-known-projections) projection for content rows (row 2, `post/v1 → title`): `null` for any chain whose held head document is not an enumerated schema, on any circuit breaker (including a non-public chain — `publicRead: false` — whose title is never projected), or when bytes are not held — an honest unknown, never a guess. Parameters: `creator` (exact DID — the chain's genesis signer; `400` when malformed), `signer` (exact DID — keeps chains in which the DID signed at least one **accepted** operation, branch-inclusive: "has signed in this chain," not "signs the current head lineage"; operations on branches later deleted or abandoned still count — the log records that the signature happened; `400` when malformed), `docSchema` (exact opaque string match against held head bytes — a lower bound, per coverage above), `documentCID` (exact match against the projected `currentDocumentCID` — the reverse lookup "who published this document"), `publicRead` (boolean), `order` (optional time ordering — `genesisAt.desc` or `headAt.desc`; `400` on any other value), `after` (a `contentId` keyset cursor in the lexical default — returns rows with `contentId` strictly greater — or an opaque token in ordered mode), `limit` (default 100, max 1000). This is the reverse lookup "what content does DID X own" plus the composition surface for application-level queries — e.g. a client's notion of _public posts by X_ is `creator=X&docSchema=&publicRead=true`, and its notion of _recent public posts_ is `order=headAt.desc&docSchema=&publicRead=true`, composed client-side. `signer` is an **actor-axis verification outcome**, deliberately raw: the creator matches their own chains (the creator signs genesis), and "contributed to but did not create" is client-composed as `signer=X` minus `creator=X`. It is proof-tier — the DID's key actually signed accepted operations, revealing nothing not already derivable from the public per-chain log — and it is never an authorship or credit claim: `credits` and similar payload fields are assertion-tier and are not index concepts. ### Countersignatures by Witness (`GET /index/v0/countersignatures?witness={did}&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. ```json { "witness": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "countersignatures": [ { "cid": "bafyrei…", "targetCID": "bafyrei…", "relation": "endorses", "jwsToken": "eyJhbGciOiJFZERTQSIs…" } ], "next": null } ``` `witness` is required (`400` when missing or malformed); `after` is a countersignature-`cid` keyset cursor (returns rows with `cid` strictly greater), `limit` defaults to 100 and maxes at 1000. `relation` is the countersign's open-namespace tag, `null` when omitted by the signer. Each entry carries the full JWS — self-proving, same posture as the issuer revocations feed: the caller re-verifies the token rather than trusting the row. ### Credentials (`GET /index/v0/credentials?issuer={did}&resource=&after={cid}&limit=N`) Enumerates the relay's held public credentials, `cid` ascending. ```json { "credentials": [ { "cid": "bafyrei…", "issuerDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4", "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), `after` (a credential-`cid` keyset cursor — returns rows with `cid` strictly greater), `limit` (default 100, max 1000). Filters are ANDed. Only public credentials (`aud: "*"`) are ever held by the relay. Targeted bearer credentials never enter relay storage, so they are neither enumerable nor leakable here. This route is amber and relay-asserted: `resource=chain:Y` returns a superset of candidates (exact `chain:Y` plus any `chain:*`). Each entry carries the full JWS — self-proving, same posture as the issuer revocations feed. The caller folds each token against the proof plane (delegation roots at Y's creator, revocation, expiry) before treating it as authorization; the relay makes no authorization claim in this index row. ### Deferred from v0 - **`/search`** — fuzzy or tokenized name search. Search semantics (normalization, ranking) are deliberately kept off this clock; if they ship, they ship as their own explicitly-unstable family, never frozen into the index contract. `nameContains` (above) is the index's one search-flavored filter and its ceiling: it is a relocation candidate into `/search` when that family exists, and the index does not grow a second. - **Fork/tips visibility** — remains deferred at the proof-plane level (see [What's Deferred](#whats-deferred)). --- ## Key Resolution The relay uses two key resolution strategies: - **Historical resolver** (for chain re-verification): searches all keys that have ever appeared in an identity chain's log, including rotated-out keys. This is necessary because re-verifying a full content chain from genesis must resolve keys from operations signed before a key rotation. - **Current-state resolver** (for live authentication): only resolves keys in the identity's current state. After a key rotation, the old key immediately stops working for auth tokens. This prevents a compromised rotated-out key from being used to authenticate new requests. **Which primitive uses which resolver:** - **Current-state resolver** — auth tokens. A rotated-out key cannot mint an auth token, preventing stale-key auth. - **Historical resolver** — identity and content chain re-verification, artifacts, revocations, and countersignatures. These are historical facts whose signing key may since have rotated out, so they must resolve against every key that ever appeared in the chain; re-verifying them under current state would break sync of honest operations after any rotation. Their invalidation mechanism is revocation or deletion, not key rotation. - **Credentials are the exception to the auth grouping.** Although a credential proves authorization, it uses the **historical** resolver and survives key rotation — a credential signed before a rotation remains valid afterward. Revocation (not key rotation) is the invalidation mechanism for credentials. See [CREDENTIALS.md](https://protocol.dfos.com/credentials). --- ## Storage Interface The relay delegates persistence to a `RelayStore` interface. Implementations handle how data is stored — the relay handles what to store and when. ```typescript interface RelayStore { getOperation(cid: string): Promise; putOperation(op: StoredOperation): Promise; getIdentityChain(did: string): Promise; putIdentityChain(chain: StoredIdentityChain): Promise; getContentChain(contentId: string): Promise; putContentChain(chain: StoredContentChain): Promise; getBlob(key: BlobKey): Promise; putBlob(key: BlobKey, data: Uint8Array): Promise; getCountersignatures(operationCID: string): Promise; addCountersignature(operationCID: string, jwsToken: string): Promise; appendToLog(entry: LogEntry): Promise; readLog(params: { after?: string; limit: number; }): Promise<{ entries: LogEntry[]; cursor: string | null }>; // chain state at arbitrary CID (fork verification) getIdentityStateAtCID( did: string, cid: string, ): Promise<{ state: VerifiedIdentity; lastCreatedAt: string } | null>; getContentStateAtCID( contentId: string, cid: string, ): Promise<{ state: VerifiedContentChain; lastCreatedAt: string } | null>; // peer sync cursors getPeerCursor(peerUrl: string): Promise; setPeerCursor(peerUrl: string, cursor: string): Promise; } ``` The `getIdentityStateAtCID` / `getContentStateAtCID` methods compute materialized chain state at an arbitrary operation CID. Used by fork verification — the ingestion pipeline needs state at the fork point to verify signer authority and timestamp ordering. Implementations decide how: `MemoryStore` replays from genesis, `SQLiteStore` can use snapshot tables. The package includes `MemoryRelayStore` for development and testing. Production deployments would implement the interface over Postgres, SQLite, D1, or any durable store. --- ## Quick Start ```typescript import { createHttpPeerClient, createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay'; // JIT mode — generates relay identity + profile artifact at startup const relay = await createRelay({ store: new MemoryRelayStore(), }); // With peering const relay = await createRelay({ store: new MemoryRelayStore(), peers: [{ url: 'https://peer.relay.example.com' }], peerClient: createHttpPeerClient(), }); // Mount on any Hono-compatible runtime export default relay.app; // Schedule sync polling setInterval(() => relay.syncFromPeers(), 30_000); ``` The returned `CreatedRelay` includes `app` (Hono), `did` (string), and `syncFromPeers` (async function). The Hono app exposes: | Method | Path | Plane | Auth | | ------ | ------------------------------------------- | ----------- | ----------------------------------------- | | `GET` | `/.well-known/dfos-relay` | meta | none | | `POST` | `/proof/v1/operations` | proof | none | | `GET` | `/proof/v1/operations/:cid` | proof | none | | `GET` | `/proof/v1/countersignatures/:cid` | proof | none | | `GET` | `/proof/v1/identities/:did` | proof | none | | `GET` | `/proof/v1/identities/:did/log` | proof | none | | `GET` | `/proof/v1/content/:contentId` | proof | none | | `GET` | `/proof/v1/content/:contentId/log` | proof | none | | `GET` | `/proof/v1/log` | proof | none | | `GET` | `/1.0/identifiers/:did` | meta | none | | `GET` | `/revocations/v1/credential/:credentialCID` | revocations | none | | `GET` | `/revocations/v1/issuer/:did` | revocations | none | | `GET` | `/index/v0/identities` | index | none | | `GET` | `/index/v0/content` | index | none | | `GET` | `/index/v0/countersignatures` | index | none | | `GET` | `/index/v0/credentials` | index | none | | `PUT` | `/content/:contentId/blob/:opCID` | content | auth token | | `GET` | `/content/:contentId/blob[/:ref]` | content | standing auth, or auth token + credential | --- ## Peering Relay-to-relay peering enables data replication across the network. The relay expresses peering intent through a `PeerClient` interface (injected like `Store`) and per-peer configuration flags. ### Three Behaviors | Behavior | Trigger | Mechanism | | ---------------- | ---------------- | ----------------------------------------------------- | | **Gossip-out** | New op ingested | Push to peers with `gossip: true` | | **Read-through** | Local 404 on GET | Fetch from peers with `readThrough: true` | | **Sync-in** | Scheduled poll | Pull from peers with `sync: true` via `/proof/v1/log` | Gossip fires on `new` status only — `duplicate` results are not re-gossiped, preventing gossip storms. Read-through applies to **identity chains** and **content chains** only — operations and countersignatures are not read-through targets. When triggered, the relay fetches the full chain log from a peer and ingests locally (full verification, no trust). Sync-in uses cursor-based pagination against the peer's global log. ### Peer Configuration ```typescript interface PeerConfig { url: string; gossip?: boolean; // default: true readThrough?: boolean; // default: true sync?: boolean; // default: true } ``` No relay roles or types. Topology is emergent from configuration. A relay with `gossip: true, readThrough: false, sync: false` is a write-only edge node. A relay with `gossip: false, readThrough: true, sync: false` is a read-only cache. ### PeerClient Interface The `PeerClient` is injected like `Store` — semantic per-resource methods, not raw HTTP. The default implementation uses HTTP. Tests inject mocks that route directly to another relay's API in-process. ```typescript interface PeerClient { getIdentityLog( peerUrl: string, did: string, params?: { after?: string; limit?: number }, ): Promise<{ entries: PeerLogEntry[]; cursor: string | null } | null>; getContentLog( peerUrl: string, contentId: string, params?: { after?: string; limit?: number }, ): Promise<{ entries: PeerLogEntry[]; cursor: string | null } | null>; getOperationLog( peerUrl: string, params?: { after?: string; limit?: number }, ): Promise<{ entries: PeerLogEntry[]; cursor: string | null } | null>; submitOperations(peerUrl: string, operations: string[]): Promise; } ``` Each method corresponds to a peering behavior: `getIdentityLog` / `getContentLog` support read-through, `getOperationLog` supports sync-in, and `submitOperations` supports gossip-out. A `PeerLogEntry` is `{ cid: string; jwsToken: string }`. --- ## Convergence The protocol guarantees: given the same set of operations, any relay computes the same deterministic head state. Peering (gossip, read-through, sync) replicates operations across relays. But operations may arrive before their causal dependencies — a content extension before its identity chain, a fork before the branch it forks from. A relay MUST eventually process any structurally valid operation whose causal dependencies have been processed. This is the convergence contract. ### Causal Dependencies An operation's causal dependencies are the minimum state required for verification: | Operation type | Dependencies | | ------------------ | ------------------------------------------------------- | | Identity genesis | None | | Identity extension | Previous identity operation (by `previousOperationCID`) | | Content genesis | Creator's identity chain (for key resolution) | | Content extension | Previous content operation + creator's identity chain | | Artifact | Signer's identity chain | | Countersignature | Signer's identity chain + target operation | If all causal dependencies are present, the operation MUST be verifiable. If any dependency is missing, the operation cannot be verified yet — but it is not invalid. The relay MUST retain it and re-attempt verification when the missing dependency arrives. ### Store-Then-Verify A relay MUST NOT discard a structurally well-formed operation because its dependencies are temporarily unavailable. The implementation strategy is store-then-verify: 1. **Store**: on receipt (via `POST /proof/v1/operations`, gossip, sync, or read-through), store the raw JWS token in a content-addressed buffer keyed by operation CID. This is idempotent — duplicate CIDs are ignored. 2. **Verify**: attempt full verification against current state. Three outcomes: - **Sequenced** — verification succeeded, operation committed to chain state and global log - **Dependency failure** — a causal dependency is missing, operation remains in the buffer - **Permanent rejection** — structurally invalid, bad signature, deleted identity, etc. — will never succeed regardless of what state arrives 3. **Sequence loop**: after each ingestion batch, re-attempt all buffered operations in dependency order until no further progress is made (fixed-point). This ensures cross-batch dependencies resolve immediately — when batch B provides the identity that batch A's content operation was waiting for, the sequencer resolves it within B's response cycle. ### Dependency Failures A rejection is a dependency failure if and only if it is caused by missing state that may arrive later via peering. The set is small and stable: - Previous operation not yet in store (`previousOperationCID` unknown) - Identity chain not yet available (key resolution fails) - Content chain not yet created (genesis not arrived) - Fork state cannot be computed (ancestor in branch path not yet available) All other rejections are permanent. Permanent rejections MUST NOT be retried. ### Serialization All chain-state mutations (ingestion + sequencing) MUST be serialized. Concurrent ingestion of operations for the same chain produces a read-modify-write race: two goroutines read the chain log, both append their operation, and the second write clobbers the first. Raw operation storage (`putRawOp`) does not require serialization — it is idempotent and append-only. ### Convergence Bound Given a fully connected peer mesh where every relay syncs from every other relay: - After one sync cycle, every relay has every operation that any peer accepted (stored as raw) - After one sequencer pass, every operation whose full dependency chain exists locally is sequenced - Deterministic head selection ensures all relays agree on the canonical head In practice, the dependency depth for fork operations is 1 (each op depends on its immediate predecessor). Convergence is typically achieved in a single sync + sequence cycle. ### Storage Interface (Convergence) The `RelayStore` interface extends with methods for raw operation buffering: ```typescript // raw ops — content-addressed store for all received operations putRawOp(cid: string, jwsToken: string): Promise; getUnsequencedOps(limit: number): Promise; markOpsSequenced(cids: string[]): Promise; markOpRejected(cid: string, reason: string): Promise; countUnsequenced(): Promise; resetSequencer(): Promise; ``` --- ## What's Deferred - **Peer discovery**: Static configuration only — no dynamic discovery - **SSE/realtime push**: Polling `GET /proof/v1/log` for now, SSE in the future - **Fork visibility API**: Dedicated endpoint to list tips/branches - **Search**: fuzzy/tokenized name queries — deliberately excluded from the [index](#index-v0) contract; would ship as its own explicitly-unstable family - **Branch termination op**: Protocol-level operation to explicitly kill fork branches - **Rate limiting / anti-spam**: Operational concern, not protocol concern - **Blob size limits**: No enforcement yet — production deployments should add limits at the middleware layer - **Artifact `$schema` registry**: Schema names are free-form strings for now — no formal registry or validation beyond structural checks --- # DFOS Document Gateway A stateless, content-addressed blob store whose authorization is a re-verifiable endorsement derived live from the proof plane. The gateway serves the **content plane** — the raw documents that content chains commit to via `documentCID` — and holds no _authoritative_ authorization state of its own (it re-derives every decision live). This spec is under active review. Discuss it in the [DFOS](https://nce.dfos.com) space. [Protocol](https://protocol.dfos.com) · [Web Relay](https://protocol.dfos.com/web-relay) · [Credentials](https://protocol.dfos.com/credentials) > **Gateway version 0.1.** This document specifies the document gateway as an > optional service on its own `0.x` clock, independent of the Protocol v1 freeze. > It matches the behavior the reference relay (`@metalabel/dfos-web-relay`, > `dfos-web-relay-go`) ships today: both the public-grant and delegated read paths > re-derive authorization live from the proof plane on every request. How the > gateway hands a public-read caller the grants it re-verified — a 0.x ergonomic > that does **not** touch the frozen proof plane — is sketched under > [Public-read discovery](#public-read-discovery-0x). A relay MAY keep a > materialized public-credential index as a **non-authoritative performance > cache** — it is never authority (see [Statelessness](#statelessness)). Published > here for review and to inform implementors. --- ## What it is The document gateway is the read/write face of the **content plane**. Content chains live in the proof plane as signed commitments — each operation carries a `documentCID`, the hash of a document the chain commits to. The gateway stores and serves those documents (the **preimages** of the committed CIDs). It is deliberately **dumber** than a proof node. It has no chains, no signatures of its own, no gossip, no consensus, no operation log. It does exactly two things: - **Stores bytes** addressed by the `documentCID` a content chain already committed to. - **Serves bytes** to readers it can verify are authorized — where "authorized" is a judgment re-derived live from the proof plane on every request, 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 gateway holds only the bytes. This split is the heart of the design: | Plane | Holds | Guarantees | | --------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Proof plane** | Signed commitments (ops, CIDs, credentials, revocations) | Integrity + authenticity + authorization, cryptographically, against any adversary | | **Content plane (gateway)** | Document preimages, verified by content-addressing | Integrity (bytes → CID) cryptographically; access control _honest-host_ | A relay's content plane **is** a document gateway. The two terms name the same surface from two angles: "content plane" is the relay-internal plane (paired with the proof plane); "document gateway" is the standalone service contract on a `0.x` clock. A reverse proxy can split them across origins — the proof node owns `GET /proof/v1/content/:contentId` and `/log`; the gateway owns the `/content/:contentId/blob*` sub-paths. ### Terminal and referential documents A document the gateway serves is either **terminal** — the `{ $schema, … }` blob _is_ the content — or **referential** — a `{ $schema, … }` document that describes _how to fetch_ external bytes: an `ipfs://` CID, or an opaque `attachment://` 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 gateway serves the document blob either way and never resolves a referential pointer.** Dereferencing — actually fetching the external bytes — is _delivery_, and delivery lives outside the protocol and outside the gateway. ### What it is not The document gateway is **not** a media server. It does not resolve or dereference referential documents, does not deliver external media bytes, does not mint or sign CDN URLs, and has no range requests, partial content, or streaming surface. There is no "media gateway" and no protocol-level media: large or binary media is a **referential document** (a content-schema convention) whose bytes are fetched out-of-protocol. The gateway is an authorization-gated, content-agnostic store of opaque document blobs — nothing more. --- ## Statelessness The gateway holds **no _authoritative_ authorization state and no proof-plane replica**. "Stateless" here means _stateless over the proof plane_: every authorization decision is re-derived live from the proof plane, and nothing the gateway stores is ever trusted as authority — so nothing it stores can be served stale. A relay MAY keep a materialized index of ingested public grants (a resource → candidate-credential map) as a **performance optimization** — it makes "which grants might cover this chain?" an O(1) lookup instead of an op-log scan. That index is **not authority**: every candidate it yields is re-verified live (signature, issuer-key resolution, expiry, revocation, delegation rooted at the creator) before it can authorize anything. A stale or revoked entry in the index cannot grant access, because the live re-verify rejects it. The index is a cache over the proof-plane op log, fully re-derivable from it — never a source of truth. (This is the same "re-verified, non-authoritative cache" the split-deployment TTL cache is; see [Deployment locality](#deployment-locality).) This is not the same as offline. Verifying any signature requires the issuer's keys, which live in a **mutable, revocable** identity chain. "Is this grant valid right now?" is a question about _current_ proof-plane state — a key may have rotated, a credential may have been revoked, an identity may have been deleted. There is no correct offline answer. A live read is the honest price of verifying against live, revocable truth. What the gateway gains by holding no authority: it can never serve a stale authorization. It is coupled to _truth_ and holds no authoritative replica to drift. See [Coupling](#coupling-why-tight-is-correct), which separates the three couplings that "stateful/stateless" usually smears together. --- ## Discovery A reader finds a gateway through the identity's `services` vocabulary (see [PROTOCOL.md → Services](https://protocol.dfos.com/spec#services), [DID-METHOD.md → Services](https://protocol.dfos.com/did-method#45-services)). The `services` namespace is open: recognized types (`DfosRelay`, `ContentAnchor`) are structurally validated; any other type is preserved verbatim and ignored. The gateway adds two **open-namespace** types — additive, requiring no protocol or relay change: | Service `type` | Fields | Meaning | | --------------------- | ---------------- | ----------------------------------------------------------------------------------------------- | | `DfosDocumentGateway` | `endpoint` (URL) | Base URL of a document gateway serving this identity's content | | `DfosProfile` | `anchor` | The identity's profile document — a 31-char contentId (living chain) or a `baf…` CID (artifact) | A resolver replays the identity chain to current state, reads the `DfosDocumentGateway` endpoint, and requests the document. `DfosProfile` dispatches by shape exactly as `ContentAnchor` does: a contentId resolves to a content chain (a living, updatable profile), a CIDv1 resolves to an artifact (an immutable snapshot). Both are just content the gateway serves under the same authorization rules — discovery and authorization stay orthogonal. Because these are open-namespace service types, a relay that does not recognize them preserves and ignores them; a gateway-aware client reads them. No coordinated upgrade is required. --- ## The unified verifier Authorization is **one routine**. Both the public path and the delegated path reduce to the same verification — 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 ``` There is no second code path, no "is it public?" branch that trusts a stored flag, no stored table treated as authority before the verifier runs. A grant is authorized **iff** a credential covering the resource survives this routine against live proof-plane state. ### Two paths, one verifier - **Public path.** The reader presents no credential. The gateway derives the relevant public credentials (`aud: "*"`) covering the chain from the proof plane (see [Public-grant derivation](#public-grant-derivation)) and runs each through the unified verifier. A surviving public grant authorizes the read. The candidates may come from a materialized public-credential index, but that index is a **non-authoritative cache** — authority is the live re-verify, never the stored lookup. - **Delegated path.** The reader presents a DFOS credential in the `X-Credential` header. The gateway runs the same verifier over it. Unchanged in shape — it simply gains the same revocation check the public path runs. Both paths check revocation at **every link** of the delegation chain. There is no asymmetry: a revoked public grant and a revoked presented credential are denied identically. See [Revocation](#revocation). ### Public-grant derivation Public credentials (`aud: "*"`) are ordinary proof-plane operations — they enter through `POST /proof/v1/operations` and live in the operation log like any other signed op (kind `credential`). The gateway therefore needs no separate grant table _as authority_: it derives the public grants covering a chain from the proof plane it already reads for the chain head. (A relay MAY keep a materialized index of these credentials as a non-authoritative candidate cache; see [Statelessness](#statelessness).) The gateway derives these grants from the proof-plane operation log it already reads (see [Public-read discovery](#public-read-discovery-0x) for how it then hands them to a public-read caller). Crucially, the gateway works from the **credentials themselves**, not a pre-chewed `publiclyReadable: true`. The proof plane provides _data_; the gateway makes the _decision_ by re-verifying through the unified verifier. The proof plane never makes an authorization judgment the gateway blindly trusts — a malformed or revoked credential is rejected by the verifier. This keeps the verifier honest and composable. A public grant may name `chain:` (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 (see [CREDENTIALS.md → `aud: "*"` + write](https://protocol.dfos.com/credentials#security-aud-quotquot--write--a-world-writable-bearer-grant)). --- ## Routes The gateway's route **surface** is unchanged from what the relay serves today; only the authorization _logic_ behind it changes. The 0.1 gateway adds **zero new gateway routes**. | Method | Path | Purpose | | ------ | ---------------------------------------- | ----------------------------------------------------------- | | `PUT` | `/content/:contentId/blob/:operationCID` | Upload the document committed by a given operation | | `GET` | `/content/:contentId/blob[/:ref]` | Download a document (`:ref` = `head` default, or an op CID) | These remain at the root (not under `/proof/v1`) because they belong to the gateway's `0.x` clock, not the frozen proof plane. Content-plane support is optional per relay: when `capabilities.content: false`, both return **501 Not Implemented**. ### Download authorization `GET /content/:contentId/blob[/:ref]` requires, in order: 1. A valid **auth token** (`Bearer`) proving caller identity — except where a public grant authorizes the resource, in which case no auth token is required. 2. Then exactly one of: - the caller is the **chain creator** (creator always reads their own blobs); - a **public grant** survives the unified verifier for `(resource, read)`; - the caller presents a **DFOS credential** (`X-Credential`) that survives the unified verifier for `(resource, read)`. ### Upload authorization `PUT /content/:contentId/blob/:operationCID` requires a valid auth token and: - the referenced operation exists in this content chain and carries a `documentCID`; - the authenticated DID is the chain creator **or** the signer of the referenced operation (delegated uploads); - the uploaded bytes hash to that operation's `documentCID` (dag-cbor + sha-256). > **Byte encoding of blobs.** Stored and served blob bytes are the bytes **as received** (the raw upload body — canonically a JSON document), NOT a re-canonicalized form. The `documentCID` check is therefore **decode-JSON → dag-cbor canonical encode → sha-256 → compare CID** (matching the reference relay's upload check), not a direct hash of the served bytes. A naive `sha256(servedBytes)` will NOT equal `documentCID`; a verifier must re-canonicalize through the same decode → dag-cbor path. Blobs are stored by `(creatorDID, documentCID)` — keyed to the chain creator regardless of who uploads, so identical documents across a creator's chains deduplicate. --- ## Ingestion: none The proof plane has an ingestion pipeline (`POST /proof/v1/operations` → verify signatures, store, gossip). The gateway has **no signed-object ingestion**. It never verifies a chain, never checks a countersignature, never participates in consensus. It has two doors, and both reduce to content-addressing plus a proof-plane read: - **Upload** checks (a) the bytes hash to a `documentCID` already committed by the named operation — pure content-addressing, self-verifying — and (b) the proof plane confirms the op exists, commits that `documentCID`, and the uploader is creator or signer. - **Download** runs the unified verifier and serves the bytes. **Blobs are unsigned, and that is correct.** A blob's integrity _is_ its CID, and the CID is already signed in the proof plane. Re-signing the bytes at the gateway would be redundant: a blob that does not hash to its committed `documentCID` is rejected by content-addressing alone, against any adversary including a malicious gateway. The proof plane provides **legitimacy** (a real, committed document of a real chain) and **authorization**; content-addressing provides **integrity**. The gateway needs both — a blob alone is just bytes; all of its meaning lives in the proof plane. --- ## Revocation Revocation is checked **symmetrically** and **per-link**. There is no path on which a revoked credential is honored: - On the **public path**, each derived public grant is checked for revocation before it can authorize. - On the **delegated path**, the presented credential and **every parent** in its `prf` delegation chain are checked. Revocations are themselves proof-plane operations (kind `revocation`), so revocation status is resolved live from the proof plane the gateway already reads — consistent with statelessness, no separate revocation cache that could serve a stale "still valid." A depth-`N` delegation chain costs up to `N` revocation lookups; public grants are usually depth-1. This matches the protocol's revocation rule: a credential is denied if it, or any link in its delegation chain, is revoked — checking only parents is insufficient (see [CREDENTIALS.md → Revocation](https://protocol.dfos.com/credentials#revocation)). Revocation controls **future** access; it does not rewrite the append-only proof-plane history. Revocation is immediate against live proof-plane state. In a [split deployment](#deployment-locality) with a TTL cache, "immediate" is bounded by the cache TTL — a stated, finite staleness window, never authoritative state. --- ## Trust model A `200` from the gateway is an **endorsement**: "I, a cooperating host, verified against the live proof plane that a grant authorizes this read." It is not a bare assertion the caller must take on faith — every input to the decision is **public and re-derivable**, so a zero-trust caller can re-run the unified verifier itself: - The chain head and `documentCID` come from the proof plane. - The public grants (or the presented credential) are signed, CID-addressable proof-plane objects. - Revocation status is a proof-plane query. So the gateway's policy is **"a presented credential is valid implicitly — the gateway is endorsing it — but the caller MAY re-verify."** Presence of a surviving credential _is_ public-read authorization; re-verifying it is cheap and fully client-side. 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, and it is the one place the model is host-cooperative rather than cryptographic. --- ## Security model | 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; wrong bytes fail content-addressing. Integrity is cryptographic **even against a malicious gateway**. | | **Can withhold / leak** | The gateway holds plaintext. Content-plane access control is **host-cooperative** — it protects an _honest_ host from mis-serving. It is not a cryptographic vault. | The cryptographic guarantees — integrity, authenticity, authorization — live in the **proof plane**. The content plane is honest-host access control: the "undisclosed-by-default among cooperating relays" layer of the dark forest, not end-to-end encryption. Anything that must stay confidential against a _hostile_ host is withheld or encrypted **above** the protocol. This is the explicit, accepted trust boundary; see [THREAT-MODEL.md](https://protocol.dfos.com/threat-model) for the consolidated proof-plane / content-plane split. --- ## Coupling: why tight is correct It is tempting to call the gateway "tightly coupled" to the proof plane and treat that as a smell. Three distinct couplings hide under that word; separating them shows the design kept the good one and killed the bad one: 1. **State coupling (replication)** — does it hold an _authoritative_ copy of proof-plane state? A standing-authorization table _trusted as authority_ does (→ invalidation, drift). The stateless gateway holds none: any materialized index it keeps is a re-verified, non-authoritative cache, not authority. ← _the coupling we killed._ 2. **Read coupling (runtime dependency)** — does it talk to the proof plane per request? **Yes, unavoidably.** You cannot verify a signature without the issuer's mutable, revocable key. This is intrinsic and correct, not a defect. 3. **Trust coupling** — does it believe the node's judgment or re-derive? **Minimal** — the node hands self-verifying data (signed ops, CIDs, credentials); the gateway decides; a lying node is caught by the verifier. You **cannot** decouple a verifier from the source of the keys it verifies against. Decoupling would mean either caching authority (→ state coupling, the bug we removed) or ignoring mutability (→ honoring rotated-out keys and revoked grants, a security hole). Tight _read_-coupling to live truth, with **zero** _authoritative_ state replicated, is exactly the shape that is always correct. ### Deployment locality Performance is a _deployment_ question, not an architectural one. Logical coupling does not imply network latency: - **Co-located** (gateway beside a proof node or read-replica) — reads are local, microseconds. This is the default. - **Split** (gateway and proof plane on different origins) — network reads, optionally fronted by a **TTL cache**. The cache is a _performance optimization_ with bounded, stated staleness and is always re-verifiable; it is **never** authoritative state. Logical coupling, physical locality. --- ## Versioning and governance The document gateway sits below two version clocks and references neither's internals: - **Protocol v1 (frozen)** — chain mechanics, DAG-CBOR encoding, identifier derivation, validity bounds. The gateway depends **only** on these frozen primitives (CIDs, credentials, signatures). The protocol never references the gateway. - **Content-schema conventions** — the gateway is content-agnostic; it serves bytes addressed by `documentCID` and does not interpret document schemas. The gateway's own `0.x` clock advances independently. New gateway capability arrives **additively** atop frozen v1 — a new service type, a richer blob-response envelope on a gateway-owned route — never as a protocol break, and never by adding fields to a frozen proof-plane route. The governance invariant: **capability flows up from frozen primitives; the protocol never reaches down to a gateway.** --- ## Public-read discovery (0.x) > **Status: design, not yet built.** This sketches a `0.x` gateway ergonomic. It is **not** part of the reference relay today and is deliberately under-specified at the wire level — the framing below fixes the _shape_ of the answer; an implementor picks the exact bytes. A zero-trust public-read caller wants two things at once: the document bytes, _and_ the `aud: "*"` credentials that authorized the read, so it can re-verify the grant itself instead of trusting "the gateway let me in." The challenge is handing back both without: 1. **Touching the frozen proof plane.** The grants do **not** ride `GET /proof/v1/content/:contentId` — that route is frozen with protocol v1 and carries pure chain state, nothing derived. Public-read discovery is a gateway concern on the gateway's own `0.x` clock, so it lives entirely on a **gateway-owned route** (the public blob path), never as a new field on a locked proof route. 2. **Overloading HTTP headers.** A delegation chain of credential JWS tokens can run to many kilobytes — well past what belongs in a response header. So the grants come back in a **response envelope** (a body the caller parses), not a header. The ergonomic, then: on the public blob path the gateway already serves, when a public grant authorized the read, the gateway hands back — alongside (or wrapping) the blob — the **authorizing credentials themselves**: the `aud: "*"` JWS tokens (`chain:` or `chain:*`) it re-verified to allow the read. The caller re-runs the same unified verifier over them and arrives at the same yes independently. The gateway's filtering is a convenience; the caller's re-verify is the backstop, exactly as on the server side. Constraints that fix the shape (not the bytes): - **Response envelope, not header.** The delegated grant set is bounded at **≤ 256 KiB** of credential material; anything that large signals a pathological delegation graph and the gateway MAY refuse to inline it (the caller can still fall back to fetching credentials by CID off the proof plane). - **Re-verified, not raw.** Only grants that survive the live verifier (signature, issuer-key resolution, expiry, revocation, delegation rooted at the creator) are inlined — same filter the read decision used. - **Revocation currency is the caller's option.** The inlined grants were revocation-checked at serve time; a caller that wants stronger currency MAY re-check revocation live against the proof plane it can read directly. The envelope is a head-start, never a substitute for the caller's own proof-plane reads. - **Wire shape left to the implementor.** Whether the envelope is a JSON wrapper around a base64 blob, a multipart response, or a sidecar `Link`-discoverable resource is a `0.x` implementation choice. This section fixes only that the grants come back **in a body, on a gateway route, re-verified** — not the field names. This adds **zero proof-plane surface**: it is purely a gateway-side enrichment of a response the gateway already owns. The grants are public credentials; surfacing them discloses nothing private. --- ## Follower materialization (0.x) A gateway holds the bytes for the chains it authored or was uploaded to. A gateway MAY also acquire bytes by **following**: pulling the documents of chains it is authorized to read from peer gateways, so it can serve that content independently of the origin. This is the content-plane counterpart to proof-plane sync — same "authored at origin, verified at the edge" geometry, opposite transport (pull, not gossip), one shared gate. It is an optional `0.x` behavior that adds **no new route** and is invisible to a gateway that does not opt in. The normative shape: - **Pull over the existing public blob route.** A follower fetches `GET /content/:contentId/blob/:operationCID` (or `/blob` for the head) from a source gateway. No new endpoint, no new wire field. - **Content-addressed, source-agnostic.** Each pulled blob is verified against the `documentCID` the chain committed — the same content-addressing check `PUT` enforces. Integrity is the CID, which is already signed in the proof plane, so a follower may pull from any source and reject anything that does not hash to its committed CID. This is what makes following trustless. - **Gated by the same predicate that serves.** A follower materializes a chain's bytes only while a surviving public-read grant authorizes anonymous read of it — the gateway's own download-authorization decision (see [Download authorization](#download-authorization)). A private, revoked, or deleted chain is never followed. - **`404 blob not found` is CONFORMANT.** Authorization (the grant) arrives on the proof plane instantly; the bytes arrive asynchronously. A follower that is authorized for a chain but has not yet materialized a blob returns `404 blob not found` on `GET /content/:contentId/blob[/:ref]` — the honest **authorized-but-not-yet-materialized** state, NOT a conformance failure. A conforming follower converges to serving the bytes (it is _eventually_ consistent); a conformance test asserts eventual materialization (poll until served), never instantaneous. - **Revoke is correctness-free; GC is reclamation.** The per-request download-authorization decision is re-derived live, so revoking a grant makes any cached bytes immediately unreachable — the gate, not deletion, is what enforces revocation. Deleting the now-orphaned bytes is a separate convergent garbage-collection pass keyed on the same gate; it reclaims storage and is never load-bearing for correctness. Whether a gateway follows, which sources it pulls from, and how aggressively are deployment choices, not protocol. The reference Go relay exposes following as `CONTENT_FOLLOW=eager` (default `none`); see [WEB-RELAY.md → Content Following](https://protocol.dfos.com/web-relay#content-following). --- ## What's deferred - **Index chains** — a content chain enumerating an identity's documents (a catalog, an author's works) is pure discovery, orthogonal to authorization. This is served by the [`index/v1`](https://protocol.dfos.com/content-model#index-httpsschemasdfoscomindexv1) content schema, which needs no special gateway support: an index is just another content chain, gated by the same rules, and a consumer folds it via the canonical fold. No gateway `0.x` primitive. - **Credentials-by-resource query** — reverse discovery ("what can DID X read"). It serves no part of the read path; YAGNI for 0.1. - **Blob-response credential envelope** — inlining the re-verified `aud: "*"` grants alongside the public blob so a zero-trust caller re-verifies the grant itself (see [Public-read discovery](#public-read-discovery-0x)). Designed, not yet built; the exact wire shape is deferred to the implementor. - **Media** — explicitly **out of gateway and protocol scope**. Media is a [referential document](#terminal-and-referential-documents): a content-schema convention describing how to fetch external bytes (`ipfs://`, an opaque `attachment://` resolved by an out-of-protocol signed-CDN API), delivered out-of-protocol. That convention is now specced as the [Media object](https://protocol.dfos.com/content-model#media-object) in the content model — a content-schema convention, never a gateway `0.x` version or a protocol primitive. Nothing changes for the gateway: it serves the document that carries the media object and never dereferences the pointer. --- # DFOS Credentials UCAN-style authorization credentials for the DFOS protocol. Replaces VC-JWTs with a simpler, more powerful model: CID-addressable JWS tokens with embedded delegation chains, monotonic attenuation enforcement, and first-class public credential semantics. > **Status — Protocol v1: feature-complete and frozen.** The credential model — the JWS envelope, linear delegation, monotonic attenuation, revocation, and the validity bounds — is **frozen** as part of the v1 surface; build on it as specified. Per the [core protocol status](https://protocol.dfos.com/spec), v1 is frozen but not yet final: clarifications are corrected in place and new capability lands additively, while a genuine break to a frozen field becomes v1.1 or v2 — never a silent edit. The reference packages stay on their own `0.x` semver line. Discuss in the [DFOS](https://nce.dfos.com) space. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/credentials) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## Overview DFOS credentials are signed authorization tokens. They answer the question: "does this DID have permission to do this thing?" A credential is a JWS-encoded payload where the issuer grants the audience specific permissions over specific resources, with an expiry. Two mechanisms from UCAN make credentials composable: 1. **Delegation chains** — a credential can embed its parent credential in a `prf` (proof) field, forming a verifiable linear chain of authority from a root issuer down to the leaf holder. 2. **Monotonic attenuation** — each hop in a delegation chain can only narrow scope, never widen it. Fewer resources, fewer actions, shorter expiry. Credentials are content-addressed via CID (same `dagCborCanonicalEncode` + SHA-256 scheme as all protocol objects). The CID appears in the JWS header, making each credential a stable, revocable artifact. --- ## Schema ### DFOSCredentialPayload The credential payload is validated against the schema below. Unknown top-level fields are preserved-and-ignored (forward-compat, per the protocol's MUST-ignore-unknown rule), not rejected; the CID still commits to the exact bytes. ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "aud": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` | Field | Type | Description | | --------- | ------------------ | ------------------------------------------------------------------------- | | `version` | `1` | Schema version (literal `1`) | | `type` | `"DFOSCredential"` | Literal discriminator | | `iss` | string | Issuer DID — the authority granting permission | | `aud` | string | Audience DID, or `"*"` for public credentials | | `att` | Attenuation[] | Resource + action pairs (min 1, max 32) | | `prf` | string[] | Parent credential JWS token — at most 1 (linear delegation), default `[]` | | `exp` | number | Expiration — unix seconds (positive integer) | | `iat` | number | Issued-at — unix seconds (positive integer) | ### Attenuation Entry Each attenuation entry is an object with two non-empty string fields: ```json { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ``` | Field | Type | Description | | ---------- | ------ | -------------------------------------- | | `resource` | string | Resource identifier (`type:id` format) | | `action` | string | Comma-separated action list | ### Size and Cardinality Limits A credential is bounded by **one aggregate size cap** plus a small set of **cardinality caps** — not a per-field string-length table. The validity rules that `iss`, `aud`, `resource`, and `action` participate in (issuer-key resolution, `aud → iss` delegation linkage, attenuation subset coverage) are enforced directly and identically in both implementations, so no per-field length cap is needed — and a per-field cap would only risk forking validity across implementations. **Aggregate credential size:** | Bound | Value | Applies to | | -------------------- | -------------------------- | ------------------------- | | credential JWS token | **262144 bytes** (256 KiB) | the serialized credential | Verifiers MUST reject a credential whose serialized JWS token exceeds 262144 bytes, checked before any decode. The leaf token embeds the entire nested delegation chain (each parent is carried verbatim in `prf`), so this single cap bounds the whole chain. Credentials carry their own ceiling — larger than the 64 KiB operation cap ([PROTOCOL.md](https://protocol.dfos.com/spec)) — precisely because a maximum-depth delegation chain legitimately exceeds 64 KiB; the credential is exempt from the operation cap and bounded by this one instead. **Cardinality caps:** | Field | Max | Rationale | | ----- | -------- | ---------------------------------------------------------------------------------- | | `att` | 32 items | Generous for multi-resource grants; min 1 (a zero-`att` credential grants nothing) | | `prf` | 1 item | Single-parent (linear) delegation | ### CID Derivation The credential payload is content-addressed using the same scheme as all protocol objects: ``` dagCborCanonicalEncode(payload) -> SHA-256 -> CIDv1 (dag-cbor + SHA-256) ``` The resulting CID is embedded in the JWS protected header as `cid`. This makes the credential a stable, addressable artifact — used for revocation references and audit trails. **CID integrity check:** During verification, the payload is re-encoded and the derived CID is compared against the `cid` header value. Mismatch is a verification failure. ### JWS Encoding The credential is signed as a JWS Compact Serialization token (`header.payload.signature`). The payload is JSON-encoded (not dag-cbor) in the JWS body, following standard JWS conventions. dag-cbor is used only for CID derivation. --- ## JWS Header ```json { "alg": "EdDSA", "typ": "did:dfos:credential", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` | Field | Value | Description | | ----- | ----------------------- | ---------------------------------------------------- | | `alg` | `"EdDSA"` | Ed25519 signature algorithm | | `typ` | `"did:dfos:credential"` | Protocol-specific type discriminator | | `kid` | DID URL | `did:dfos:#` — identifies the signing key | | `cid` | CID string | Content address of the payload (for revocation) | **kid format:** The `kid` MUST be a DID URL containing `#`. The DID portion (before `#`) MUST match the `iss` field in the payload. The key fragment (after `#`) identifies which key on the issuer's identity was used to sign. **Key resolution:** The signing key is resolved from the issuer's identity chain using **historical key resolution** — all keys that have ever appeared in the identity chain's create and update operations are considered valid signing keys, not just the current state. This means credentials survive key rotation: a credential signed before a key rotation remains valid even after the signing key is no longer in the issuer's current state. Revocation (not key rotation) is the invalidation mechanism for credentials. Any key role (auth, assert, controller) is accepted — the protocol does not restrict which key role may sign credentials. This is distinct from auth tokens, which use **current-state-only** key resolution (rotated-out keys are immediately rejected). The difference reflects the different lifetimes: auth tokens are ephemeral (minutes), while credentials are long-lived (hours to months) and their validity is managed through explicit revocation. --- ## Delegation Chains Delegation chains enable transitive authorization. A root authority issues a credential to an intermediary, who can then issue a narrower credential to a downstream party, embedding the parent credential as proof. ### `prf` Semantics The `prf` field contains an array of full JWS compact tokens — the complete parent credentials, not references or CIDs. This makes each credential self-contained: a verifier can walk the entire chain without external lookups (beyond identity resolution). - `prf: []` — root credential. The issuer is the original authority. - `prf: [""]` — delegated credential. The single parent credential proves the issuer was authorized. **Delegation is linear (single-parent).** A credential's `prf` MUST contain at most one entry. Verifiers MUST reject any credential whose `prf` has more than one element. (A prior union-of-authority model — attenuating the child against the _union_ of multiple parents while rooting the walk through only the first — allowed a self-issued secondary parent to contribute authority that was never rooted at the expected creator, an authority-escalation. Linear delegation removes the class entirely.) ### Verification Walk Chain verification proceeds from the leaf credential upward: 1. **Verify the leaf credential** — signature, schema, expiry, CID integrity. 2. **Reject multi-parent** — if `prf` has more than one entry, reject. 3. **Verify the parent in `prf`** — same checks, recursively. 4. **Audience linkage** — the child's `iss` MUST match the parent's `aud` (or the parent's `aud` MUST be `"*"`). This prevents a DID from using a credential not addressed to it. 5. **Expiry narrowing** — the child's `exp` MUST NOT exceed the parent's `exp`. 6. **Attenuation check** — the child's `att` MUST be a valid attenuation of the parent's `att` (see [Attenuation Rules](#attenuation-rules)). 7. **Root check** — when a credential has `prf: []`, its `iss` MUST equal the expected root DID (e.g., the content chain creator). **Depth limit:** A delegation chain MUST contain at most **16 credentials**, counting the leaf and the root inclusive (i.e. at most 15 delegation hops). A verifier walks from the leaf (counted as the first credential) toward the root; the **17th credential is rejected** ("delegation chain too deep"). This boundary is exact and normative — verifiers MUST agree on it (a verifier that accepts a 17-credential chain forks authorization validity). Conformance: a 16-credential chain verifies; a 17-credential chain is rejected. **Revocation at every level:** Revocation is checked at every level of the delegation chain — the leaf credential AND each parent — not just the leaf (MUST — see Revocation / Relay Enforcement). --- ## Attenuation Rules Every delegation hop enforces monotonic attenuation. The child credential's scope MUST be a subset of the parent's scope. Two dimensions are attenuated independently: resources and actions. ### Scope Narrowing Every entry in the child's `att` array must be covered by at least one entry in the parent's `att` array. Valid narrowing examples: - Parent grants `chain:X` and `chain:Y` -- child requests only `chain:X` (subset of resources) - Parent grants `read,write` -- child requests only `read` (subset of actions) - Parent grants `chain:*` -- child requests `chain:X` (wildcard to specific) Invalid widening: - Parent grants `chain:X` -- child requests `chain:X` and `chain:Y` (new resource) - Parent grants `read` -- child requests `read,write` (new action) - Parent grants `chain:X` -- child requests `chain:*` (specific to wildcard) ### Action Coverage An action is a **comma-separated list** of action tokens. To compare two action strings, each is **canonicalized to a set** of tokens by the following rules, applied identically by every verifier: 1. **Split on comma** (`,`). 2. **Trim** ASCII leading/trailing whitespace from each element. 3. **Drop empty elements.** An element that is empty after trimming contributes nothing to the set. Leading, trailing, and doubled commas are therefore insignificant — `read`, `read,`, `,read`, and `read,,read` all canonicalize to `{read}`. 4. **Collect into a set.** Order and duplication are insignificant; `write,read` and `read,write` both canonicalize to `{read, write}`. 5. **Compare tokens by exact, case-sensitive byte equality.** `read` and `Read` are distinct actions. There is **no action wildcard** — a `*` token is an ordinary, literal action token, not a match-all. The child's canonical action set MUST be a **subset** of the parent's canonical action set for the matched resource entry. Equivalently, every token in the child's set MUST appear in the parent's set. | Parent action | Child action | Canonical child set | Covered? | | ------------- | ------------- | ------------------- | -------- | | `read,write` | `read` | `{read}` | Yes | | `read,write` | `write,read` | `{read, write}` | Yes | | `read,write` | `read,,write` | `{read, write}` | Yes | | `write` | `write,` | `{write}` | Yes | | `read` | `read,write` | `{read, write}` | No | | `read` | `Read` | `{Read}` | No | **Empty action set (canonical bottom).** An action string that canonicalizes to the empty set `{}` (e.g. `""` or `","`) is the bottom of the action lattice: it is **vacuously a subset of any parent set**, so it never widens scope and passes the attenuation check, but it **grants nothing** — a request always carries a concrete action token, which is never a member of `{}`, so an `att` entry with an empty action set authorizes no operation. Such an entry is inert, not separately rejected. ### Expiry Narrowing The child's `exp` MUST be less than or equal to every parent's `exp`. A delegated credential cannot outlive its authority. ### Expiry Basis (Normative) `exp` is **signer-discretionary**: the issuer chooses how long a credential is valid, and there is no protocol-imposed maximum in v1. Verifiers compare `exp` against a **deterministic time basis**, NOT a free-running wall clock: - **At ingest** (a delegated content operation carrying an inline `authorization`): `exp` is compared against the operation's own `createdAt`. A relay MUST NOT add an ingest-time wall-clock `exp` check. Each relay reads its own clock at a different instant, so a wall-clock check would make ingest verdicts diverge across relays and break convergence — the same content op would be accepted on one relay and rejected on another. - **At read** (standing authorization / per-request credential checks): `exp` is compared against the current time, because reads are local, ephemeral decisions that never enter the replicated log. #### Time Basis Conversion and Boundaries (Normative) The ingest time basis is derived from the operation's `createdAt` (an ISO-8601, millisecond-precision, UTC string) by converting to **integer Unix seconds**: ``` now_s = floor(createdAt_epoch_ms / 1000) ``` where `createdAt_epoch_ms` is the number of milliseconds since the Unix epoch parsed from the `createdAt` string. The conversion MUST truncate (floor) the millisecond remainder; it MUST NOT round. For the `.000Z` millisecond form used by all conforming operations this is exact, but implementations MUST floor unconditionally so that any sub-second component is discarded rather than rounded up. A credential's `iat` and `exp` are integer Unix seconds (JWT `NumericDate`). At ingest, a credential is temporally authorized **if and only if**: ``` iat <= now_s AND now_s < exp ``` This is the half-open interval `[iat, exp)`. The two boundaries are not symmetric and MUST be enforced exactly as stated: - **`iat` boundary is inclusive (open-accepting).** A credential MUST be accepted when `iat == now_s`. A credential MUST be rejected as not-yet-valid only when `iat > now_s`. - **`exp` boundary is exclusive (closed-rejecting).** A credential MUST be rejected as expired when `exp <= now_s`, including the exact instant `exp == now_s`. A credential is temporally valid only while `now_s < exp`. Conversely, an `exp` strictly greater than `now_s` (i.e. in the future relative to the operation's `createdAt`) MUST be accepted on the temporal check — even if that `exp` is already in the past relative to the verifier's own wall clock. This conversion and these boundaries are evaluated against the operation's `createdAt`, never against the verifier's wall clock (see the ingest bullet above). Two relays processing the same content operation therefore reach the same temporal verdict regardless of when each one ingests it. Revocation — not expiry — is the **timely lever** for invalidating a credential ahead of its natural expiry (see Revocation, below). A relay MAY additionally enforce a local maximum-age policy as **relay policy** (rejecting credentials whose `exp` is implausibly far in the future), but this is post-v1 and out of scope for the wire protocol; v1 defines no maximum-`exp` cap. --- ## Resource Types Two resource forms are defined. Both use the `chain:` prefix. ### `chain:` -- Exact Match Grants access to a specific content chain identified by its 31-character content ID. ```json { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ``` Matching: `chain:X` matches only `chain:X`. Exact content ID comparison. ### `chain:*` -- Wildcard Match Grants access to all content chains owned by the credential's root authority. The wildcard covers all present and future content without enumerating specific chain IDs. ```json { "resource": "chain:*", "action": "read" } ``` Matching: `chain:*` matches any `chain:` request for content where the delegation chain roots at the expected creator DID. This is the broadest resource scope. Common use case: granting a collaborator access to all of a creator's content. ### Attenuation Between Forms | Parent | Child | Valid? | Reason | | --------- | --------- | ------ | ----------------------------------------- | | `chain:*` | `chain:*` | Yes | Exact match | | `chain:*` | `chain:X` | Yes | Narrowing from wildcard to specific chain | | `chain:X` | `chain:X` | Yes | Exact match | | `chain:X` | `chain:*` | No | Widening from specific to wildcard | The resource hierarchy from broadest to narrowest is: `chain:*` > `chain:X`. Each delegation hop can only move down this hierarchy, never up. --- ## Public Credentials ### `aud: "*"` Semantics A credential with `aud` set to `"*"` is a **public credential**. It is not addressed to a specific DID -- it is a standing authorization that anyone can use. ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "aud": "*", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "read" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` ### Relay Ingestion Public credentials are ingested into the relay and stored as standing authorizations. When a request arrives for a resource, the relay checks its stored public credentials for matching `att` entries. The caller does not need to present the credential per-request -- the relay already has it. ### Private Credentials A credential with a specific DID as `aud` is a **private credential**. It is presented per-request by the holder. The relay does not store it -- the holder includes it with each request that requires authorization. ### Delegation Chain Interaction A parent credential with `aud: "*"` satisfies the audience linkage check for any child issuer. This means a public credential can serve as a parent in a delegation chain -- any DID can issue a narrower child credential using the public credential as proof. ### Security: `aud: "*"` + write = a world-writable bearer grant Because `aud: "*"` matches **any** operation signer, a public credential that grants a **write** action is a **bearer token anyone can present**. Any DID can attach the public credential inline as a content operation's `authorization` field and author writes to the covered chain(s) — the credential authorizes the bearer, not a named audience. A public `chain:*` write credential is effectively world-writable across every chain rooted at the issuer. Public credentials SHOULD therefore be **read-scoped**. Reserve `write` (and `chain:*`) for **private** credentials with a specific `aud`, where the relay also verifies that the operation signer matches the audience. If a public write credential is issued and later regretted, revocation is the remedy — but the exposure window is every relay that ingested it. --- ## Revocation ### Revocation Artifact A revocation is a standalone signed artifact that permanently invalidates a credential. It uses the artifact type `did:dfos:revocation`. **JWS Header:** ```json { "alg": "EdDSA", "typ": "did:dfos:revocation", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` **Payload:** ```json { "version": 1, "type": "revocation", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "credentialCID": "bafyrei...", "createdAt": "2026-03-07T00:00:00.000Z" } ``` | Field | Type | Description | | --------------- | -------------- | ----------------------------------- | | `version` | `1` | Schema version (literal `1`) | | `type` | `"revocation"` | Literal discriminator | | `did` | string | Issuer DID revoking the credential | | `credentialCID` | CID | CID of the credential being revoked | | `createdAt` | string | ISO 8601 timestamp | ### Rules - **Issuer-only.** Only the credential's issuer DID can revoke it. The `kid` DID in the JWS header MUST match the `did` field in the payload. - **Permanent.** There is no un-revoke operation. To restore access, issue a new credential. - **CID-addressed.** The revocation artifact itself has a CID (derived from the payload, embedded in the header), making it a content-addressable artifact. - **Gossiped.** Revocations are propagated across the relay network on the proof plane like any other signed operation. ### Relay Enforcement Relays maintain a revocation set keyed by `(issuerDID, credentialCID)`. During credential verification, the relay checks whether the credential's CID appears in the revocation set for that credential's issuer. This scoping prevents a rogue DID from revoking credentials it did not issue. A revoked credential fails verification regardless of its expiry or signature validity. Revocation MUST be checked at **every level** of a presented credential — the **leaf** credential AND each **parent** in its delegation chain. Checking only parents is insufficient: a revoked leaf credential, if its leaf-level revocation is not checked, would still authorize access. This applies to **both** authorization surfaces: the **read/route** path (standing authorization and per-request credential checks) and the **write** path (the inline `authorization` on a delegated content operation, verified at ingest). Without an explicit leaf check on the write path, revocation is not a timely lever for the leaf case. ### Revocation Scope Revocation is **forward-looking**: it prevents future use of a credential but does not retroactively invalidate operations already committed to the content chain. Once a delegated content operation (create, update, delete) has been ingested and verified by a relay, revoking the authorizing credential does not undo that operation — the operation is permanently part of the content chain's log. This is consistent with the content chain's append-only semantics: operations are immutable once committed. Revocation controls future access (standing authorization checks, per-request credential verification) but not the historical record. --- ## Relationship to Auth Tokens The credential system serves a different purpose than auth tokens. Both are DID-signed JWTs using Ed25519, but they answer different questions. | Concern | Auth Token | DFOS Credential | | ----------------- | ------------------------------------ | ------------------------------------------- | | Question answered | "Does this caller control this DID?" | "Does this DID have permission to do this?" | | Role | AuthN (authentication) | AuthZ (authorization) | | JWS `typ` | `JWT` | `did:dfos:credential` | | Lifetime | Short (minutes) | Long (hours to months) | | Audience | Relay hostname (prevents replay) | Specific DID or `"*"` | | Content-addressed | No (`cid` not in header) | Yes (`cid` in header) | | Revocable | No (short-lived, expires naturally) | Yes (via revocation artifact) | | Delegation | None | Via `prf` chains | | Key resolution | Current-state only | Historical (survives key rotation) | A typical relay request flow: 1. **Auth token** proves the caller controls a DID (AuthN). 2. **Credential** proves the DID has access to the requested resource (AuthZ). Auth tokens are ephemeral session tokens -- they establish identity. Credentials are durable authorization grants -- they establish access rights. --- ## Worked Examples ### Simple Credential Alice (`did:dfos:alice...`) grants Bob (`did:dfos:bob...`) write access to a content chain: ```json // JWS Header { "alg": "EdDSA", "typ": "did:dfos:credential", "kid": "did:dfos:alice...#key_abc", "cid": "bafyrei..." } // JWS Payload { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:alice...", "aud": "did:dfos:bob...", "att": [ { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` Alice is the root authority (`prf: []`). Bob presents this credential to a relay when writing to content chain `cv7n8vkvr64cctf3294h9k4eanhff8z`. The relay verifies Alice's signature, confirms the credential is not expired or revoked, and checks that the requested resource and action match an `att` entry. ### 2-Hop Delegation A space DID grants a member write access, and the member delegates to their device: ``` Space (root) -> Member -> Device (leaf) ``` **Hop 1 -- Space issues root credential to Member:** ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:space...", "aud": "did:dfos:member...", "att": [{ "resource": "chain:content1", "action": "write" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` **Hop 2 -- Member delegates to Device (with narrower expiry):** ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:member...", "aud": "did:dfos:device...", "att": [{ "resource": "chain:content1", "action": "write" }], "prf": [""], "exp": 1796169600, "iat": 1772841600 } ``` Verification walk for the Device's credential: 1. Verify Device credential signature (signed by Member). 2. Verify parent in `prf` (signed by Space). 3. Audience linkage: Device credential's `iss` (`member`) matches parent's `aud` (`member`). 4. Expiry: Device credential's `exp` does not exceed parent's `exp`. 5. Attenuation: `chain:content1/write` is covered by parent's `chain:content1/write`. 6. Parent has `prf: []` -- it is the root. Parent's `iss` (`space`) must match the expected root DID. ### Public Credential A space DID issues a public read credential for a content chain. Any DID can read without presenting the credential per-request: ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:space...", "aud": "*", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "read" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` This credential is ingested by the relay as a standing authorization. When any caller requests read access to `chain:cv7n8vkvr64cctf3294h9k4eanhff8z`, the relay matches it against stored public credentials — no auth token or per-request credential needed. Because `aud` is `"*"`, any DID can also use this credential as a parent in a delegation chain -- e.g., to issue a narrower credential to a specific collaborator with a shorter expiry. --- # Sign In With DFOS (SIWD) Cryptographic identity verification for third-party applications — Ed25519 challenge-response via a universal `/authorize` endpoint. One flow, two signing paths (managed and sovereign), same JWS output. Verification is pure crypto — no DFOS server in the loop after issuance. > **SIWD version 0.1.** Sign In With DFOS is an **optional authentication seam on its own `0.x` clock, independent of the Protocol v1 freeze** — it is **not part of the frozen protocol surface**. SIWD builds _on top of_ the frozen primitives (the identity chain, the [Signature Verification Profile](https://protocol.dfos.com/spec#signature-verification-profile), and [DFOS Credentials](https://protocol.dfos.com/credentials)) and may reference them, but the frozen protocol never depends on SIWD. The challenge-response and verification rules below are protocol-normative for any SIWD verifier; the platform endpoint, KMS custody, local CLI port, and health-check behavior are **reference-implementation** details of the DFOS platform, not normative requirements. No reference implementation of the third-party verifier exists yet in this repository — published here for review and to inform implementors. Discuss in the [DFOS](https://nce.dfos.com) space. --- ## Overview SIWD lets any third-party application verify a user's DFOS identity. The third party redirects to a single `/authorize` URL on the DFOS platform. The user consents, the challenge is signed with their DID key, and the callback delivers a standard JWS. The third party verifies the signature against the user's identity chain — resolved from any relay — without contacting the DFOS platform. Two signing paths exist behind the same endpoint: | Path | Signer | Trust model | | ------------- | --------------------------------------- | ------------------------------------------------------------------- | | **Managed** | Platform signs via KMS-held key | Platform custody — user trusts the platform to sign on their behalf | | **Sovereign** | User's local Go CLI signs via local key | Self-custody — user holds the key, platform never touches it | The third party never knows which path was used. Both produce the same JWS format, both reference keys in the same identity chain, both verify identically. --- ## Flow ### 1. Redirect to authorize The third-party application redirects the user to the platform's `/authorize` endpoint: ``` https://dfos.com/authorize? challenge= &redirect_uri=https://3p.com/callback &scope=identity ``` Query parameters: | Parameter | Required | Description | | -------------- | -------- | ------------------------------------------------------------------------------ | | `challenge` | Yes | Base64url-encoded challenge object (see [Challenge Schema](#challenge-schema)) | | `redirect_uri` | Yes | URL the platform redirects to after signing | | `scope` | Yes | A single requested scope (one scope per authorization request) | Scopes: | Scope | Meaning | | ------------------ | ------------------------------------------------------------------------ | | `identity` | Prove DID ownership only | | `read:` | Prove DID + return a read credential for the content chain `` | The `` is the content chain's content ID as defined by the protocol. A `read:` scope maps to exactly one [DFOS credential](https://protocol.dfos.com/credentials) attenuation: `{ "resource": "chain:", "action": "read" }`. SIWD does not define a resource grammar of its own — the resource form, action vocabulary, and matching rules are the credential spec's `chain:` exact-match form (see [Credentials — Resource Types](https://protocol.dfos.com/credentials)). There is no separate "chain type" dimension; every content resource is a `chain:` resource. ### 2. Consent screen The platform authenticates the user (existing session) and presents a consent screen. The screen describes what the third party is requesting — identity verification alone, or identity plus scoped resource access. If the user has local signing enabled, both signing options are presented. Otherwise, only managed signing is available. ### 3. Signing The user's DID key signs the challenge as a JWS compact token. See [Managed Signing Path](#managed-signing-path) and [Sovereign Signing Path](#sovereign-signing-path) for details. ### 4. Callback The platform (or local CLI) redirects to the `redirect_uri` with the signed challenge: ``` https://3p.com/callback? jws= &did=did:dfos: ``` If a credential was requested via `scope`, it is included as an additional parameter: ``` &credential= ``` --- ## Challenge Schema The challenge is a JSON object, base64url-encoded in the `challenge` query parameter: ```json { "domain": "3p.com", "nonce": "a8f2e93b...", "timestamp": "2026-04-13T15:30:00.000Z", "statement": "Sign in to 3P App", "did": "did:dfos:" } ``` | Field | Required | Description | | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `domain` | Yes | Origin domain of the requesting application. MUST match the domain in `redirect_uri`. | | `nonce` | Yes | Unique value generated by the third party, used for replay prevention. | | `timestamp` | Yes | ISO 8601 timestamp of challenge creation. | | `statement` | No | Human-readable description shown on the consent screen. | | `did` | No | If provided, binds the challenge to a specific DID. The platform MUST reject signing if the authenticated user's DID does not match. | The challenge is signed as a JWS using the user's DID key with `alg: "EdDSA"`. The JWS protected header MUST set `typ: "did:dfos:siwd"` — verifiers MUST reject any other `typ`. This follows the protocol's convention of typ-scoped envelopes (`did:dfos:identity-op`, `did:dfos:credential`, …) and is what lets a typ-routing verifier tell a SIWD proof apart from every other DFOS token; without the gate, a JWS signed for one purpose could be presented as another. The JWS `kid` header contains the DID URL of the signing key (`did:dfos:#`), following the same convention as identity and content chain operations. ### Canonical signing input The signer and the verifier MUST agree on the exact bytes that are signed — otherwise a field reordering or an omitted optional silently breaks verification across the package boundary. The **canonical signing input** is the challenge object serialized as UTF-8 JSON with its members in this fixed order, omitting any absent optional member: ``` domain, nonce, timestamp, statement?, did? ``` These bytes are both the JWS payload (the segment the `alg: "EdDSA"` signature covers) and the body that is base64url-encoded into the `challenge` query parameter — the same bytes serve both roles, so a verifier that holds the encoded challenge and the JWS can confirm they describe the same object. A reference implementation of this byte contract is `siwdSigningInput(challenge)` in [`@metalabel/dfos-client/siwd`](https://protocol.dfos.com), a pure function both a browser wallet (signing) and a third-party verifier (checking) import so the encoding lives in exactly one place. --- ## Managed Signing Path The platform holds the user's DID key material in a KMS (Key Management Service). When the user consents via the managed path: 1. Platform verifies the user's session. 2. Platform signs the challenge with the user's KMS-held key. 3. Platform redirects to `redirect_uri` with the signed JWS and DID. The KMS key is one of the user's `authKeys` declared in the identity chain — the authentication key set that verification resolves against (see step 2 below). The signature is indistinguishable from any other Ed25519 signature over the challenge — the third party verifies it against the identity chain like any other key. The KMS custody model and the platform's session handling are **reference-implementation** details; what is normative is only that the signature is produced by a key declared in the identity chain and verifies under the [Signature Verification Profile](https://protocol.dfos.com/spec#signature-verification-profile). --- ## Sovereign Signing Path Users who hold their own keys via the DFOS Go CLI can sign challenges locally. The platform does not touch the key material. ### Configuration The user enables local signing in their platform settings: | Setting | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------- | | `localSigningEnabled` | boolean | Whether the sovereign signing option is presented on consent screens | | `localSigningPort` | number | Port the local CLI listens on (default: `8420`) | > These settings, the `localhost` port, and the preflight `GET /health` probe are **reference-implementation** behavior of the DFOS platform and Go CLI, not protocol-normative. A SIWD verifier never observes them — it sees only the resulting JWS. ### Flow 1. User selects "Sign locally" on the consent screen. 2. Platform redirects to `http://localhost:/authorize` with the same `challenge` and `redirect_uri` parameters. 3. The Go CLI receives the request, presents consent (terminal or local web UI), and signs the challenge with the locally-held key. 4. The CLI redirects to `redirect_uri` with the signed JWS and DID. The local key MUST be declared in the user's identity chain (`authKeys`). The third party resolves the identity chain and finds the key — same verification as the managed path. ### Failure handling If the user selects sovereign signing but the CLI is not running, the browser fails to connect to localhost. The user navigates back and falls through to managed signing. No state is corrupted — the challenge is stateless and can be signed by either path. The platform MAY perform a preflight health check (`GET http://localhost:/health`) to disable the sovereign signing button when the CLI is not reachable. --- ## Third-Party Verification Verification is identical regardless of signing path. The JWS signature MUST be checked under the DFOS [Signature Verification Profile](https://protocol.dfos.com/spec#signature-verification-profile) — the same profile every DFOS verifier applies — not unprofiled "standard Ed25519 verification": 1. **Decode the JWS** — extract the challenge payload, `kid` header (DID URL of signing key), and signature. Before any signature work, apply the header gates: the protected header `typ` MUST equal the exact string `"did:dfos:siwd"`; the protected header `alg` MUST equal the exact string `"EdDSA"`; a `crit` member MUST cause rejection; and an embedded header key (`jwk`, `x5c`, or any key-bearing member) MUST cause rejection. The key is never read from the header. 2. **Resolve the DID** — fetch the identity chain from any DFOS relay and replay it to its **current state**. Extract the public key matching the `kid` from the current `authKeys`. Keys that have been rotated out, and identities that have been deleted, MUST NOT verify — a challenge signed by a key that is no longer current (or by a deleted identity) MUST be rejected. (The protocol has no identity-revocation primitive; deletion is the terminal state a verifier checks. Credential-scoped revocation is a separate mechanism that applies to the optional returned credential, not to the identity.) 3. **Verify the signature** — Ed25519 verification of the JWS against the resolved public key, with the canonical-scalar gate (`S < L`) and the 64-byte length check from the profile. 4. **Validate the nonce** — confirm the `nonce` in the challenge payload matches the server-side value issued to this session. Discard the nonce after use. 5. **Validate the timestamp** — reject challenges older than a reasonable window (implementation-defined, e.g., 5 minutes). 6. **Validate the domain** — confirm the `domain` in the challenge matches the verifier's own origin. If a credential was returned, the third party stores it and presents it to relays for scoped access. See [Optional Credential Return](#optional-credential-return). No DFOS platform server is contacted during verification. The third party only needs access to a relay (any relay) to resolve the DID's identity chain. --- ## Optional Credential Return When `scope` includes resource access beyond `identity`, the callback includes a DFOS credential alongside the signed challenge. ### User-owned content For content owned by the user's DID, the credential is issued by that DID: a standard [DFOS credential](https://protocol.dfos.com/credentials) with `iss` = the user's DID, `aud` = the third-party app's DID, and a single attenuation `{ "resource": "chain:", "action": "read" }` covering the requested content chain. The envelope, signing, CID derivation, and validity bounds are exactly as the [credential spec](https://protocol.dfos.com/credentials) defines — SIWD adds no fields and no separate credential format. ### Space-owned content For content owned by a space (a separate DID), the credential is issued by the **space's DID**, not the user's. The platform mediates: the user consents, the platform verifies the user's membership and permissions within the space, then issues the credential from the space's DID. The third party presents the credential to any relay hosting that content. The relay verifies the credential against the space's identity chain and grants scoped access. --- ## Security Considerations ### Replay prevention The `nonce` field is the primary replay defense. The third party MUST: - Generate a cryptographically random nonce per authorization request. - Store it server-side, bound to the user's session. - Reject any callback where the nonce does not match or has already been consumed. - Expire unused nonces after a short window. The `timestamp` field provides a secondary bound — challenges with stale timestamps SHOULD be rejected even if the nonce is valid. ### Redirect URI validation The platform MUST validate `redirect_uri` against a registered allowlist for the requesting application. Open redirectors allow phishing — an attacker could substitute their own callback URL to capture signed challenges. The `domain` field in the challenge MUST match the domain of the `redirect_uri`. The platform MUST reject requests where these diverge. ### Challenge binding If the `did` field is present in the challenge, the platform MUST refuse to sign with any other DID. This prevents an attacker from substituting a different user's identity into a challenge intended for a specific user. ### Localhost security (sovereign path) The sovereign path redirects to `localhost`, which is not TLS-protected. This is acceptable because: - The signing key never leaves the local machine. - The challenge is not secret — it is a value the user is explicitly consenting to sign. - The redirect back to `redirect_uri` uses HTTPS. The CLI SHOULD bind exclusively to `127.0.0.1` (not `0.0.0.0`) to prevent network-adjacent access. ### Token lifetime Signed challenges are single-use authentication proofs, not bearer tokens. Third parties SHOULD establish their own session after verification and discard the JWS. Credentials returned via `scope` have an explicit `exp` (expiration) field. Third parties MUST respect expiration and re-request credentials when they expire. --- # DFOS Threat Model A consolidated map of the DFOS adversary model and trust boundaries. This document does not introduce new protocol rules — it assembles the threat surface that is already specified, in prose, across [PROTOCOL.md](https://protocol.dfos.com/spec), [CREDENTIALS.md](https://protocol.dfos.com/credentials), [WEB-RELAY.md](https://protocol.dfos.com/web-relay), [DID-METHOD.md](https://protocol.dfos.com/did-method), and [SIWD.md](https://protocol.dfos.com/siwd), and links each claim back to its source. This spec is under active review. Discuss it in the [DFOS](https://nce.dfos.com) space. --- ## Trust Boundaries DFOS has two planes with fundamentally different trust models. ### Proof plane — self-authenticating, trustless The crypto core is the trust boundary (PROTOCOL.md "Protocol Overview", `specs/PROTOCOL.md`). Identity chains, content chains, artifacts, countersignatures, credentials, and revocations are all signed, content-addressed objects that anyone can verify with a public key and any standard EdDSA + dag-cbor library. There is no privileged registry, blockchain, or consensus layer; the identifier _is_ the trust anchor (DID-METHOD.md "Abstract", `specs/DID-METHOD.md`). Verification is against the chain, not the source — a `did:dfos` is verified by re-deriving it from the genesis CID (DID-METHOD.md §5.2.3, `specs/DID-METHOD.md`). All proof-plane relay routes are unauthenticated; the operations carry their own authentication (WEB-RELAY.md "Proof Plane", `specs/WEB-RELAY.md`). Everything below the crypto core is cryptographically verified. Nothing above it needs to be trusted to verify a proof. ### Content plane — honest-host, undisclosed-by-default The protocol commits to content _hashes_, not plaintext — it does not encrypt (README.md, `README.md`; PROTOCOL.md "Philosophy", `specs/PROTOCOL.md`). Confidentiality of the underlying documents is enforced at the application layer by whoever serves them. **The relay operator can read what it stores.** This is undisclosed-by-default, _not_ end-to-end encrypted. The content plane never gossips; blobs are stored by the relay that received them and served only to authorized readers (WEB-RELAY.md "Content Plane", `specs/WEB-RELAY.md`). Content-plane access is gated by an auth token plus (for non-creators) a read credential (WEB-RELAY.md "Content Plane Access", `specs/WEB-RELAY.md`). The security posture of a document is therefore the security posture of the relay operator that holds it. ### Countersignatures live on the public proof plane A countersignature is a proof-plane object (PROTOCOL.md "Countersignatures", `specs/PROTOCOL.md`). Publishing one permanently and publicly links the witness DID to its target: anyone can see that this identity attested to that operation. A countersignature therefore MUST NOT be used to cross a public/private boundary — witnessing a target that is meant to stay confined to a private context leaks the witness↔target association onto the public plane, where it is immutable and gossiped. If the fact of the attestation is itself sensitive, do not countersign. --- ## Adversary Classes | Adversary | Can | Cannot | Pointer | | --------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | | Malicious/Byzantine relay | Withhold, reorder, equivocate, censor, serve stale state, read stored content-plane blobs | Forge a chain or operation | DID-METHOD.md §6.4 `specs/DID-METHOD.md` | | Malicious peer | Push invalid/spam operations to peers | Have invalid operations accepted (each peer re-verifies, no trust) | WEB-RELAY.md "Peering" `specs/WEB-RELAY.md` | | Unauthenticated submitter | POST arbitrary JWS to `/proof/v1/operations`; impose CPU + storage cost | Have malformed/unsigned ops accepted | WEB-RELAY.md "Operation Ingestion" `specs/WEB-RELAY.md` | | Compromised custody/KMS key | Full, indistinguishable impersonation of the user | Be detected on-chain (signature is valid Ed25519) | SIWD.md "Managed Signing Path" `specs/SIWD.md` | | Lost key | — | — (1-of-N availability vs. total loss) | DID-METHOD.md §6.2 `specs/DID-METHOD.md` | ### Malicious / Byzantine relay A relay is untrusted by construction. It can **withhold** a chain (denial of service), **serve stale** state, **reorder** delivery, **equivocate** (serve different views to different clients), and **censor** operations it dislikes. It can also **read** any content-plane blob it stores (see Trust Boundaries). What it **cannot** do is **forge**. Every ingest path re-derives the operation CID and verifies the Ed25519 signature over the signed bytes (WEB-RELAY.md "Verification", `specs/WEB-RELAY.md`); peers verify independently with no trust (WEB-RELAY.md "Peering", `specs/WEB-RELAY.md`). An attacker who intercepts a chain request can withhold, serve a stale chain, or serve a completely different chain — but a modified or forged chain fails the self-certification check (DID-METHOD.md §6.4, `specs/DID-METHOD.md`). ### Malicious peer Peering carries no inter-relay trust: "No trust between relays, no coordination required" (WEB-RELAY.md "Philosophy", `specs/WEB-RELAY.md`). A peer that gossips, is read through, or is synced from has its operations fully re-verified locally before storage (WEB-RELAY.md "Peering" / "Convergence", `specs/WEB-RELAY.md`, `specs/WEB-RELAY.md`). A malicious peer can therefore only impose cost and noise, not corrupt state. ### Malicious / unauthenticated submitter `POST /proof/v1/operations` is unauthenticated (WEB-RELAY.md "Quick Start" route table, `specs/WEB-RELAY.md`); operations self-authenticate. An attacker can submit arbitrary JWS tokens, imposing CPU (verification) and storage (store-then-verify buffering, `specs/WEB-RELAY.md`) cost. Field-size ceilings bound per-operation abuse (PROTOCOL.md "Operation Field Limits", `specs/PROTOCOL.md`), but **protocol-layer rate limiting is explicitly deferred** to the deployment layer (WEB-RELAY.md "What's Deferred", `specs/WEB-RELAY.md`). ### Compromised custody / KMS key In the SIWD managed-signing path the platform holds the user's key material in a KMS and signs on their behalf (SIWD.md "Managed Signing Path", `specs/SIWD.md`). A compromise of that custody is **full impersonation** and is **indistinguishable on-chain**: the signature is a valid Ed25519 signature by a key declared in the identity chain, so it verifies identically to a sovereign signature (SIWD.md "Overview" / "Managed Signing Path", `specs/SIWD.md`, `specs/SIWD.md`). The sovereign path avoids this by never letting the platform touch the key (SIWD.md "Sovereign Signing Path", `specs/SIWD.md`). ### Lost key There is no key pre-rotation and no recovery mechanism (DID-METHOD.md §6.2, `specs/DID-METHOD.md`). The mitigation is **1-of-N availability**: each role set holds up to 256 keys per role, and any one current key can authorize an operation, so an identity can spread controller/auth keys across devices and rotate out a lost one from a survivor (DID-METHOD.md §6.2, `specs/DID-METHOD.md`). This is availability, not recovery — it requires registering additional keys _in advance_ while a controller key is still held. It is symmetric with the compromise surface: every additional device key is also another key to keep safe. Total loss of every key in a role set is unrecoverable. --- ## Self-Certification Binding Strength `did:dfos` identifiers and content IDs are 31-character strings over a 19-symbol alphabet (`2346789acdefhknrtvz`), derived as `customAlpha(SHA-256(genesis CID bytes))` (PROTOCOL.md "ID Alphabet" / "Addressing", `specs/PROTOCOL.md`, `specs/PROTOCOL.md`; DID-METHOD.md §3.1–§3.2, `specs/DID-METHOD.md`). ``` Identifier space: 19^31 ≈ 2^131.6 bits Birthday collision: ≈ 2^65.8 Targeted second-preimage ≈ 2^131.6 ``` This is the binding strength **of the identifier**, which is below SHA-256's full 256-bit strength: the identifier truncates and re-encodes the hash. The full 32-byte genesis CID and the operation signatures are unaffected — this parameter bounds only how hard it is to find a _second_ chain that encodes to the same 31-character DID/content ID, or two chains that collide. This parameter (alphabet size × length) was **widened to 31 characters for v1** — the targeted second-preimage cost (≈ 2^131.6) now sits above the 128-bit floor, and the birthday-collision cost rises to ≈ 2^65.8. This is a settled decision for v1, not an open parameter. See PROTOCOL.md "ID Alphabet" (`specs/PROTOCOL.md`) and DID-METHOD.md §3.1 (`specs/DID-METHOD.md`). --- ## Head Selection Is Convergent, Not Canonical Deterministic head selection — highest `createdAt`, lexicographic-highest-CID tiebreak — guarantees that any implementation with the same set of operations computes the same head, regardless of ingestion order (PROTOCOL.md "Chain Validity", `specs/PROTOCOL.md`; WEB-RELAY.md "Fork Acceptance", `specs/WEB-RELAY.md`). That is its entire job: **convergence across implementations.** It is **not** a canonical-truth or causal-ordering mechanism. `createdAt` is signer-asserted and bounded only by the relay-enforced +24h future bound (PROTOCOL.md "Future timestamp bound", `specs/PROTOCOL.md`; WEB-RELAY.md "Future timestamp guard", `specs/WEB-RELAY.md`). Any current-key holder can therefore **bid** the head by choosing a `createdAt` up to 24 hours ahead — forks are valid, and the highest timestamp wins. Undeletion falls directly out of this: a controller can fork from before a delete with a higher `createdAt` and make the non-deleted branch the head (WEB-RELAY.md "Undeletion", `specs/WEB-RELAY.md`; DID-METHOD.md §5.4, `specs/DID-METHOD.md`). Head selection answers "which tip do all honest verifiers agree on?" — not "which tip is true?" or "which happened first?". Semantic interpretation of forks (concurrency glitch, intentional recovery, equivocation) is application-defined (PROTOCOL.md "Chain Validity", `specs/PROTOCOL.md`; DID-METHOD.md §6.3 "Equivocation", `specs/DID-METHOD.md`). --- ## Explicitly-Accepted Residual Risks (v1) These are known and deliberately accepted for v1. - **No end-to-end encryption.** Content confidentiality is an application-layer concern; the relay operator can read stored blobs (README.md, `README.md`; PROTOCOL.md "Philosophy", `specs/PROTOCOL.md`; WEB-RELAY.md "Content Plane", `specs/WEB-RELAY.md`). - **No protocol-layer rate limiting.** Anti-spam / rate limiting is an operational concern, pushed to the deployment layer (WEB-RELAY.md "What's Deferred", `specs/WEB-RELAY.md`). Blob size limits are likewise unenforced by the protocol (`specs/WEB-RELAY.md`). - **Public (`aud: "*"`) write credential is a world-writable bearer.** Because `aud: "*"` matches any signer, a public credential granting `write` authorizes the _bearer_, not a named audience — anyone can attach it inline and write to the covered chains. Public credentials SHOULD be read-scoped (CREDENTIALS.md "Security: `aud: "*"` + write", `specs/CREDENTIALS.md`). - **Same-relay auth-token replay until expiry.** Auth tokens are not content-addressed and not revocable; they are scoped to a relay via `aud` (preventing cross-relay replay) and rely on short lifetime for invalidation (CREDENTIALS.md "Relationship to Auth Tokens", `specs/CREDENTIALS.md`; WEB-RELAY.md "Relay Identity", `specs/WEB-RELAY.md`). Within the same relay, a captured token is replayable until it expires. - **SIWD security controls live in the unimplemented third-party verifier.** Replay prevention (nonce), redirect-URI allowlisting, challenge-DID binding, and timestamp windows are obligations on the verifying third party, and SIWD has no reference implementation in this repository yet (SIWD.md note, `specs/SIWD.md`; SIWD.md "Security Considerations", `specs/SIWD.md`). --- ## Out of Scope Mirroring [SECURITY.md](../SECURITY.md) "Scope": out of scope are vulnerabilities in third-party dependencies (report upstream), and any issue that requires a compromised host or a user's own private keys (`SECURITY.md` "Scope"). A compromised custody/KMS key and a lost key are _modeled_ above as adversary classes for completeness, but their _remediation_ (key hygiene, custody choice) is outside the protocol's integrity guarantees. In scope for security reporting is anything that breaks integrity, authenticity, or authorization — signing, JWS construction/verification, dag-cbor canonical encoding, CID derivation, chain state-machine transitions, credential verification, and relay auth (see SECURITY.md). --- # DFOS Conformance What it means to be a _conformant_ DFOS implementation, by tier, and how to prove it against the existing proving corpora. This document defines no new protocol rules — it points at the normative MUST sets already specified in [PROTOCOL.md](https://protocol.dfos.com/spec), [CREDENTIALS.md](https://protocol.dfos.com/credentials), [WEB-RELAY.md](https://protocol.dfos.com/web-relay), and [DID-METHOD.md](https://protocol.dfos.com/did-method), and binds each tier to the tests that exercise it. This spec is under active review. Discuss it in the [DFOS](https://nce.dfos.com) space. --- ## Conformance Tiers Three roles. They compose: a relay is also a verifier; a signer relies on a verifier to be checkable. Each tier's normative MUST set lives in the referenced spec sections — this document points, it does not restate every rule. ### Tier 1 — Verifier A verifier consumes signed objects and decides accept/reject. It implements: - **Signature Verification Profile** — `alg: "EdDSA"` exact-match, `crit` rejection, no header-key-trust (`jwk`/`x5c` rejected), canonical scalar `S < L`, 64-byte length (PROTOCOL.md "Signature Verification Profile" §1–§4, `specs/PROTOCOL.md`). Applies to **every** verification path. - **Identity chain verification** — genesis bootstrap, signer-validity against prior controller state, `previousOperationCID` linkage, `createdAt` ordering, `header.cid` consistency, terminal-state enforcement (PROTOCOL.md "Verification → Identity Chain", `specs/PROTOCOL.md`; "Identity Chain Signer Validity", `specs/PROTOCOL.md`; DID-METHOD.md §5.2.1, `specs/DID-METHOD.md`). - **Content chain verification** — valid EdDSA signature, `kid`-DID matches payload `did`, CID integrity, chain linkage, terminal state, and creator-sovereignty authorization when `enforceAuthorization` is enabled (PROTOCOL.md "Verification → Content Chain", `specs/PROTOCOL.md`; "Content Chain Signer Model", `specs/PROTOCOL.md`). - **Services projection** — project the identity-chain `services` array into verified identity state as full-state discovery vocabulary: enforce ≤ 256 entries, `id`s unique within the set, the 32768-byte CBOR-encoded cap, and recognized-type structure (`DfosRelay` requires `endpoint`, `ContentAnchor` requires `label` + `anchor`); preserve but ignore unrecognized `type`s (MUST-ignore-unknown) (PROTOCOL.md "Services", `specs/PROTOCOL.md`). - **Derivation** — DID/CID/multikey: dag-cbor canonical encoding with integer (not float) number encoding, CIDv1 construction, the 19-char/31-length ID alphabet, W3C Multikey (PROTOCOL.md "CID Construction", `specs/PROTOCOL.md`; "Number Encoding", `specs/PROTOCOL.md`; "ID Alphabet", `specs/PROTOCOL.md`; "Multikey Encoding", `specs/PROTOCOL.md`). - **Credential verification** (if it consumes credentials) — delegation walk, monotonic attenuation, linear (single-parent) `prf`, expiry narrowing against a deterministic time basis, depth limit, revocation at every level (CREDENTIALS.md "Verification Walk" / "Attenuation Rules" / "Revocation", `specs/CREDENTIALS.md`, `specs/CREDENTIALS.md`, `specs/CREDENTIALS.md`). ### Tier 2 — Signer A signer emits well-formed envelopes that a Tier-1 verifier accepts. It implements: - **JWS Envelope Format** — signing input construction, signing order (derive CID before signing, embed in protected header) (PROTOCOL.md "JWS Envelope Format" / "`cid` Header", `specs/PROTOCOL.md`, `specs/PROTOCOL.md`). - **`kid` rules** — bare key ID for identity genesis, DID URL otherwise; content ops always DID URL (PROTOCOL.md "kid Rules", `specs/PROTOCOL.md`). - **`cid` header** — present on every operation JWS, artifacts, countersignatures, credentials, revocations; absent on auth-token JWTs (PROTOCOL.md "`cid` Header", `specs/PROTOCOL.md`). - **Canonicalization discipline** — integer number bounds, no Unicode normalization, no duplicate keys (PROTOCOL.md "Number Encoding" / "String Encoding" / "JSON Payload Canonicalization", `specs/PROTOCOL.md`, `specs/PROTOCOL.md`, `specs/PROTOCOL.md`). ### Tier 3 — Relay A relay ingests, sequences, and serves. It implements: - **Ingestion** — single `POST /proof/v1/operations` endpoint, `typ`-based classification, dependency sort, per-type verification, store-then-verify convergence (WEB-RELAY.md "Operation Ingestion" / "Convergence", `specs/WEB-RELAY.md`, `specs/WEB-RELAY.md`). - **Sequencing & fork handling** — fork acceptance, deterministic head selection, ingestion statuses, deletion semantics (WEB-RELAY.md "Fork Acceptance" / "Ingestion Statuses" / "Deletion Semantics", `specs/WEB-RELAY.md`, `specs/WEB-RELAY.md`, `specs/WEB-RELAY.md`). - **Capability / feature flags + 501 semantics** — the well-known response advertises capabilities; unsupported optional features return **501 Not Implemented** (not 404) (WEB-RELAY.md "Well-Known Endpoint", `specs/WEB-RELAY.md`; "Two Planes", `specs/WEB-RELAY.md`). **The content plane is OPTIONAL.** A compliant relay **always** serves the proof plane (`capabilities.proof: false` is not a valid value); when `capabilities.content: false`, all content-plane routes return 501 (WEB-RELAY.md "Well-Known Endpoint", `specs/WEB-RELAY.md`). Proof-plane-only is a fully conformant relay. The content plane is the [document gateway](https://protocol.dfos.com/document-gateway), an optional service on its own `0.x` clock — outside the v1 conformance tiers. **Writes are OPTIONAL too.** A lite (pull-only) proof node MAY advertise `capabilities.write: false`, in which case `POST /proof/v1/operations` returns **501 Not Implemented** while all proof-plane read routes remain conformant; the node stays current by pulling peers' logs (WEB-RELAY.md "Lite (pull-only) node"). So a conformant proof node need not accept writes — only serve and verify them. A read-only node cannot be seeded by the suite (its POSTs 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, asserting the relay's served state matches. The served state must be reproducible from the served operations alone — which needs no write. --- ## Proving Corpora Each tier maps to an existing test suite. The mapping is deliberately honest about what each suite actually exercises. | Tier | Corpus | What it proves | | ----------------- | ----------------------------------------------- | ------------------------------------------------------------ | | Verifier / Signer | `packages/protocol-verify` (5 languages) | Single-JWS primitives: signature, field equality, derivation | | Verifier / Signer | `packages/dfos-protocol/tests` (TS) | Full chain/authz semantics | | Verifier / Signer | PROTOCOL.md "Deterministic Reference Artifacts" | Reproducible reference vectors from fixed seeds | | Relay | `packages/relay-conformance` (Go) | HTTP integration against any live relay | ### Verifier / signer corpora - **`packages/protocol-verify`** — the five-language re-derivation suite (TypeScript, Go, Python, Rust, Swift). Each suite is **standalone**: native crypto only, no DFOS library imports, reference constants hardcoded inline (the same deterministic values published in PROTOCOL.md). See `packages/protocol-verify/README.md`. - **`packages/dfos-protocol/tests`** — the TypeScript reference test suite. - **Deterministic reference artifacts** — PROTOCOL.md "Deterministic Reference Artifacts" (`specs/PROTOCOL.md`) and the "Verification Checklist for Independent Implementers" (`specs/PROTOCOL.md`) provide every value an implementer needs to self-check, derived from `SHA-256("dfos-protocol-reference-key-N")`. **Honest coverage statement.** The cross-language `protocol-verify` suites prove **single-JWS primitives** — signature verification, field equality, and derivation (key, multikey, CID, DID, document CID, credential structure, number-encoding determinism — the sections in `packages/protocol-verify/README.md`). They do **not** all exercise the stateful chain semantics. Per the cross-language table in PROTOCOL.md, the five `protocol-verify` suites all run the same primitive set (TypeScript 73, Go 19, Rust 19, Python 63, Swift 18); the deep stateful chain-tier coverage lives separately in the TypeScript reference suite (`dfos-protocol/tests`, 246) and the Go library suite. **Chain linking, fork/head-selection, delete-terminality, and credential expiry/delegation are exercised in the TypeScript and Go suites, not in all five languages.** A claim of full chain-tier conformance rests on the TS + Go corpora; the five-language suite proves the cryptographic core is unambiguous across languages. ### Relay corpus - **`packages/relay-conformance`** — a Go integration suite that runs against **any live relay endpoint** over HTTP. It exercises the relay-tier MUST set (ingestion, sequencing, fork acceptance, head convergence, capability flags, 501 semantics, deletion semantics) against the running service rather than the library. Capability-gated variants self-skip unless the relay advertises the matching flag: the content-disabled suite (501 on every content route when `capabilities.content: false`) and the write-disabled suite (`scripts/run-write-disabled.sh` — recompute-from-log read-only conformance when `capabilities.write: false`). - **Content following** is inherently a **two-relay** behavior (a follower materializing an origin's bytes), so it is exercised in the Go relay library's race-tested in-package suite rather than the single-endpoint conformance corpus. An origin and an eager follower are wired over loopback HTTP; the suite asserts the full lifecycle — authorized-but-not-yet-materialized (blob `404`), then eventual materialization of content-address-verified bytes, then revoke (the serve gate denies while bytes are still cached), then GC reclamation — over the real `HttpPeerClient` and content-plane HTTP routes. See WEB-RELAY.md "Content Following". --- ## Self-Certification Procedure A third party claims conformance by running the corpus that matches its tier. No central authority grants conformance — the proofs are reproducible and the claim is self-certifying, mirroring the protocol's own trust model. 1. **Verifier / signer.** Implement the Tier-1/Tier-2 MUST sets using your own crypto stack. Reproduce the deterministic reference artifacts (PROTOCOL.md "Verification Checklist", `specs/PROTOCOL.md`) and, ideally, add a suite to `packages/protocol-verify` following its "Adding a New Language" steps — hardcoding the same reference constants inline so the suite is standalone. Agreement across suites is the proof; divergence means the spec (or your implementation) is wrong. 2. **Relay.** Stand up your relay and run `packages/relay-conformance` against its endpoint. A passing run demonstrates the relay-tier MUST set against the live service. Declare your capability flags honestly in `/.well-known/dfos-relay`; a proof-plane-only relay is conformant. 3. **Scope your claim.** State which tier(s) you claim and which corpora you ran. Per the honest-coverage statement above, "verifier conformant via the five-language primitive suite" is a narrower claim than "chain-tier conformant via the TS/Go suites" — say which. --- # DFOS CLI The sovereign actor in the DFOS architecture. Generates keys, signs operations, stores chains locally, decides what to publish and when. Relays are dumb pipes — the CLI holds the keys. This spec is under active review. Discuss it in the [DFOS](https://nce.dfos.com) space. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-cli) · [Protocol](https://protocol.dfos.com) --- ## Install ### One-liner (Linux / macOS) ```bash curl -sSL https://protocol.dfos.com/install.sh | sh ``` ### Homebrew (macOS) ```bash brew install metalabel/tap/dfos ``` ### Container ```bash docker pull ghcr.io/metalabel/dfos:latest ``` ### Windows Download the latest release from [GitHub Releases](https://github.com/metalabel/dfos/releases/latest). Extract the zip and add `dfos.exe` to your PATH. ### From source ```bash cd packages/dfos-cli && make build ``` --- ## Quickstart ```bash # create your identity dfos identity create --name myname # publish your first post echo '{"$schema":"https://schemas.dfos.com/post/v1","format":"short-post","body":"gm"}' | dfos content create - # see it dfos content list # run a relay dfos serve ``` --- ## Philosophy The DFOS protocol defines signed chain primitives — identity and content chains, credentials, countersignatures — but says nothing about how a user manages keys or communicates with relays. The CLI is the user-side agent that bridges this gap. Relays are dumb pipes that verify and store. The CLI is the sovereign actor: it generates keys, signs operations, decides what to publish and when, and independently verifies what relays serve back. Private key material never leaves the local machine. The CLI is designed for both human operators and AI agents. Every command that produces output supports `--json` for structured machine-readable responses. Every interactive prompt has a flag equivalent. Stdin is accepted wherever a file is expected. --- ## Architecture ``` ┌──────────────────────────┐ │ OS Keychain │ Ed25519 private key seeds │ (never on disk) │ macOS Keychain / Linux secret-service / Windows Credential Manager └──────────┬───────────────┘ │ ┌──────────▼───────────────┐ │ ~/.dfos/ │ Configuration + local relay │ ├── config.toml │ Relays, identities, contexts, defaults │ └── relay.db │ SQLite — chains, operations, blobs └──────────┬───────────────┘ │ ┌──────────▼───────────────┐ │ Relays (HTTP) │ Verify, store, serve │ relay.dfos.com │ Relays are peers, not authorities │ localhost:4444 │ └──────────────────────────┘ ``` The CLI embeds a full relay locally — the same SQLite-backed relay that runs as a network service via `dfos serve`. Every CLI command reads and writes to this local relay. Running `dfos serve` exposes it over HTTP with peer sync, gossip, and read-through. The CLI has three layers of state: - **OS Keychain**: private key material only. One entry per Ed25519 key, keyed by `dfos` service + `did:dfos:xxx#key_yyy` account. Hex-encoded 32-byte seed. Never written to disk. - **Local relay** (`~/.dfos/relay.db`): SQLite database storing identity chains, content chains, operations, countersignatures, and blobs. Both chains you own (have private keys for) and chains you've fetched from relays. - **Config** (`~/.dfos/config.toml`): relay URLs, identity names, active context, defaults. --- ## Context Model A **context** is a (named-identity, named-relay) pair. Contexts determine which identity signs operations and which relay receives them. ### Configuration ```toml active_context = "alice@local" [relays.local] url = "http://localhost:4444" [relays.prod] url = "https://relay.dfos.com" [identities.alice] did = "did:dfos:zhkrrzrd7z623ha8tt7dt699de8r3ar" [identities.bob] did = "did:dfos:cv7n8vkvr64cctf3294h9k4eanhff8z" [defaults] auth_token_ttl = "5m" credential_ttl = "24h" ``` Contexts are implicit: `alice@local` resolves to identity "alice" + relay "local" without needing an explicit `[contexts]` section. Named contexts can be defined for non-obvious names. ### Resolution Precedence Every command resolves its active (identity, relay) pair via: ``` --ctx flag → DFOS_CONTEXT env → active_context in config → error --identity → DFOS_IDENTITY env → from resolved context --peer → DFOS_RELAY env → from resolved context → (optional for local-only ops) ``` The `@` syntax is shorthand: `alice@local` = identity "alice" + relay "local". If both the identity and relay names exist in config, the context resolves without pre-registration. --- ## Key Management ### Backends The CLI stores each Ed25519 seed under an account key of the form `did:dfos:xxx#key_yyy`. There are two storage backends: | Backend | Location | When used | | ----------- | ----------------------- | -------------------------------------------------- | | OS keychain | system keychain/keyring | default, when an OS keychain is reachable | | File store | `~/.dfos/keys/` | keychain probe fails, or `DFOS_NO_KEYCHAIN` is set | On startup the CLI probes the OS keychain with a test write/read/delete cycle (the gh CLI pattern). If the probe succeeds, keys go in the keychain. If it fails — which is the common case on headless Linux, containers, and CI where no keychain daemon is running — the CLI prints a warning to stderr and **falls back to the file store**. Setting `DFOS_NO_KEYCHAIN` to any non-empty value skips the probe and uses the file store directly. `dfos status` reports the active backend in the `Keys:` line (`keychain` or `file ()`), so you can always see where your keys actually live. #### Keychain backend One keychain entry per key: | Field | Value | | ------- | -------------------------------- | | Service | `dfos` | | Account | `did:dfos:xxx#key_yyy` | | Secret | hex-encoded 32-byte Ed25519 seed | Protection is whatever the host keychain provides (e.g. macOS Keychain, libsecret/gnome-keyring). #### File store backend (`~/.dfos/keys/`) When the keychain is unavailable, each key is written to its own file under `~/.dfos/keys/`, named after the account (`#` and `:` replaced with path-safe characters). **The file contains the hex-encoded 32-byte Ed25519 seed in plaintext — it is not encrypted.** The directory is created `0700` and each key file `0600` (owner read/write only), so the protection is filesystem permissions and nothing more. Threat model for the file store: - A seed file grants full signing authority for that key to anyone who can read it. Treat `~/.dfos/keys/` like an SSH private key directory. - There is no passphrase, no encryption at rest, and no hardware backing. Disk theft, a permissive backup, a synced home directory, or root on the box all expose the seeds. - If you need encryption at rest, run on a host with a working OS keychain (the default path) or place `~/.dfos/keys/` on an encrypted volume. During identity genesis (before the DID is known), keys are stored under a temporary account (`pending:`) and renamed after the DID is derived from the genesis CID — this happens in whichever backend is active. The CLI discovers which keys belong to which identity by querying the identity's chain state (from local store or relay) and checking which keys have private material in the active backend. ### Security Properties - Private keys are loaded into memory only during signing operations - With the keychain backend, seeds are held by the OS keychain; with the file store backend, seeds are written **unencrypted** to `~/.dfos/keys/` at mode `0600` (see threat model above) - `identity keys` shows key presence/absence, never key material - After key rotation, old keys remain in the active backend (needed for historical chain re-verification) but are no longer used for new operations --- ## Local-First Workflow The default mode is local. Operations are signed and stored in `~/.dfos/relay.db` without network access. Publishing to relays is explicit. ### Create-Then-Publish ```bash # create identity (local only) dfos identity create --name alice # → keys stored in keychain, genesis stored in ~/.dfos/relay.db # create content (local only) dfos content create post.json # → blob and chain stored in ~/.dfos/relay.db # publish when ready dfos identity publish alice --peer local dfos content publish --peer local ``` ### Direct-to-Relay If `--peer` is present on create commands, the CLI creates and publishes in one step: ```bash dfos identity create --name alice --peer local dfos content create post.json --peer local ``` ### Smart Dependency Resolution If you create content with `--peer` but the identity hasn't been published to that relay, the CLI detects the dependency and auto-publishes the identity chain before submitting the content. --- ## Multi-Device Identities (1-of-N) An identity can hold up to 256 controller keys and 256 auth keys. **Any one current key in a role set can sign** — so the same identity can act from multiple devices, each holding its own key. This is _availability_, not key recovery: with a key on more than one device, losing a single device is not loss of the identity. A surviving device can keep publishing and can even rotate out the lost key. The handoff never moves a private key. A new device generates its own keypair locally; only its **public** key crosses to a device holding a controller key, which adds it to the chain. End-to-end, adding device **B** to an identity already controlled by device **A**: ```bash # 1. On A: create the identity (already has controller + auth keys). dfos identity create --name alice --peer prod # 2. On B: get the chain locally. dfos identity fetch alice --peer prod --name alice # 3. On B: generate a device key. Prints {id, publicKeyMultibase}. # The private seed stays on B; nothing secret is printed. dfos identity device-pubkey # ID: key_... # Public key: z6Mk... # 4. Hand the id + public key to A (copy/paste, QR, air-gap — public only). # 5. On A: add B's public key, signed with A's held controller key. dfos identity add-key --auth-key --id key_... --pubkey z6Mk... --peer prod # 6. On B: re-fetch so B sees its now-in-chain key. dfos identity fetch alice --peer prod # B can now publish content / credentials independently, signing with its # own key. dfos content create post.json --peer prod ``` Notes: - **`device-pubkey` defaults to the auth role**, which is sufficient for publishing content and credentials. Pass `--controller` only to print a controller-role hint; granting a controller key is a higher-trust act (a controller can rotate, delete, and add further keys), and the role is ultimately decided by A's `add-key` flags (`--auth-key` vs `--controller-key`), not by B. - **B must re-fetch after A's `add-key` propagates.** Between `device-pubkey` and that re-fetch, B holds a private key that is not yet in the published set, so a publish attempt will report "no held auth key" until B syncs. - This is set up _in advance_. There is no way to add a key after every device key is lost — `add-key` itself must be signed by a held controller key. ## Local Relay The CLI stores all chain data in a SQLite database at `~/.dfos/relay.db`. This is the same relay implementation that powers network relays via `dfos serve` — the CLI just runs it embedded, without HTTP. Identity chains, content chains, operations, countersignatures, and blobs all live in this single database. Local metadata (identity names, publish state) is tracked in `config.toml`. ### Fetching Remote Chains The CLI can download and store any chain from any relay, without owning the private keys: ```bash dfos identity fetch did:dfos:xxx --peer prod --name carol dfos content fetch abc123 --peer prod ``` Fetched identities appear in `identity list` with `KEYS 0/N` — visible public keys but no private material in the keychain. This enables local verification, credential checking, and countersigning against remote identities. --- ## Content Create Content creation accepts any JSON document. The CLI enforces one convention: documents should have a `$schema` field pointing to a content model schema. ```bash # from file dfos content create post.json # from stdin echo '{"$schema":"...","body":"hello"}' | dfos content create - # from heredoc dfos content create - <<'EOF' {"$schema":"https://schemas.dfos.com/post/v1","format":"short-post","body":"hello"} EOF ``` If the document has no `$schema` field, the CLI warns but proceeds. The relay is document-agnostic — schema enforcement is a client-side convention, not a protocol rule. --- ## Credentials The CLI issues DFOS credentials for content access control. `dfos cred` is an alias for `dfos credential`. ```bash # grant read access dfos credential grant --read # grant write access (allows extending the content chain) dfos credential grant --write # with custom TTL dfos credential grant --read --ttl 1h # wildcard credential covering all content dfos credential grant --read --broad # scope to a specific content ID (different from the positional arg) dfos credential grant --read --scope # revoke a credential dfos credential revoke # revoke and push to a peer immediately dfos credential revoke --peer prod ``` Credentials are printed to stdout (or as JSON with `--json`). The recipient passes them to relay endpoints via the `X-Credential` header, or to the CLI via `--credential` (reads) or `--authorization` (writes): ```bash # present a read credential for downloads dfos content download --credential --peer local # present a write credential for delegated mutations dfos --ctx bob@prod content update new.json --authorization ``` Credential transport is out-of-band — the CLI mints and consumes them, but doesn't transmit them between parties. --- ## Discovery Services An identity can publish a **services** set — an additive discovery vocabulary carried in its chain state. Services are full-state on every `identity create` / `identity update`: an update **replaces** the entire set, and an unspecified set is carried forward unchanged. Each entry has a common `{id, type}` envelope; the namespace is **open**, so unrecognized types are preserved verbatim and ignored by the core. Two types are structurally recognized: - **`DfosRelay`** — `{id, type, endpoint}`, a transport endpoint where this identity's chains can be fetched. - **`ContentAnchor`** — `{id, type, label, anchor}`, a stable pointer to a content chain (31-char content id) or an artifact (CIDv1 `baf…`), addressable by `label` (e.g. `profile`, `avatar`). Bounds (enforced at sign time by the protocol layer): at most 256 entries, unique ids, non-empty `id`/`type`, and a 32768-byte cap on the encoded services array. Individual field lengths are not separately capped — the aggregate byte cap is the single bound. ```bash # attach services at genesis dfos identity create --name alice \ --service id=relay,type=DfosRelay,endpoint=https://relay.dfos.com \ --service id=profile,type=ContentAnchor,label=profile,anchor=cv7n8vkvr64cctf3294h9k4eanhff8z # replace the entire set on update (also rotate keys in the same op if you like) dfos identity update \ --service id=relay,type=DfosRelay,endpoint=https://relay.dfos.com # open namespace: any type, carried through verbatim dfos identity update --service id=site,type=Website,url=https://alice.example # empty the set dfos identity update --clear-services # view the resolved set dfos identity services alice dfos identity services alice --json ``` Each `--service` spec is a comma-separated `key=value` list; `id` and `type` are required, every value is a string. --- ## Solemnization (Witness) `witness` countersigns an operation by CID — a collective endorsement that solemnizes it. This is the protocol's only inter-subjective primitive: a separate identity attesting to someone else's operation. An optional `--relation` tags the nature of the endorsement (open namespace, 1..64 chars). There is no withdrawal primitive — a countersignature is a standing attestation. ```bash # plain endorsement dfos witness --peer prod # tagged with a relation dfos witness --relation endorses --peer prod dfos witness --relation coauthors --peer prod # inspect countersignatures on an operation dfos countersigs ``` --- ## Verification `content verify` re-verifies a chain's integrity locally — re-derives all CIDs, re-checks all Ed25519 signatures, and optionally verifies blob integrity. Zero trust in the relay. ```bash dfos content verify ``` This catches relay corruption, data tampering, and implementation bugs (including the CBOR number encoding trap — see PROTOCOL.md § Number Encoding). --- ## Raw API Access `dfos api` is the escape hatch for agents and power users — raw HTTP to the relay with automatic auth token injection: ```bash # unauthenticated dfos api GET /.well-known/dfos-relay dfos api GET /proof/v1/identities/did:dfos:xxx # with auto auth (mints a fresh JWT, injects Authorization header) dfos api GET /content/abc123/blob --auth # POST with body dfos api POST /proof/v1/operations --body '{"operations":["eyJ..."]}' # custom headers dfos api PUT /content/abc123/blob/bafyop... --auth -H "Content-Type: application/octet-stream" --body-file doc.bin # response headers dfos api GET /proof/v1/identities/did:dfos:xxx -i ``` The `--auth` flag resolves the active identity, loads the auth key from the keychain, fetches the relay's DID from well-known, mints a short-lived JWT, and injects it. One flag replaces the entire auth token lifecycle. --- ## Environment Variables | Variable | Purpose | | ---------------------- | ------------------------------------------------- | | `DFOS_CONTEXT` | Override active context (`identity@relay`) | | `DFOS_IDENTITY` | Override active identity name | | `DFOS_RELAY` | Override active relay name | | `DFOS_CONFIG` | Config file path (default: `~/.dfos/config.toml`) | | `DFOS_NO_KEYCHAIN` | Skip OS keychain; use file store `~/.dfos/keys/` | | `DFOS_NO_UPDATE_CHECK` | Disable automatic version update checks | | `DFOS_DEBUG` | Debug logging (HTTP traffic, key resolution) | --- ## Commands | Method | Command | Description | | ------ | ------------------------------- | -------------------------------------------------------- | | `GET` | `identity list` | List all known identities (owned + fetched) | | `GET` | `identity show [name\|did]` | Show identity state | | `GET` | `identity keys [name\|did]` | Show key state + keychain availability | | `GET` | `identity services [name\|did]` | Show resolved discovery services | | `POST` | `identity create --name` | Generate keys + sign genesis (`--service`) | | `POST` | `identity update` | Rotate keys / set services (`--service`) | | `POST` | `identity device-pubkey` | Generate a device keypair, print its pubkey | | `POST` | `identity add-key` | Add another device's pubkey (1-of-N) | | `POST` | `identity delete` | Permanently delete identity | | `POST` | `identity publish [name\|did]` | Submit identity chain to a relay | | `GET` | `identity fetch ` | Download identity chain from relay | | `GET` | `identity log ` | Show identity operation history | | `DEL` | `identity remove ` | Drop an identity name from config (data stays in relay) | | `GET` | `content show ` | Show content chain state | | `GET` | `content log ` | Show operation history | | `GET` | `content download ` | Download blob (stdout or file) | | `POST` | `content create ` | Create content chain | | `POST` | `content update ` | Update content chain (supports delegation) | | `POST` | `content delete ` | Permanently delete content chain | | `POST` | `content publish ` | Submit content chain + blob to a relay | | `GET` | `content fetch ` | Download content chain from relay | | `GET` | `content list` | List locally stored content chains | | `POST` | `credential grant ` | Issue read/write credential | | `POST` | `credential revoke ` | Revoke a credential | | `GET` | `content verify ` | Re-verify chain integrity locally | | `POST` | `witness ` | Countersign an operation (`--relation`) | | `GET` | `countersigs ` | Show countersignatures for an operation | | `GET` | `operation show ` | Inspect a protocol operation | | `GET` | `auth token` | Mint short-lived auth token (stdout) | | `GET` | `auth status` | Show current auth state | | `*` | `api ` | Raw HTTP to relay with optional `--auth` | | `GET` | `peer list` | List configured relays (alias: `relay`) | | `GET` | `peer info [name]` | Show relay metadata | | `POST` | `peer add ` | Register a named relay | | `DEL` | `peer remove ` | Unregister a relay | | `SET` | `use ` | Set active context | | `GET` | `config list` | Show full configuration | | `GET` | `config get ` | Read a single config value | | `SET` | `config set ` | Write a config value | | `GET` | `status` | At-a-glance overview | | `POST` | `sync` | Sync with all configured relays | | `*` | `serve` | Run the local relay as an HTTP server | | `*` | `skill print` / `skill install` | Print or install the DFOS Claude Code skill (`--global`) | --- ## What's Deferred - **Schema validation**: validate documents against bundled JSON schemas (currently warns on missing `$schema` only) - **Key backup/recovery**: mnemonic seed phrases or encrypted export - **Shell completion docs**: `dfos completion ` ships today (cobra-generated); dedicated setup docs are still pending - **Batch refresh** (`identity fetch --all`): re-fetch all tracked remote identities --- # Frequently Asked Questions ## What is the DFOS Protocol? An open protocol for cryptographic identity and verifiable content. Ed25519 signed chains establish identity, commit content, and produce proofs that anyone can verify — offline, in any language, from any source. No platform, no blockchain, no trust assumption. Chains are directed acyclic graphs (DAGs) that converge deterministically across implementations without consensus. ## What problem does it solve? Your identity and content are rented back to you by the platforms that own them. If a service shuts down or locks your account, everything disappears. The DFOS Protocol makes identity and content provenance self-sovereign — derived from cryptographic keys you control, verifiable by anyone with your public key and any standard EdDSA library. ## What does "dark forest" mean in this context? The internet is a dark forest — most meaningful creative and social activity happens in access-controlled spaces, not on the public web. DFOS is designed for this reality. Content lives in member-governed spaces, undisclosed by default and served only to participants. The cryptographic proof layer is the only public surface: signed commitments to content hashes that anyone can verify without being given the content. The proof is public; the content is access-controlled. Note that the protocol does not encrypt — it commits to an unsalted CID — so the "verify without revealing" property only holds for high-entropy content (low-entropy documents can be confirmed by recomputing the CID), and confidentiality is enforced at the application layer by whoever serves the space, not by end-to-end encryption. ## How do chains handle forks and conflicts? Chains are DAGs, not linear sequences. Forks are valid — two operations referencing the same predecessor both get accepted. All implementations converge to the same head via a deterministic rule: highest createdAt timestamp among tips, with lexicographic CID as tiebreaker. Given the same set of operations, any relay computes the same head regardless of ingestion order. This is convergence without consensus — no coordination protocol, no leader election, no global ordering. ## How does the relay network work? Web relays are verifying HTTP endpoints that store and serve chains. Every relay independently verifies every operation on ingestion — there is no trust relationship between relays. Three peering behaviors compose to form the network: gossip (push new operations to peers), read-through (fetch from peers on cache miss), and sync (periodic pull via cursor-based polling). There are no relay roles or hierarchy. Topology is emergent from per-peer configuration. Relays also expose a universal did:dfos resolver at GET /1.0/identifiers/:did that returns a W3C DID document — a convenience layer over the proof plane, not a dependency, since resolution remains fully derivable from the chain itself. ## Do I need to run a server or connect to a network? No. Verification is offline and self-contained. A signed chain carries everything needed to verify it — public keys, signatures, content-addressed hashes. There is no registry to query, no blockchain to sync, no API to call. Given a chain and a public key, any standard Ed25519 library in any language can verify it. Relays are useful for storage and distribution, but verification never depends on them. ## What languages are supported? The reference implementation is in TypeScript (available as @metalabel/dfos-protocol on npm). Cross-language verification implementations exist in Go, Python, Rust, and Swift — all verifying the same deterministic test vectors from the protocol specification. The CLI is written in Go with pre-built binaries for Linux, macOS, and Windows — installable via Homebrew, curl, or Docker. ## How is this different from blockchain-based identity? Blockchain identity systems anchor trust in a shared ledger — you need to sync with or query the chain to verify identity. The DFOS Protocol anchors trust in cryptographic signatures alone. There is no consensus layer, no gas fees, no chain state to maintain. Verification is a pure function: public key + signed chain = valid or invalid. Forks converge deterministically without coordination. This makes it simpler, faster, and fully transport-agnostic. ## How does this compare to AT Protocol (Bluesky)? AT Protocol and DFOS Protocol share foundations — self-sovereign identity, signed data, content-addressed storage, DIDs — but differ in topology. AT Protocol is public-by-default: your data repository is a public document, posts are visible to the network, and federation relays ingest content openly. The DFOS Protocol inverts this. Content is access-controlled — it lives in member-governed spaces, undisclosed by default and served only to participants. The cryptographic proof layer is the only public surface. This is an architectural choice, not a privacy setting (the protocol does not encrypt; confidentiality is enforced by whoever serves the space). Identity resolution also differs: an AT Protocol did:plc is resolved through plc.directory, a registry Bluesky operates, whereas a did:dfos derives from its genesis operation and needs no external directory to resolve. AT Protocol is also a full social networking protocol (federation, data repositories, application schemas); the DFOS Protocol is narrower by design — cryptographic primitives only, agnostic to transport, federation, and application semantics. ## How do identity chains relate to DIDs? Every identity chain is also a DID. The DID (did:dfos:) is derived deterministically from the hash of the chain's genesis operation — making it self-certifying. Given the chain, anyone can verify that it belongs to the claimed DID without trusting the source. The DID method specification defines how did:dfos identifiers conform to the W3C DID standard. ## Is the protocol coupled to the DFOS platform? No. The protocol is independent. DFOS (the platform) is one implementation, but any system that implements the same chain primitives produces interoperable, cross-verifiable proofs. An identity created on one system can sign content on another. The protocol is MIT-licensed open source. ## Is this production-ready? The protocol's v1 surface is feature-complete and frozen: the core wire is settled and will not change in shape, while the reference packages remain on their own 0.x release line. The TypeScript reference implementation is published and tested, with deterministic test vectors verified across five languages. The CLI ships pre-built binaries for 6 platforms via Homebrew, Docker, and direct download. The DFOS platform runs on this protocol in production. The specification has not been submitted to any formal standards body. ## Where can I discuss the protocol? The specification is open source on GitHub (metalabel/dfos). Protocol discussion happens in the DFOS space.