Skip to content

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.

These records follow the standard ADR discipline:

  • Every entry has a Status: Proposed, Accepted, Deprecated, or Superseded 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 fixes, dead-link fixes, and adding a Tripwire are the only in-place edits.)
  • A one-way decision carries a Tripwire: the observation that re-opens it. It is additive metadata, not part of the decision — it says nothing about what was chosen, only what would make us write the superseding ADR. Without one, a decision decays into a taboo: the rationale ages, the conditions that produced it change, and because nobody wrote down what would count as evidence against, the entry stops being re-openable and becomes a rule nobody remembers the reason for. This is the same job review: does for docs/explorations/ (exploration 0421), and the reasoning is exploration 0430’s.
  • 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>
**Tripwire:** <the observation that re-opens this required for a one-way
decision, omitted for a convention nothing could falsify>

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.

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.

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.

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 instanceof checks when needed

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)

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.

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.

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.

Tripwire: a second implementation cannot fully participate in L0–L3 while treating the Yjs body as an opaque octet string — i.e. some normative behaviour turns out to require decoding the document bytes. That would mean the codec is not pluggable and the kernel boundary is drawn in the wrong place.

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. A bounded Turso pilot on the disposable telemetry.db is sanctioned as a probe.

Tripwire: one engine ships production-ready embedded vector ANN and MVCC concurrent writes together (today they live in two different Turso products), or a shipped feature needs a vector workload SQLite + FTS5 demonstrably cannot serve.

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.

Tripwire: a shipped feature cannot produce its first paint without a hub round-trip — not “is slower offline”, but cannot render. One such feature makes the hub a dependency in fact regardless of what this entry says, and the honest move is to supersede rather than carry a promise the product no longer keeps.

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/react touch useSyncExternalStore; 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.

Tripwire: the MIT hub acquires a dependency on FSL-licensed code (which pnpm check:cloud-boundary would catch first), or a hyperscaler ships a managed xNet anyway — proving FSL bought no protection and only cost adoption.

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 fixed core 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.

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.

Tripwire: a provider worth routing to requires a key on the client (the keyless property is the whole boundary), or local models reach parity such that the managed path exists only to meter — at which point it is a billing decision wearing an architecture costume.

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.recovery requires critical).

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.

Tripwire: a real replication policy needs a unit finer than a namespace (per node, per schema) or coarser (per account), or the planner is still unconsumed by the time the manifest and trust gating ship — built-ahead machinery that never acquires a caller is the shape this repo already knows from 0377.

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 bootrequestIdleCallback tracks 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'.

Tripwire: compaction stops keeping up — the change log grows monotonically across a release cycle on an ordinary workload — or cold open breaches exploration 0266’s stop rule (first rows under 100ms p95) with compaction already default-on. Either means deferred reclamation has stopped being free, which is the entire basis of this entry.

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.

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:key has 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.

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.

ADR-26: Cross-hub grants — plane split before propagation

Section titled “ADR-26: Cross-hub grants — plane split before propagation”

Status: Accepted (design); implementation deferred to a follow-up Context: Explorations 0258/0382/0383 (W4). Hub-to-hub Space subscription shipped for public Spaces; grants do not cross hubs.

