Architecture Decisions
ADR-1: Yjs over Automerge for rich text
Section titled “ADR-1: Yjs over Automerge for rich text”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.
ADR-2: DID:key for identity
Section titled “ADR-2: DID:key for identity”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).
ADR-3: Field-level LWW for structured data
Section titled “ADR-3: Field-level LWW for structured data”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”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 @xnet/crypto for interop cases.
ADR-5: Named exports only
Section titled “ADR-5: Named exports only”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”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”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”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”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”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.