Data Model (L1)
This is the heart of the protocol. Get it right and your implementation can
participate in the node graph. The normative text is
02-data-model.md.
The Node
Section titled “The Node”Every node, in every implementation, has exactly four universal fields; all others are schema‑defined:
interface Node { id: string // opaque, collision-resistant (nanoid in the reference impl) schemaId: SchemaIRI // "xnet://authority/Name@version" createdAt: number // Unix ms, set on first change createdBy: DID // did:key of the creator — immutable [key: string]: unknown // schema-defined properties}createdBy is the root of a node’s provenance and never changes. Nodes are
soft‑deleted (tombstoned), never hard‑deleted.
Schema references
Section titled “Schema references”SchemaIRI = "xnet://" authority "/" Name ( "@" semver )?The authority names who governs the schema — xnet.fyi for built‑ins, a domain
for organizations, or a DID for personal schemas. Nodes are self‑describing via
schemaId, so relays and stores can process them without resolving the schema.
Schemas are JSON‑Schema‑shaped — not JSON‑LD/RDF (a deliberate choice; full
JSON‑LD is the reason ActivityPub implementations diverge).
The Change
Section titled “The Change”A node is never mutated in place. Its state is the fold of an append‑only log of signed Change records:
interface Change { protocolVersion: 3 id: string type: 'node-change' payload: { nodeId, schemaId?, properties, deleted? } // sparse hash: string // "cid:blake3:<hex>" parentHash: string | null // the causal hash chain authorDID: DID signature: Uint8Array // Ed25519 over UTF-8(hash) wallTime: number lamport: number // ordering / LWW}The first change for a node carries schemaId; later changes carry only the
properties that changed.
The byte‑exact contract
Section titled “The byte‑exact contract”- Take the unsigned change (all fields except
hash/signature). - Canonical JSON: sort object keys lexicographically and recursively, no
whitespace, arrays in order,
undefinedomitted, UTF‑8 bytes. - Hash:
"cid:blake3:" + hex(BLAKE3(bytes)). - Sign:
Ed25519(UTF8(hash_string), key).
const sorted = sortKeysRecursively(unsigned) // recursive key sortconst bytes = utf8(JSON.stringify(sorted)) // compactconst hash = "cid:blake3:" + hex(blake3(bytes))const sig = ed25519.sign(utf8(hash), signingKey) // sign the STRING bytesThis exact transform is pinned by the golden vectors, so any implementation can check itself byte‑for‑byte.
Conflict resolution: Lamport + LWW
Section titled “Conflict resolution: Lamport + LWW”Materialized state keeps a { lamport, wallTime } timestamp per property.
When two changes touch the same property:
- higher
lamportwins; - tie → higher
wallTime; - tie → higher
authorDID(lexicographic) — a deterministic final tiebreak so all peers converge identically.
Because LWW is per‑property, concurrent edits to different properties of the same node both survive.
The document codec (where Yjs lives — and why it’s opaque)
Section titled “The document codec (where Yjs lives — and why it’s opaque)”A schema may declare a collaborative body with document: 'yjs'. Such nodes
carry a documentContent blob identified by a documentCodec discriminator
(yjs-v1, automerge-2, none). Implementations must transport and persist
the blob faithfully, but interpreting it is optional — a peer that can’t parse
yjs-v1 still fully participates in the graph. This is the seam that makes XNet
portable without porting a CRDT byte format across languages.
Next: Replication (L2) →