Decision: Cross-hub access control splits by plane before any grant ever propagates. The public plane needs no grants: a hub mirrors another hub’s public Space over the ordinary wire protocol, and mirrored state is never re-exported (served only under /sub/*), so subscription cycles cannot amplify. The granted plane stays single-hub until a dedicated design lands: a grant is a capability minted by a Space’s home hub, and no mirror, gateway, or federation surface may widen it. When cross-hub grants are built, they will be delegation chains rooted at the granting hub’s persistent DID (UCAN-style, matching the existing auth kernel) — never grant-table replication, which would turn every subscribed hub into a policy-enforcement point it cannot honestly be.

Rationale:

  • Public-first delivered the hub-of-hubs primitive without touching the security kernel (0383’s subscription-first ordering).
  • Replicating grant rows would make revocation eventually-consistent across hubs an attacker can choose among — a deny must always win (0359).
  • Delegation chains keep the home hub authoritative: a downstream hub can prove access was granted without being able to mint it.

Tradeoff: Members of a gated community cannot yet read it through a different hub. Accepted: correctness of revocation outranks read locality.

ADR-27: @xnetjs/server stays a separate product

Section titled “ADR-27: @xnetjs/server stays a separate product”

Status: Accepted Context: Exploration 0382/0383 (the standing question): “everything is a hub” unified the server surface into one binary with roles — except @xnetjs/server, the BYO-backend engine (exploration 0223), which does not depend on @xnetjs/hub at all.

Decision: Scope out. @xnetjs/server is not a hub role and will not be absorbed. It serves a different buyer: an app team embedding xNet’s data model inside their backend (their auth, their storage, their trust mode) — not an operator running xNet’s server. The hub’s role system covers every deployment of our server; @xnetjs/server remains the library for building someone else’s.

Rationale:

  • Absorbing it as a role would couple its TrustMode/storage hooks to hub release cadence for zero operator benefit (the MinIO gateway lesson, 0382: a role that cannot share the core’s invariants is a neighbour, not a role).
  • The decision is recorded precisely so the “second server” cannot drift back into ambiguity one layer up (0383 R6).

Tradeoff: Two server documentation surfaces. Accepted; they answer different questions.

Tripwire: @xnetjs/server’s TrustMode and storage hooks converge with the hub’s until a role could share the core’s invariants (the 0382 test for neighbour vs role), or the BYO-backend buyer fails to materialise — nobody embeds it in their own backend — at which point maintaining a second server is paying for an audience that does not exist.

ADR-28: No durable-execution orchestrator; reconcilers instead

Section titled “ADR-28: No durable-execution orchestrator; reconcilers instead”

Status: Accepted Context: Exploration 0411 evaluated adopting Temporal (and Restate, DBOS, pg_durable, Inngest, Cloudflare Workflows) for the parts of the stack that look workflow-shaped: tenant provisioning, staged fleet rollouts, nightly restore drills, and the non-payment lifecycle.

Decision: Do not adopt a workflow engine. Fix the three real durability gaps in-repo with primitives over the DocStore port the control plane already ships — a compensating saga(), a leased due-based job runner, and a durable RolloutRun checkpoint.

A workflow engine may only ever be considered for apps/cloud. It must never enter packages/hub, packages/server, or any client package.

Rationale:

  • Temporal and its peers provide durable execution; xNet’s hard problem is durable state, which CRDTs and the signed change log already solve. The position was already recorded in exploration 0332’s landscape survey.
  • It cannot run where most xNet work happens. The Temporal TypeScript SDK runs workflow code in a Node-only deterministic sandbox against a server cluster — there is no browser or offline story, and the device is xNet’s primary.
  • In packages/hub it would break a Charter §6 receipt (“the hub is a single self-contained process”), failing the BATNA test: a self-hoster would have to operate a workflow cluster to run their own hub.
  • The control plane is deliberately built as pure decision functions plus level-triggered reconcilers, which are restart-safe by construction. Only two modules deviated, and both were ordinary bugs rather than a missing capability.
  • Every candidate bottoms out in a datastore we do not run. There is no Postgres anywhere in the stack (SQLite + Firestore only), so the usual “just use DBOS” answer is unavailable.

Tradeoff: We maintain ~300 lines of scheduling and compensation logic ourselves. Accepted, and bounded.

Tripwire: any of the six decidable tripwires recorded in exploration 0411, or the hand-rolled primitives (saga(), the leased job runner, the RolloutRun checkpoint) growing past ~500 LOC — at which point we are maintaining the thing we declined to adopt, without its testing or its operator tooling.

Status: Accepted Context: Exploration 0416 asked whether xNet should connect to agent harnesses (OpenClaw, Hermes, Buzz) or build a competing one with its own privacy layer. “Agent harness” turned out to name three separate jobs, two of which xNet already occupies in shipped code: the substrate agents act against (xnet mcp serve, Agent Passports, the audit trail — exploration 0337) and the client that drives an agent (the bridge daemon and AgentFrame wire — exploration 0392). Only the third — owning the agent loop itself, with channels, scheduling, memory, and a skill marketplace — was genuinely undecided.

Decision: Do not build an agent harness. xNet is the accountability substrate agents run against and a client for the good ones. The agent loop, its messaging channels, its cron and heartbeats, and its skill marketplace are permanent non-goals.

The corollary is a positive commitment: the guardrail, the passport, and the audit ledger stay xNet-side and apply identically to every connected agent, regardless of what that agent’s own permission model claims.

Rationale:

  • The layer commoditised. Five meta-harnesses landed in roughly one week of June 2026 — Databricks Omnigent (Apache-2.0), Zed ACP, Vercel HarnessAgent, Cloudflare Flue, and Conductor — on top of two excellent free harnesses (Hermes, MIT; OpenClaw). Nothing xNet builds here stays differentiated for a quarter.
  • The layer beneath it did not. OpenClaw’s audit is mutable JSONL with documented blind spots for cron jobs and sub-agents; Hermes records learned capability in SKILL.md but says nothing about attribution. xNet’s kernel — authorDID, per-author parentHash chains, Ed25519 signatures — answers “prove what the agent did” structurally, and exploration 0337 already aimed it at agents.
  • Composition beats competition. Positioned beneath every harness, xNet works with all of them. Positioned as one, it competes with all of them and composes with none — including Buzz, whose agents already carry keypairs and are a natural interop target rather than a rival.
  • A harness is a permanent treadmill. Every model release re-opens the work, and the skill-marketplace supply chain brings a ClawHavoc-class trust problem xNet has no advantage in solving.

Tradeoff: xNet’s relationship with the user is mediated by whichever agent they chose, so the accountability story has to be marketed as the reason the agent is safe rather than as a product of its own. Accepted: the alternative spends the roadmap’s other two pillars on a layer five funded competitors gave away in one week.

Scope note: the provider-agnostic loop in packages/cloud/src/ai/agent-runner.ts stays — it is a safety core (tool allow-list, injection deny point, token-cap breaker) for xNet’s own server-side features, not the seed of a harness product.

Tripwire: growing agent-runner.ts channels, scheduling, or a skill registry — the three things that would make it a harness. Separately, the commoditisation premise fails if the layer re-consolidates: two of the five June 2026 meta-harnesses dead and one harness holding a dominant share of agent sessions for two consecutive quarters would mean “nothing stays differentiated for a quarter” was wrong, and the substrate-only position needs re-arguing.

ADR-30: Hub addresses resolve; no data-path routing tier

Section titled “ADR-30: Hub addresses resolve; no data-path routing tier”

Status: Accepted Context: Exploration 0423 compared xNet’s managed fleet to PlanetScale’s account of hiding 768 database servers behind one connection string. xNet already runs that topology — packages/cloud/src/provisioner/sharding.ts places 800 tenant hubs per GCP project — and pays almost none of the router tax, because its shard key is a person and people do not join to each other: no cross-shard queries, no resharding, no rebalancing.

What xNet had not bought was the other half of what the router provides. The Cloud Run adapter returns hubUrl: svc.uri, so a client’s durable configuration is a vendor hostname. A region move, a substrate swap, or a migration to self-hosting silently misconfigures every device — defeating, at the last inch, the Provisioner abstraction that exists so xNet is never hostage to one vendor’s terms.

Decision: Give hubs a stable name that resolves to an address the client dials directly. Never operate a proxy, gateway, or load-balanced routing tier that xNet-hosted traffic passes through.

Concretely: a hub publishes a record signed by its own system identity at /.well-known/xnet-hub-address; the control plane may mirror that record but never authors one; the client resolves once, caches, and falls back to its last-known address when the resolver is unreachable.

Rationale:

  • A gateway fails the Charter §6 BATNA test outright — a self-hoster cannot run the routing tier, so the self-hosted path becomes measurably worse than the managed one.
  • It fails the Vanish test — if xNet disappeared, every client would be pointed at a hostname resolving to nothing, mid-session, with no local fallback. A resolution record degrades instead to “connect where you connected last time”, and the address travels inside the .xnetpack export.
  • The Charter already refuses this by name: “No global chokepoint tier. We do not operate an indispensable middle to rent back later.” A tier carrying every byte of every tenant’s sync traffic is that middle, described.
  • Signing matters because mirroring is allowed. A mirror that could rewrite url would be a redirect primitive for anyone who compromised it; re-serving the hub’s signature verbatim makes the mirror untrusted by construction.
  • The split between the signed record (where to connect) and the mirror’s unsigned liveness hint (whether to wait) is deliberate: a lying mirror can make a client pause, never redirect it.

Tradeoff: Not quite one connection string — a migration costs one failed dial and a re-resolve, and the client needs a cache-invalidation story. Accepted: a one-round-trip staleness window is far cheaper than an operated tier we have promised not to build.

Tripwire: any proposal to terminate tenant sync connections on xNet-operated infrastructure — including for fleet-wide rate limiting or observability, the usual framings — re-opens this ADR rather than shipping under it.

ADR-31: The operational record runs on xNet; readings do not

Section titled “ADR-31: The operational record runs on xNet; readings do not”

Status: Accepted Context: Exploration 0431 found the control plane has no audit log at all — /internal/* sits behind one flat shared secret, and POST /internal/account/recover clears a tenant’s bound DID so the next device to present a passkey claims their hub. Anyone holding the secret could take over any tenant and leave no attributable trace. The hub, meanwhile, already has what the control plane lacks: packages/hub/src/routes/audit.ts pages an author’s signed, hash-chained change history.

Decision: Operator actions, incident notes and consent grants are signed xNet nodes authored by an operator’s bound did:key, so the change log is the audit trail. They live on a dedicated ops hub, run through the managed GCP path in its own project, and never provisioned by the fleet provisioner.

Metrics and tenant state stay in Firestore. SLI buckets are hourly writes per tenant forever — exploration 0323 measured a 318k-row change log producing a multi-second cold-open stall, and a 250-change burst cliff above which every subscribed client re-renders. TenantRecord stays authoritative in Firestore because billing and provisioning read it on the request path.

Audit is two-tier: a fail-closed Firestore write authorises the action, and the signed node publishes asynchronously.

Rationale:

  • A signed change log is verifiable; an append-only database collection is merely trusted. With a signing identity an operator cannot repudiate an action and nobody with database access can forge one.
  • The ops hub must not share fate with the fleet it records. Its own project, outside the provisioner, means a fleet-provisioner bug — the realistic failure for a small team — cannot destroy the record of what operators did.
  • Two tiers resolve the contradiction between “audit before acting” and “the hub may be unreachable during an incident”. Tier 1 never blocks on the hub, so operators can always act; the publish queue’s depth is an alertable metric, so a gap between the tiers is visible rather than silent.
  • The record is low-volume by construction. Keeping the readings off it is what makes the ops hub something that never needs to scale.

Tradeoff: The ops hub becomes a standing dependency of incident response, and the audit trail is eventually-consistent rather than synchronous. Accepted: the local replica and the tier-1 gate mean the degraded mode is “audit history is stale”, not “you cannot see or do anything”.

Tripwire: the ops hub’s change log crosses ~100k changes, or any proposal to put a per-tenant time series on it — either re-opens the record/readings split rather than shipping under this ADR.

Section titled “ADR-32: Support sees shape; content requires per-incident consent”

Status: Accepted Context: A managed hub is not opaque to its operator. The trusted tier provides integrity and revocation-denial but not confidentiality (exploration 0343), and packages/hub/src/services/search-indexer.ts extracts plaintext from rich text to build the FTS index. Any support console therefore draws its boundary in policy and audit, not in cryptography — and four user-facing surfaces were claiming otherwise.

Decision: Operators see Tier 1 — shape without consent: counts, bytes, latencies, plan, region, version, sync backlog, job history, error class and stack. No document titles, no field values.

Tier 2 — content requires a typed reason, the tenant’s explicit grant, and a hard expiry with no renewal. Every request, grant, denial and expiry is mirrored to the tenant’s own hub. Standing consent is refused at every tier, including enterprise contracts.

Audit is graduated: aggregate fleet views are not audited, per-tenant reads are audited silently without a prompt, mutations require a typed reason. Audit entries are retained 12 months and are not purged when a tenant is deleted.

Rationale:

  • Most real tickets — “is my sync broken”, “why is my hub slow”, “where did my storage go” — are answerable from shape alone. Making content expensive and visible rather than impossible is what keeps the boundary honest instead of routinely circumvented.
  • Titles are not a soft middle ground. A document called “Q3 layoffs” leaks exactly the thing the boundary protects.
  • A reason prompt on every read trains operators to type “investigating”, which produces a log that looks rigorous and means nothing. Reserving the prompt for mutations keeps it meaningful.
  • Standing access is the mechanism by which consented access erodes into routine unlogged looking. Refusing it at contract level is the only durable form.
  • Audit surviving tenant deletion protects the user: it is the only thing preventing look-then-delete from erasing its own evidence.

Tradeoff: Some tickets will be slower, and some enterprise negotiations will be harder. Accepted: a sovereignty promise with a standing-access carve-out is not a promise.

Tripwire: the first support ticket that cannot be resolved at Tier 1, or the first enterprise negotiation that makes standing access a condition of sale — either re-opens the consent model rather than shipping an exception under it.

ADR-33: Storage is sold as a flat per-GB add-on, not a plan tier

Section titled “ADR-33: Storage is sold as a flat per-GB add-on, not a plan tier”

Status: Accepted Context: Exploration 0435 asked whether a customer could buy 100, 500 or 1000 GB while keeping everything else about their plan unchanged. The entitlement machinery already supported it — withStorage() and requiresMigration() treat a quota change inside an isolation tier as a live flip — but two things did not. Bulk bytes were written to the hub’s local filesystem, which on Cloud Run is an in-memory tmpfs capped at 32 GiB, so the catalog’s existing company (1 TiB) and enterprise (5 TiB) quotas were already unbuildable. And quotaBytes is enforced per user, so a pack sold once per tenant would have been provisioned once per seat.

Welding storage to the plan tier had also produced a live inversion: family ($15/mo) shipped 250 GiB while the strictly more expensive, more isolated team tier shipped 100 GiB.

Decision: Sell storage as an additive per-tenant add-on at a flat $0.03/GiB-month — +100 GB $3, +500 GB $15, +1000 GB $30 — billed as a second Stripe SubscriptionItem, never as a new plan tier and never per seat.

Three invariants make it safe:

  • The pack is stored; the quota is derived. TenantRecord.storagePackGb holds the purchase and every resolve recomputes planBase + pack. Persisting a resolved absolute would make a personal+500 GiB tenant upgrading to family silently shrink from 750 GiB to 525.
  • Enforced against a per-tenant ceiling. tenantQuotaBytes is a new signed field, absent meaning unlimited, so the pack is not multiplied by seat count.
  • Billing leads the entitlement. The Stripe webhook applies the flip, so held space always equals billed space.

Rationale:

  • It is the cleanest improvement charge xNet has. Every GB sold is a GB rented from Cloudflare at $0.015 and replicated, monitored and backed up. The margin rides on an operation we run, not on access to bytes the customer would own anyway — Charter §6’s improvement test, passed more squarely than by seats or AI markup.
  • The BATNA is real and stays real. Keeping data local is free and unlimited, export is free, egress is zero, and the same MIT hub self-hosts against the customer’s own bucket. Consumer clouds sell 2 TB below our raw input cost; the honest answer to that comparison is bring-your-own-bucket, not a price war we would lose.
  • A flat rate over a linear cost holds one margin. 42% at every pack size — one number to defend, no volume-discount cliff, and a floor-margin test that fails if a future edit tiers the price.
  • Per-seat storage was rejected twice over. It would smuggle back the per-member meter withSeats() refuses for community (0359), and it prices out the single-seat customer with a 1 TB archive, who is the actual demand.

Tradeoff: $0.03/GiB is roughly six times iCloud’s headline rate, and the comparison will be made. Accepted: we are selling capacity inside a live, synced, exportable hub, and the customer who only wants a cheap byte locker should use one.

Tripwire: if storage becomes a material share of revenue, the incentive to degrade local-first storage appears. The first proposal to cap on-device storage, throttle local sync, or slow export for large workspaces re-opens this decision — charging for cloud bytes is an improvement charge only for as long as not buying them remains fully functional. Separately, R2 Infrequent Access at $0.010/GiB would make a lower published rate viable; a sustained gap between our price and blended COGS is a reason to cut the price, not to bank the margin.

ADR-34: A tenant is a roster of DIDs, and the hub enforces it

Section titled “ADR-34: A tenant is a roster of DIDs, and the hub enforces it”

Status: Accepted Context: xNet Cloud provisioned hubs but had no model of who was inside one. TenantRecord held a single billingUserId and a single did; there was no invite, roster, role or removal anywhere in the control plane. Meanwhile the hub applies a trusted-root policy only when config.trustedDids is set (packages/hub/src/auth/ucan.ts), and the control plane never set it — so a self-issued UCAN from a key generated seconds ago was accepted on a hub somebody else was paying for. seats had exactly two consumers in the whole repository, both render calls (exploration 0436).

Decision: A tenant carries members: TenantMember[]{ did, role, addedAtMs, addedBy } — and that roster is the trusted-root policy. It projects into HUB_TRUSTED_DIDS on every entitlement push, including inside bindDataIdentity, so a device that has just claimed a hub is trusted before it is told to connect.

Three invariants make it safe:

  • Absent means legacy, not empty. A record written before members existed reports [{ did, role: 'owner' }]. rosterFor() is the only place that decides this.
  • An empty policy is never written. checkTrustedRoots treats absent and empty identically, so an empty value produces a hub that is wide open while looking configured. trustedDidsEnv() returns undefined rather than ''.
  • A self-hosted hub never receives the variable at all, so its behaviour is unchanged — the anti-lock-in invariant from ADR-4 / exploration 0174.

Invitation reuses the device grant rather than inventing a second handshake: the collaborator’s app shows a short code, the owner approves it, and the key is minted on their own device. An invited DID joins the roster without rebinding the tenant.

Rationale:

  • The hub’s authorization model is rich and correct — grants, spaces, roles, the CRUD split. What was missing sat one layer above it: nothing decided which DIDs are the tenant. Admission control and seat counting are the same missing concept wearing a security hat and a product hat.
  • Putting the roster in the control plane rather than in the hub’s own grants keeps it readable during a support call and during a GDPR request, and lets a member join while the owner is offline.
  • Modelling a tenant as a WorkOS Organization was rejected: a WorkOS user is an email an IdP vouches for and a member is a key on someone’s device, and conflating them would put the billing identity provider in charge of the data identity — the arrangement the non-custodial claim flow exists to avoid. organizationId is a pointer that decides who is entitled to join; each human still binds their own key.

Tradeoff: membership changes push a new revision, which on Cloud Run restarts the hub — invisible on scale-to-zero plans, a brief rolling drop on warm ones. Accepted: the alternative is a hub anyone on the internet can write to.

Tripwire: the first proposal to authorize on the roster instead of the hub’s grant model — the roster decides admission, never permission — or the first hub shipped with HUB_TRUSTED_DIDS written as an empty string. Either re-opens this.

ADR-35: Seats bill collaborators, never audience

Section titled “ADR-35: Seats bill collaborators, never audience”

Status: Accepted Context: team was advertised at $12/seat/mo from three seats while checkout.sessions.create passed quantity: 1, so a three-seat subscription billed $12 against a margin model assuming $36. With ADR-33 the roster makes a seat countable for the first time, which is exactly when somebody will want to bill it by length (exploration 0436).

Decision: Seats are a real Stripe SubscriptionItem.quantity, floored at the plan’s catalog minimum and kept in sync from customer.subscription.updated by reading the subscription item rather than the checkout metadata — metadata records what was bought and never moves when a customer adds a seat later.

Two limits are part of the decision, not implementation detail:

  • guest never consumes a seat. seatsUsed() counts owners and members only. A seat is capacity we provision for a collaborator; an audience member the customer brought is not one.
  • Flat plans are never multiplied. checkoutQuantity returns 1 for any plan with seats === 0, and withSeats() already refuses to attach a seat count to community.

Enforcement refuses admission and never evicts a live session: a tenant at its seat limit cannot invite a fourth person, and the three already there keep working.

Rationale (Charter §6):

  • Improvement — a seat is a collaborator whose devices sync against a hub we operate: real connections, real storage, real relay.
  • BATNA — self-host the MIT hub with unlimited members; @xnetjs/entitlements is MIT and dependency-free, so a self-hosted hub never phones home.
  • Vanish — every member’s authoritative copy is on their own device. Losing the hub loses the relay, not the data.
  • Disconnecting someone mid-sync reads as data loss in a local-first product even when it technically is not, which is why the guard is admission-time only.

Tradeoff: a tenant can sit at its seat limit with people waiting, and the fix is a plan change rather than an automatic overage charge. Accepted: a surprise bill is the failure mode the AI budget work already went out of its way to prevent.

Tripwire: the moment a seat is charged for someone the customer brought rather than someone we provision capacity for, this decision is void. The concrete signals are a proposal to seat-meter community, or to count read-only/guest DIDs as seats on any plan. The roster is where that would sneak in, because once you have a member list somebody will want to bill its length.

ADR-36: A host may refuse service; refusal never takes the work

Section titled “ADR-36: A host may refuse service; refusal never takes the work”

Status: Accepted · 2026-08-13 · exploration 0444

Context: Every account-suspension story in the industry follows one shape. A platform reads what a customer stored, decides against them, and in the same motion stops serving them and keeps their work. The two are separable acts — Google left a father’s account closed after police cleared him; Notion terminated a workspace it had to read in order to judge; Slack cut off researchers with no window to pack; Google banned Gemini subscribers in February 2026 over a third-party OAuth integration with no appeal and no refund. Nothing technical fuses judgement to custody. They arrive together because the account is the only handle anyone built, so pulling it drags everything attached.

xNet Cloud is a host. It will receive abuse reports, legal demands and sanctions lists, and sometimes the honest answer is no. A promise never to refuse anyone would be worth nothing and would be broken inside a year, so the promise has to be the other one.

Decision: A host may refuse service. A host may never turn refusal into a claim on the customer’s work.

Concretely, and already true in code: every action moderation can take names reachreject, hide, quarantine, block-peer (packages/abuse/src/policy-blocks.ts) — and none names possession. There is no delete, no revoke, no confiscate. An appeal resolves to reverse or annotate (packages/abuse/src/appeals.ts), and reverse is only a meaningful word because nothing was destroyed. Identity is a did:key the host neither issues nor can revoke (packages/identity/src/keys.ts); the wire format is an open signed hash-chained log another hub can pick up (packages/sync/src/change.ts); the client runs with no hub at all (packages/runtime/src/sync/offline-queue.ts).

Rationale (Charter §1, §2): the master copy is the customer’s, so a refusal withdraws a relay rather than a possession. This is what makes Exit true at the one moment it matters — the moment you are no longer welcome. An export flow that only works while you can still log in is not an exit; it is a door on an interior wall.

Tradeoff: a hub cannot make a bad actor’s data disappear from their own device, because it never had it to begin with. Refusal stops propagation and stops us relaying, and that is the whole of what a host can honestly offer. Accepted: the alternative is a capability that, once it exists, is available to every future operator, subpoena and acquirer of this company.

Tripwire: the moment any hub role gains an action that removes a user’s data rather than restricting its reach, this decision is void. The concrete signals are a delete, purge, revoke or confiscate member on PolicyBlockAction, a moderation path that writes to storage rather than to a policy list, or a retention job that treats suspension as a deletion trigger. Quota and dormancy reclamation are the honest place this sneaks in, because there the deletion has an operator’s reason and no villain attached.