Architecture Decisions
This is the canonical log of xNet’s architectural decisions. It is the one place
to answer “why is it built this way?” — superseding the older internal
docs/TRADEOFFS.md.
How we keep this current
Section titled “How we keep this current”These records follow the standard ADR discipline:
- Every entry has a Status:
Proposed,Accepted,Deprecated, orSuperseded by ADR-NN. - Accepted ADRs are immutable. We don’t rewrite a decision when reality
changes — we add a new ADR that supersedes the old one and set the old
entry’s status to
Superseded by ADR-NN. The truth of the architecture is the whole chain, not the latest entry. (Typo and dead-link fixes are the only in-place edits.) - If an implemented change alters an architectural invariant, it adds or
supersedes an ADR in the same PR. Most decisions are worked out first in
docs/explorations/; this page is the distilled outcome.
ADR template
## ADR-NN: <Short decision title>
**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-MM**Context:** <the exploration(s) or forces that drove this, e.g. 0212>
**Decision:** <one sentence>
**Rationale:**
- <driver 1>- <driver 2>
**Tradeoff:** <what we gave up, and the mitigation>ADR-1: Yjs over Automerge for rich text
Section titled “ADR-1: Yjs over Automerge for rich text”Status: Accepted (2026-02) · scope clarified by ADR-11
Decision: Use Yjs for rich text CRDT, not Automerge.
Rationale:
- Yjs has a larger ecosystem (TipTap, ProseMirror, CodeMirror, Monaco integrations)
- Better performance for large documents (Yjs encodes more compactly)
- Mature awareness protocol for cursor presence
- Active maintenance and wide adoption
Tradeoff: Yjs is a mutable data structure (not pure-functional like Automerge). We mitigate this with the MetaBridge pattern — Yjs never writes to the NodeStore directly.
Scope note: Yjs is the document codec for the rich-text/canvas body of
certain nodes — it is not the sync backbone. Structured node data syncs through
the signed Change<T> log (ADR-3),
and Yjs updates ride the wire as opaque bytes inside an XNet envelope. See
ADR-11.
ADR-2: DID:key for identity
Section titled “ADR-2: DID:key for identity”Status: Accepted (2026-02)
Decision: Use did:key (Ed25519) as the identity format.
Rationale:
- Self-certifying — the public key is embedded in the DID, so verification requires no external resolver
- No blockchain, registry, or server dependency
- Small identifiers (~56 characters)
- Ed25519 is widely supported and audited
Tradeoff: DIDs are not human-readable. Display names are stored as profile metadata, not in the identifier itself. Key rotation requires creating a new DID (though UCAN delegation can bridge old and new identities). Account recovery is handled by social recovery — see ADR-24.
ADR-3: Field-level LWW for structured data
Section titled “ADR-3: Field-level LWW for structured data”Status: Accepted (2026-02) · this is normative protocol L1 — see ADR-11
Decision: Use Lamport clocks with field-level last-writer-wins for structured data, not a CRDT map.
Rationale:
- Simpler than a full CRDT for property data (titles, statuses, numbers)
- Field-level granularity means non-conflicting fields always merge cleanly
- Deterministic tie-breaking (DID comparison) ensures all peers converge
- Lower overhead than maintaining CRDT metadata per field
Tradeoff: True concurrent edits to the same field result in one write being silently dropped. In practice, this is acceptable for structured properties — users rarely edit the same title simultaneously. For rich text where character-level merging matters, Yjs handles it.
ADR-4: BLAKE3 over SHA-256
Section titled “ADR-4: BLAKE3 over SHA-256”Status: Accepted (2026-02) · post-quantum posture in ADR-25
Decision: Use BLAKE3 as the primary hash function.
Rationale:
- 3-5x faster than SHA-256 on modern hardware
- 256-bit output suitable for content addressing
- Parallelizable (tree hashing) for large inputs
- No length extension attacks
Tradeoff: SHA-256 has wider interoperability (most external systems expect it). We provide SHA-256 as a fallback option in @xnetjs/crypto for interop cases.
ADR-5: Named exports only
Section titled “ADR-5: Named exports only”Status: Accepted (2026-02)
Decision: No default exports anywhere in the codebase.
Rationale:
- Eliminates the “what did I import?” ambiguity
- Better tree-shaking in bundlers
- Consistent import style across the monorepo
- Easier to search for usages (grep for the exact name)
ADR-6: Factory functions alongside classes
Section titled “ADR-6: Factory functions alongside classes”Status: Accepted (2026-02)
Decision: Export createFoo() factory functions alongside class Foo.
Rationale:
- Factory functions are easier to mock in tests
- They can perform validation before construction
- Consistent API surface (
createLamportClock,createExtensionContext,createLocalAPI) - Classes are still available for
instanceofchecks when needed
ADR-7: Validation returns, not exceptions
Section titled “ADR-7: Validation returns, not exceptions”Status: Accepted (2026-02)
Decision: Validation functions return { valid: boolean, errors: string[] } instead of throwing.
Rationale:
- Callers can inspect and aggregate errors
- No try-catch boilerplate for expected failures
- Works well with UI form validation
- Exceptions are reserved for programmer errors (missing arguments, broken invariants)
ADR-8: One-way MetaBridge
Section titled “ADR-8: One-way MetaBridge”Status: Accepted (2026-02) · MetaBridge now ships in @xnetjs/runtime — see ADR-14
Decision: The MetaBridge syncs NodeStore → Y.Doc metadata, but not the reverse.
Rationale:
- Prevents malicious Yjs updates from poisoning structured data
- Property changes always go through the signed
Change<T>pipeline with verification - Yjs metadata map is read-only from the editor’s perspective
- Clear security boundary between the two CRDT systems
Tradeoff: Two-way sync would be simpler to implement. The one-way bridge requires property writes to go through mutate() even when the data is displayed in the editor context.
ADR-9: Multiplexed WebSocket
Section titled “ADR-9: Multiplexed WebSocket”Status: Accepted (2026-02) · extended to multi-home routing in ADR-20
Decision: Use a single WebSocket connection with room-based pub/sub, not one connection per document.
Rationale:
- O(1) connections regardless of document count
- Reduces server-side resource usage
- Simpler reconnection logic (one connection to restore)
- Lower latency (no connection setup per document)
Tradeoff: Requires a room routing layer on top of raw WebSocket. The signaling server must track topic subscriptions.
ADR-10: BSM in Electron main process
Section titled “ADR-10: BSM in Electron main process”Status: Accepted (2026-02)
Decision: Run the sync engine in Electron’s main process, not the renderer.
Rationale:
- Keeps the renderer responsive (BLAKE3 + Ed25519 are CPU-intensive)
- Survives renderer crashes
- Can sync in the background when no window is open
- MessagePort provides zero-copy binary transfer between processes
Tradeoff: IPC overhead for every document update. Mitigated by using MessageChannelMain for binary transfer and batching updates. (A migration from Electron to a Deno-based desktop shell has been explored but not adopted.)
The decisions below were made after the initial ten were written, as the system grew from an app into a protocol with a managed cloud. They are recorded here so the log reflects the architecture as it actually stands.
ADR-11: XNet is a protocol — the interop kernel is the change log, not Yjs
Section titled “ADR-11: XNet is a protocol — the interop kernel is the change log, not Yjs”Status: Accepted (2026-06)
Context: Exploration 0200. The normative spec lives in docs/specs/protocol/; see also The Protocol.
Decision: Treat XNet as a versioned, multi-implementation protocol whose
normative surface is four layers — L0 cryptographic primitives, L1 data
model, L2 replication, L3 authorization — with the application profile
(L4) explicitly non-normative. The interop kernel is a signed,
hash-chained, per-property-LWW change log over schema-typed nodes
(CURRENT_PROTOCOL_VERSION = 3 in packages/sync/src/change.ts). Yjs is a
pluggable document codec for the rich-text/canvas body of certain nodes and
travels the wire as opaque bytes inside an XNet envelope.
Rationale:
- A second implementation, in any language over any database, can forward and store the Yjs blob as an octet string and still fully participate in the node graph, identity, authorization, and replication — the hardest local-first portability problem (porting a CRDT byte format across languages) is off the critical path.
- The boundaries already existed in the layered package graph; this decision writes them down and freezes them behind a conformance corpus.
Tradeoff: Two merge systems (the LWW log and the Yjs CRDT) instead of one. Accepted deliberately — they have different conflict semantics and scale profiles (see ADR-1 and ADR-3). This ADR clarifies ADR-1 and elevates ADR-3 to normative status.
ADR-12: SQLite everywhere — better-sqlite3 + Litestream, not Turso/libSQL
Section titled “ADR-12: SQLite everywhere — better-sqlite3 + Litestream, not Turso/libSQL”Status: Accepted (2026-06) Context: Explorations 0178 (hosting economics) and 0212 (engine merits).
Decision: Keep SQLite as the durable storage substrate across all five
runtimes behind one adapter (@xnetjs/sqlite). On the web, persist via
OPFS + SQLite WASM in a single serial worker; on the hub, use
better-sqlite3 with Litestream streaming replication to Cloudflare R2
(packages/hub/src/storage/litestream.ts). Do not migrate to libSQL or
Turso Database.
Rationale:
- xNet barely uses SQLite as a relational database — the schema is an append-only, signed, hash-chained change log with a denormalized scalar index; graph walks and CRDT merges happen in TypeScript/Rust, not SQL. Most of Turso’s advances (query planner, relational concurrency) are low-leverage here.
- The two features that would tempt us (embedded vector ANN, concurrent writes) live in two different Turso products, neither production-ready together today; migrating would cost five platform adapters and an FTS5 rewrite.
- FTS5 and R-Tree/GIS (which we do use) are battle-tested on SQLite.
Tradeoff: Single-writer serialization and no built-in vector index. Documented
re-evaluation triggers (a production-ready engine with both ANN and MVCC, or a
vector workload SQLite can’t serve) would reopen this. A bounded Turso pilot on
the disposable telemetry.db is sanctioned as a probe.
ADR-13: Local-first — the hub is an accelerant, never a dependency
Section titled “ADR-13: Local-first — the hub is an accelerant, never a dependency”Status: Accepted (2026-06) Context: Exploration 0188. See also Local-first.
Decision: The local device holds the master copy. Nothing on a hub or the network may block a local read or first paint; hub work (relay, backup, search, signaling) is fire-and-forget and strictly additive.
Rationale:
- Local-first is the product’s core promise (own your data, work offline, exit freely) — it must be an enforced invariant, not an aspiration.
- A regression where hub room-subscription blocked document load caused 10–30s stalls; the fix (decouple local load from hub acquisition) codified the rule.
Tradeoff: Some cross-device features are eventually-consistent rather than instantly authoritative. Accepted: correctness comes from the convergent LWW log, so “eventually” is safe.
ADR-14: Framework-agnostic runtime — React is a thin binding
Section titled “ADR-14: Framework-agnostic runtime — React is a thin binding”Status: Accepted (2026-06) Context: Explorations 0185 and 0237.
Decision: The full client — store, MetaBridge, SyncManager, NodePool,
Registry, OfflineQueue, ConnectionManager — lives in the React-free
@xnetjs/runtime package. createXNetClient() and liveQuery() expose a
universal { getSnapshot, subscribe } contract. @xnetjs/react is a thin T1
binding (useQuery/useMutate/…) that re-exports the runtime. Support tiers:
T0 any framework/vanilla (the runtime, ships today), T1 React
(first-party), T2 Vue/Svelte/Solid (demand-gated, ~40-line adapters).
Rationale:
- The reactive seam is tiny — only two files in
@xnetjs/reacttouchuseSyncExternalStore; everything else is business logic and JSX. - Decoupling lets the runtime power CLIs, servers, and non-React apps without a React dependency.
Tradeoff: “Support” for a framework means feature parity, not just a data
binding — the ~30k LOC of @xnetjs/react is the real cost. So T2 adapters are
published only on demand, and only the data hooks (not @xnetjs/ui) are in scope.
ADR-15: Open-core license split — MIT core, FSL cloud, MIT-private contract
Section titled “ADR-15: Open-core license split — MIT core, FSL cloud, MIT-private contract”Status: Accepted (2026-06) Context: Exploration 0181.
Decision: Publish the adoption engine (core packages) under MIT. License
the managed control plane, @xnetjs/cloud, under FSL-1.1 (source-available,
converting to Apache-2.0 on its Change Date; packages/cloud/LICENSE). Keep the
entitlement contract in a separate, permissively-licensed @xnetjs/entitlements
(MIT, private) so the self-hostable MIT hub can verify a signed HUB_PLAN
token without ever taking an FSL — or a Stripe/AWS — dependency.
Rationale:
- The hub must remain fully MIT and dependency-light; the cloud must be protectable. The entitlement contract is the one seam both sides share.
- FSL protects against a hyperscaler reselling the managed service while guaranteeing eventual open-source (the Apache future license).
Tradeoff: A three-package licensing story is more to explain than “all MIT” or
“all FSL.” The @xnetjs/hub package deliberately has zero dependency on
@xnetjs/cloud; that separation is by convention (and verified by the absence of
the import), not yet a lint rule.
ADR-16: Automated releases via Changesets + Conventional Commits
Section titled “ADR-16: Automated releases via Changesets + Conventional Commits”Status: Accepted (2026-06) Context: Exploration 0220.
Decision: Version and publish with Changesets.
A fixed group of 12 core packages (core, crypto, identity, sync,
data, storage, sqlite, data-bridge, abuse, plugins, history,
react) versions in lockstep; periphery versions independently. Conventional
Commits are enforced by commitlint; publishing uses npm Trusted Publishing (OIDC)
with provenance. Changeset coverage for publishable packages is enforced by a Stop
hook (scripts/changeset/assert-coverage.mjs).
Rationale:
- The
fixedcore avoids a combinatorial version matrix across tightly-coupled packages; independent periphery avoids forcing churn on loosely-coupled ones. - OIDC removes long-lived npm tokens from the repo.
Tradeoff: The semver bump must be read from the diff, not the commit prefix
(a removed export or changed wire contract is a major even under a feat:
commit). This requires discipline the hook can prompt for but not fully decide.
ADR-17: Plugin trust and capability model
Section titled “ADR-17: Plugin trust and capability model”Status: Accepted (2026-06) Context: Explorations 0189, 0192, 0194, 0196.
Decision: Extensibility is governed by a shared trust fabric. @xnetjs/trust
maps install provenance (builtin / authored / ai-generated / imported
/ marketplace / synced) to a trust tier and a sandbox kind
(host / ses-worker / iframe). Capability endowments are enforced at the
seam — guardStore gates data access, guardedFetch gates the network
(SSRF-guarded) in @xnetjs/plugins. Paid plugins carry Ed25519, DID-bound
license tokens (@xnetjs/licenses) with a fail-closed install gate.
Rationale:
- “Everything is a plugin” only works if untrusted code is contained by construction; provenance is the natural axis for how much to trust.
- Fail-closed licensing means a missing or invalid license blocks install, never silently downgrades.
Tradeoff: More ceremony for plugin authors (declared capabilities, sandbox constraints) in exchange for a substrate that can safely run third-party and AI-generated code.
ADR-18: Managed AI via hub broker, plus bring-your-own local models
Section titled “ADR-18: Managed AI via hub broker, plus bring-your-own local models”Status: Accepted (2026-06) Context: Explorations 0201, 0208, 0244, 0252.
Decision: For managed AI, the client uses a keyless ManagedProvider
that POSTs to the hub’s /ai/chat (+ /ai/chat/stream SSE); the hub injects
tenant credentials, holds the OpenRouter key, and meters exact usage.cost
server-side (packages/hub/src/features/ai-forwarder.ts). In parallel, a
bring-your-own path runs models locally — WebLLM in-browser and Chrome’s
built-in “Nano” via an availability() probe (packages/plugins/src/ai/).
Rationale:
- Keyless managed AI means no provider key ever reaches the client; the hub is the metering and margin-safety boundary.
- Local models give a zero-cost, fully-private tier that needs no hub at all, consistent with ADR-13.
Tradeoff: Two provider code paths to maintain, and local-model availability varies by browser/hardware. Detection is capability-probed, not assumed.
ADR-19: Plan-first AI mutations
Section titled “ADR-19: Plan-first AI mutations”Status: Accepted (2026-06)
Context: docs/AI_SURFACE_CONTRACT.md.
Decision: AI never writes directly. The shared AI surface expresses writes as
an AiMutationPlan that must move through validate → preview → apply → audit.
Every tool declares requiredScopes and a risk level (low/medium/high/
critical); plans are serializable JSON so they flow identically through MCP,
the Local API, file projection, and audit logs.
Rationale:
- A plan-first pipeline makes AI writes reviewable and reversible, and keeps one contract across every adapter.
- Risk levels + scopes let the UI gate high-consequence actions
(
storage.recoveryrequirescritical).
Tradeoff: More indirection than letting an agent call mutate() directly, in
exchange for auditable, user-approvable, rollback-friendly AI edits.
ADR-20: Namespace-scoped multi-home replication
Section titled “ADR-20: Namespace-scoped multi-home replication”Status: Accepted (2026-06) · extends ADR-9 Context: Exploration 0258.
Decision: Sync routes selectively per namespace. A multiplexed multi-hub
connection manager (packages/runtime/src/sync/connection-manager.ts) maintains
one connection per hub; a replication-policy planner
(packages/sync/src/replication-policy.ts) decides which namespaces replicate to
which hubs/peers, with per-namespace minHubs/maxHubs and signed-replication
requirements. A user-facing Space maps to a namespace via spaceNamespace(),
carrying a ReplicaTrust of trusted or zero-knowledge
(packages/runtime/src/sync/replication-scope.ts).
Rationale:
- Different data belongs in different homes (system vs personal vs community vs peer); routing by namespace is the smallest primitive that expresses that.
- Correctness is free — the multi-master LWW log already converges regardless of topology; this decision is about where bytes go, not whether they merge.
Tradeoff: Only the routing slice is shipped; the manifest schema, device DID, anti-entropy, trust gating, and UI are deferred. The planner exists ahead of full consumption.
ADR-21: Append-only change log with off-boot compaction
Section titled “ADR-21: Append-only change log with off-boot compaction”Status: Accepted (2026-06) Context: Explorations 0204, 0254, 0260.
Decision: Never delete from the change log; append tombstones and materialize
current state. Reclaim space with compaction that runs off the boot path —
gated behind runWhenBootSettled (resolves at first-rows), using tiny
loop-until-dry chunks that bail on a hidden tab
(apps/web/src/lib/change-log-compaction.ts). The web runtime funnels all SQLite
work through one serial worker with a priority scheduler.
Rationale:
- The append-only log makes sync trivial and gives a complete audit trail; but an unbounded log inflates cold-start.
- On a single serial SQLite worker, no background work is free during boot —
requestIdleCallbacktracks main-thread idle, which is idle precisely while the worker is saturated. Scheduling maintenance after boot settles is the only reliable fix; priority reorders work, it never parallelizes it.
Tradeoff: Reclamation is deferred rather than immediate, and compaction is
best-effort (bails on backgrounded tabs). Accepted: a responsive cold open
outranks prompt disk reclaim. Compaction stays default-on, opt-out via
xnet:compact:changes='off'.
ADR-22: Consent-gated observability
Section titled “ADR-22: Consent-gated observability”Status: Accepted (2026-06) Context: Exploration 0210.
Decision: Zero telemetry by default. Error monitoring (Sentry) and analytics
(Plausible) initialize only when both a build-time endpoint is configured
(VITE_SENTRY_DSN / VITE_ANALYTICS_DOMAIN) and the runtime consent tier
allows it (packages/telemetry/src/consent/manager.ts;
apps/web/src/lib/{sentry,analytics,error-reporter}.ts). Reports are scrubbed and
values are P3A-bucketed.
Rationale:
- Decentralized, own-your-data software must not phone home without explicit, granular consent; gating at both build and runtime makes “off” the safe default.
Tradeoff: Far less diagnostic data (users rarely opt in), so we lean on reproducible boot timelines and local forensics instead of always-on telemetry.
ADR-23: Values-as-code CI gates
Section titled “ADR-23: Values-as-code CI gates”Status: Accepted (2026-06) Context: Explorations 0199 (motion) and 0234 (humane charter).
Decision: Encode the product’s values as enforced lints, not just prose. A
motion-vocabulary gate (scripts/check-motion-vocab.mjs) bans ad-hoc animation
outside the approved vocabulary; a humane-patterns gate
(scripts/check-humane-patterns.mjs) bans dark patterns (infinite scroll,
streaks, engagement-maximizing nudges). Both sit alongside the Humane Internet
Charter (docs/CHARTER.md) and a real “Right to Leave” export-everything panel.
Rationale:
- A stated value that CI can’t defend drifts. Mechanizing “calm” and “no dark patterns” makes them regression-proof and legible to skeptics.
Tradeoff: Some legitimate UI needs a documented exception path around the gates. Accepted: the friction is the point — it forces the intent to be explicit.
ADR-24: Social recovery via Shamir guardians
Section titled “ADR-24: Social recovery via Shamir guardians”Status: Accepted (2026-06) Context: Exploration 0243.
Decision: Recover an identity through Shamir-split guardian shares
(packages/identity/src/recoverable.ts) — a threshold of trusted guardians
reconstructs the key — alongside recovery phrases and synced passkeys. The cloud
is never a custodian. Custodial escrow was explicitly declined.
Rationale:
did:keyhas no built-in rotation (ADR-2), so recovery must live outside the key itself.- Custodial escrow is coercible (subpoena, insider, breach); social recovery keeps trust distributed and cloud-free.
Tradeoff: Recovery depends on the user having designated reachable guardians ahead of time. Accepted as the price of a non-coercible, self-sovereign model.
ADR-25: Post-quantum migration posture
Section titled “ADR-25: Post-quantum migration posture”Status: Proposed Context: Exploration 0257; the future path for ADR-4 and ADR-2.
Decision (proposed): Adopt a phased migration to post-quantum signatures — Phase 2 hybrid (Ed25519 + ML-DSA), Phase 3 ML-DSA primary with Ed25519 legacy — behind an algorithm-agility seam, rather than a flag-day switch.
Rationale:
- Ed25519 is broken by Shor’s algorithm; a hash-chained, signed log needs a credible PQ story before it’s a compliance blocker.
- Hybrid signatures preserve interop during the transition.
Tradeoff / open: Larger signatures and a two-algorithm verification path. This
is a documented posture, not shipped code — it stays Proposed until the seam
and hybrid signing land, at which point it is superseded by an Accepted ADR.