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 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 fordocs/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-waydecision, omitted for a convention nothing could falsify>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.
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/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.
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
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.
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.
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.
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 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'.
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.
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.
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/hubit 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.
ADR-29: xNet is not an agent harness
Section titled “ADR-29: xNet is not an agent harness”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.mdbut says nothing about attribution. xNet’s kernel —authorDID, per-authorparentHashchains, 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
.xnetpackexport. - 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
urlwould 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.
ADR-32: Support sees shape; content requires per-incident consent
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.storagePackGbholds the purchase and every resolve recomputesplanBase + pack. Persisting a resolved absolute would make apersonal+500 GiB tenant upgrading tofamilysilently shrink from 750 GiB to 525. - Enforced against a per-tenant ceiling.
tenantQuotaBytesis 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 forcommunity(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
membersexisted reports[{ did, role: 'owner' }].rosterFor()is the only place that decides this. - An empty policy is never written.
checkTrustedRootstreats absent and empty identically, so an empty value produces a hub that is wide open while looking configured.trustedDidsEnv()returnsundefinedrather 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.
organizationIdis 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:
guestnever 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.
checkoutQuantityreturns1for any plan withseats === 0, andwithSeats()already refuses to attach a seat count tocommunity.
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/entitlementsis 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
reach — reject, 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.