Collaboration via passthrough relay — substrate pattern
Heading heal (documentation): insert 216 section headings on 33 heading-sparse articles (EN+ES, insertion-only, no prose change, ~90-150 w/h band)
@@ -27,22 +27,36 @@ The passthrough relay pattern inverts the normal assumption about where a collab Tools such as Etherpad and HackMD operate on a server-authoritative document model: the collaborative editing server holds a live, mutable document object — an Operational Transformation log in Etherpad's case, a Y.Doc in HackMD's — and that object is the primary record of current content. A git export is a snapshot taken from that server record, not the other way around. The consequence is a permanent second authoritative state: two places in the system hold an answer to the question "what is the current text of this document," and they can drift if the export mechanism fails, the server crashes before a save, or the OT/CRDT log diverges from git history at a merge edge case. ### Message conduit, not a store The passthrough design eliminates that second record entirely. The server is a message conduit, not a store. When a Yjs client sends a binary update message to `GET /ws/collab/{slug}`, the Rust handler receives the raw bytes from the WebSocket and broadcasts them to all other clients in the same slug room via `tokio::sync::broadcast`. The server never deserialises the Yjs protocol; it never constructs a Y.Doc; it never writes anything to disk as a side effect of a relay operation. The only document state the server knows is whatever the client sends through `POST /edit` — the HTTP save path. ### Disclosure record stays in git This matters for the disclosure-substrate posture. The canonical disclosure record is the git tree: every topic's content history is a sequence of signed commits, and that sequence is what a regulatory audit would produce. A server-authoritative CRDT store would exist in parallel with that sequence, would not be signed, and would represent content states that never appeared in git. Under the passthrough design, no such parallel record exists: in-flight CRDT state is definitionally not part of the disclosure record because it is never written anywhere. The record closes at `POST /edit` time, not before. ### Late joiners and the 256-message buffer The 256-message lag buffer configured on each `tokio::sync::broadcast` channel addresses the one race this design must handle: a client that joins a collab session after other editors have already made changes will not have received those earlier Yjs update messages. Because Yjs CRDTs are convergent, a client that receives all prior update messages in any order arrives at the same document state. The 256-message buffer lets a late joiner catch up on recent activity without the server needing to materialise or store the full document state. If the buffer fills before a late joiner connects, Yjs's sync protocol handles the gap through its state-vector exchange on connection open — the server's job remains forwarding, not resolving. ## The implementation in `app-mediakit-knowledge` The collab relay shipped in commit `05f1dab` as `src/collab.rs`, gated entirely behind the `--enable-collab` CLI flag. When the flag is absent — the default, and the production posture as of v0.1.29 — the WebSocket route `GET /ws/collab/{slug}` is not registered in the axum router, the `window.WIKI_COLLAB_ENABLED` template variable is set to `false`, and the client-side JavaScript bundle never loads. Zero collab code paths execute in the default-off configuration. ### Per-slug broadcast rooms The server-side relay uses `tokio::sync::broadcast`, the standard Tokio multi-producer multi-consumer channel. Each slug gets its own broadcast channel with a buffer capacity of 256 messages, created on first connection and stored in a `DashMap<String, broadcast::Sender<Bytes>>` held in `AppState`. When a WebSocket client sends a Yjs update message, the handler reads the raw bytes and calls `sender.send(bytes)` — a single line that fans the message out to all other receivers on that channel. There is no `yrs` crate dependency; the Rust port of Yjs was specified in the original `PHASE-2-PLAN.md` Step 7 brief but was not used in the as-shipped implementation. The server relays raw Yjs binary protocol messages without deserialising them, so the server carries no document state at any point. ### WebSocket lifecycle and disconnect behaviour The WebSocket upgrade uses axum's built-in WS support. The route handler upgrades the HTTP connection, spawns a task per client that loops on incoming WebSocket frames and rebroadcasts each, and removes the client from the room's receiver set on disconnect. If the last client disconnects, the broadcast channel's receiver count drops to zero; the `DashMap` entry persists but holds an empty channel, ready for the next connection. No disk write occurs on last-client-disconnect — a deliberate departure from the `PHASE-2-PLAN.md` spec that originally called for snapshotting the Y.Doc to disk on last disconnect. The passthrough design makes that snapshot impossible because the server never holds the Y.Doc. ### Client bundle and flag-gated load The client bundle `cm-collab.bundle.js` (~302 KB as shipped) is built from three npm packages vendored in `vendor-js/`: `yjs`, `y-codemirror.next`, and `y-websocket`. The bundle is committed to `static/vendor/` as a pre-built artefact, so no npm toolchain is required at runtime or in the Rust build path. The `static/saa-init.js` initialisation script checks `window.WIKI_COLLAB_ENABLED` at load time; if the flag is `false`, the import of `cm-collab.bundle.js` is never executed. The CodeMirror editor loads fully in either case — collab is additive to the editing surface, not a prerequisite. ### Test coverage for the relay Test coverage at commit `05f1dab` added 7 tests (3 unit + 4 integration) to bring the total from 90 to 97. The unit tests cover: the WebSocket route accepts a connection when `--enable-collab` is set; `tokio::sync::broadcast` rooms multiplex correctly across two clients on the same slug; the 256-message buffer drains to completion without panic; and the client bundle HTTP 200 is reachable only when the flag is set. The 4 integration tests cover flag-gated script-injection behaviour in rendered HTML. Visual cursor-presence verification requires a two-headed browser smoke with DOM inspection, which was judged out-of-scope for Phase 2 and is covered by the manual smoke procedure in `STEP-7-COLLAB-SMOKE.md`. ## What the server does not carry @@ -59,14 +73,20 @@ The following is a precise enumeration of what the relay layer deliberately omit In-flight CRDT state — the sequence of Yjs update messages exchanged between clients during a collab session — is not part of the disclosure record and cannot be, by construction. Because the relay never persists those messages, there is no server-side artefact that could later be produced in response to a disclosure obligation. The collab session leaves no shadow state on the server. This is not a gap in the record; it is a design property that makes canonical storage — here the git tree — the sole authoritative record. A server-authoritative CRDT store would create a second authoritative record that is not git, not signed, and not suitable for regulatory production. ### Save path into the git record Saved edits enter the disclosure record through the same path as all other edits: `POST /edit/{slug}` sends the full Markdown text of the document, the server performs an atomic file rename, and the next git commit in the sequence captures that snapshot. From git's perspective, a collab-edited save is identical to a single-author save. The commit records what the document contained at save time; it does not record who typed which characters during the collab session. The disclosure unit is the committed document state, not the authorship decomposition of how that state was reached. ### Non-destructive rollback The `--enable-collab` flag's rollback path (documented in `STEP-7-COLLAB-SMOKE.md` §5) is non-destructive at every layer precisely because of this design. Removing the flag from the systemd unit's `ExecStart` line, running `systemctl daemon-reload` and `systemctl restart`, returns the service to default-off posture without losing any data — because no data was ever held on the server side. The collab CRDT overlay is ephemeral by construction; its removal on service restart is not a data loss event. Any content saved before the restart exists in the git tree and is fully recoverable. ## Generalising beyond the wiki The passthrough relay is a substrate pattern, not a wiki-specific feature. Any service that wants concurrent editing semantics faces the same architectural question: does the collab infrastructure need to hold document state on the server, or can that state live entirely on the clients and in canonical storage? The answer depends on what the canonical storage is and whether a CRDT server sitting between the clients and that storage would compete with it for authority. ### Candidate services and their canonical stores **`service-extraction` multi-author review pipeline.** The canonical storage for extraction results is the deterministic parser-combinator output written to structured records. The framing question: would a stateful CRDT server compete with the structured record store for authority? If the CRDT server materialises partial corrections that have not yet been committed back to the structured store, the answer is yes — and the passthrough design would not directly apply without an adapter layer that serialises CRDT state to the canonical structure format on save. The passthrough pattern applies in its simplest form only when the CRDT document type maps cleanly to the canonical storage type. **`app-workplace-presentation` deck collaboration.** The canonical storage for a presentation is the slide deck source committed to the customer's Git repository. The framing question: would a stateful CRDT server compete with the slide file for authority? Yes — a server-authoritative presentation service by definition holds the authoritative deck object. The passthrough design applies if and only if the on-disk file format is the canonical record and the CRDT overlay is treated as session-ephemeral on top of it. For a deck stored as a committed git artefact, that posture is achievable; for a deck stored in a third-party format that regenerates from a server-side object, it is not. Note: CRDT collaboration for `app-workplace-presentation` is planned; it is not yet implemented as of 2026-04-27.