|
| 1 | +# Migrate samod: `quarto-dev/samod@q2` → `quarto-dev/samod@access-policy` |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Our vendored samod fork is moving from **`quarto-dev/samod` branch `q2`** |
| 6 | +(samod `0.9.0`, automerge `0.8.0`) to the **`access-policy` branch** |
| 7 | +(samod `0.12.1`, automerge `0.10.0`). |
| 8 | + |
| 9 | +> **Fork location.** The `access-policy` branch was developed on the |
| 10 | +> `shikokuchuo/samod` fork and later relocated to its canonical long-term home |
| 11 | +> at **`quarto-dev/samod`** (same commit, same branch). The dependency now |
| 12 | +> points at `quarto-dev/samod` branch `access-policy`; earlier phase entries and |
| 13 | +> commits below that mention `shikokuchuo/samod` are the historical record of |
| 14 | +> the interim location. See "Post-migration follow-ups". |
| 15 | +
|
| 16 | +The new fork = upstream samod `0.12.1` + a fresh "Implement access policy" |
| 17 | +commit. The *only* thing the old `q2` branch carried on top of its base was |
| 18 | +its own "Implement access policy" commit; every other commit under it was |
| 19 | +already upstream. So switching forks **gains** the upstream 0.10/0.11/0.12 |
| 20 | +work and **loses nothing q2-specific** except the *shape* of the access-policy |
| 21 | +API, which is deliberately replaced. |
| 22 | + |
| 23 | +Two direct consumers, both native-only: |
| 24 | + |
| 25 | +- `crates/quarto-hub` — the collaborative editing server (features `tokio`, |
| 26 | + `axum`, `tungstenite`). Owns `AuditAccessPolicy`. |
| 27 | +- `crates/quarto-preview` — reads back binary docs (feature `tokio`). |
| 28 | + |
| 29 | +The WASM crate (`crates/wasm-quarto-hub-client`) has **no** Rust samod/automerge |
| 30 | +dependency, so the WASM build is unaffected. The browser/Node side uses JS |
| 31 | +`@automerge/automerge ^3.2.6` + `@automerge/automerge-repo ^2.5.x`. |
| 32 | + |
| 33 | +**Scope is exactly two `Cargo.toml`s.** Reverse-dep check: only `quarto-preview` |
| 34 | +(deps `quarto-hub`) and the `quarto` binary (deps both) sit above these crates — |
| 35 | +the WASM crate is *not* above them, so the hub-client/WASM leg is out of scope |
| 36 | +(unless the JS-bump question pulls it in; see Phase 5 / open Q2). `quarto` and |
| 37 | +`quarto-util` mention `samod` only in comments and tracing-filter *strings* |
| 38 | +(`samod=info`), not as a dependency, so they need no change. A plain |
| 39 | +`cargo build --workspace` covers the transitive rebuild of `quarto-preview` / |
| 40 | +`quarto`. |
| 41 | + |
| 42 | +### What actually changed in the API (verified against both trees) |
| 43 | + |
| 44 | +**1. `AccessPolicy` trait: async → synchronous (the headline break).** |
| 45 | + |
| 46 | +Old (`q2`, samod 0.9.0): |
| 47 | +```rust |
| 48 | +pub trait AccessPolicy: Clone + Send + 'static { |
| 49 | + fn should_allow(&self, doc_id: DocumentId, peer_id: PeerId) |
| 50 | + -> impl Future<Output = bool> + Send + 'static; |
| 51 | +} |
| 52 | +// also exported: LocalAccessPolicy (unused by us) |
| 53 | +``` |
| 54 | + |
| 55 | +New (`access-policy`, samod 0.12.1): |
| 56 | +```rust |
| 57 | +pub trait AccessPolicy: Send + Sync + 'static { |
| 58 | + fn is_allowed(&self, doc_id: &DocumentId, peer_id: &PeerId) -> bool; |
| 59 | +} |
| 60 | +// blanket impl for Fn(&DocumentId, &PeerId) -> bool; struct AllowAll |
| 61 | +``` |
| 62 | + |
| 63 | +Changes: method renamed `should_allow` → `is_allowed`; args now **borrowed** |
| 64 | +(`&DocumentId, &PeerId`); returns **`bool` synchronously** (no future); bound |
| 65 | +is `Send + Sync + 'static` (was `Clone + Send + 'static`); no `LocalAccessPolicy`. |
| 66 | +The policy is now consulted *synchronously* at the hub's actor↔peer boundary |
| 67 | +and **cannot perform async work** (DB / remote-auth lookups). Our |
| 68 | +`AuditAccessPolicy` only locks a `Mutex`, reads a `HashMap`, and logs — all |
| 69 | +synchronous already — so the port is mechanical. |
| 70 | + |
| 71 | +**2. automerge `0.8.0` → `0.10.0` (spans two minor releases).** samod 0.12.x |
| 72 | +pins `automerge = "0.10.0"`; our two crates declare a **direct** dep on |
| 73 | +`automerge = "0.8.0"` (feature `utf16-indexing`) to share samod's types, so the |
| 74 | +direct dep must move to `0.10.0` in lockstep. Direct automerge API touched (8 |
| 75 | +files): `AutomergeError`, `ReadDoc`, `Transactable`, `Value::Scalar`, |
| 76 | +`ScalarValue::{Str,Bytes}`, `ROOT`, `ObjType`, `ObjId`, `ChangeHash`, |
| 77 | +`Automerge::merge`, and `TextEncoding::{platform_default, Utf16CodeUnit}` |
| 78 | +(tests only). The `TextEncoding` variants we use survive in 0.10 (the enum only |
| 79 | +*gained* variants). The remaining 0.8→0.10 breaks are unknown until a compile |
| 80 | +pass — let the compiler drive them. |
| 81 | + |
| 82 | +### What is NOT breaking (verified present in the new fork) |
| 83 | + |
| 84 | +`RepoBuilder` shape — `Repo::build_tokio()`, `.with_storage()`, |
| 85 | +`.with_announce_policy(NeverAnnounce)`, `.with_access_policy(..)`, `.load()`; |
| 86 | +`AnnouncePolicy` / `NeverAnnounce` / `AlwaysAnnounce` (still async, unchanged); |
| 87 | +`Repo::make_acceptor` + `AcceptorEvent`; `Stopped`; `PeerInfo { storage_id: |
| 88 | +Option<StorageId> }`; `StorageId::from`, `PeerId::from`, `DocumentId` + |
| 89 | +`from_str`; `InMemoryStorage`, `storage::TokioFilesystemStorage`; `DocHandle` |
| 90 | +methods `with_document`, `document_id`, `create`, `find`, `peers`, `changes`, |
| 91 | +`broadcast`. The `with_access_policy(audit_policy)` **call site** in |
| 92 | +`context.rs` needs no change — only the trait impl does. |
| 93 | + |
| 94 | +### Highest-risk item: JS ↔ Rust interop & on-disk format |
| 95 | + |
| 96 | +samod exists for JS `automerge-repo` interoperability. Bumping the Rust |
| 97 | +automerge 0.8→0.10 must remain compatible with (a) documents already on disk in |
| 98 | +the hub's `automerge_dir`, and (b) the JS automerge / automerge-repo versions |
| 99 | +hub-client speaks over the wire. automerge's storage + sync formats are designed |
| 100 | +to be stable, but this is a **must-verify**, not an assume. This is the one part |
| 101 | +the Rust test suite cannot cover on its own. |
| 102 | + |
| 103 | +## Work Items |
| 104 | + |
| 105 | +> **Compile-unit note.** Phases 1–3 do not build in isolation: rewriting the |
| 106 | +> tests to the new trait (Phase 1) breaks `quarto-hub` until the dep (Phase 2) |
| 107 | +> and source (Phase 3) land. That is the intended migration "red" — it is |
| 108 | +> compile-level, not a running-but-failing test. Land 1–3 together; the first |
| 109 | +> green test run is Phase 4. |
| 110 | +
|
| 111 | +### Phase 0 — Isolated worktree + baseline |
| 112 | + |
| 113 | +- [x] `cargo xtask create-worktree bd-qp353u2b` (base `main`). Worktree: |
| 114 | + `.worktrees/bd-qp353u2b-migrate-samod-quarto-devsamodq2/`. No `npm install`. |
| 115 | +- [x] Green baseline on branch HEAD (old fork): `cargo build -p quarto-hub -p quarto-preview` |
| 116 | + clean; `cargo nextest run -p quarto-hub -p quarto-preview` → 360 passed, 1 skipped. |
| 117 | +- [x] Interop baseline captured with the *current* (samod 0.9 / automerge 0.8) hub |
| 118 | + binary in standalone mode: `automerge_dir` with index doc |
| 119 | + `2J1DWLvMjrSn3KdfZe3VQosnQ3Sw` (incremental sha256 |
| 120 | + `dc51f19e…a4fda2bc9`). JS versions: `@automerge/automerge ^3.2.6`, |
| 121 | + `@automerge/automerge-repo ^2.5.6` (hub-client) / `^2.5.1` (ts-packages). |
| 122 | + Pristine copy + README in the session scratchpad. |
| 123 | + |
| 124 | +### Phase 1 — Tests first (TDD) |
| 125 | + |
| 126 | +- [x] Rewrote the three `AuditAccessPolicy` tests to the sync trait: |
| 127 | + `policy.is_allowed(&doc_id, &peer_id)` (no `.await`), `#[tokio::test]` → |
| 128 | + `#[test]`, renamed `should_allow_*` → `is_allowed_*`. |
| 129 | +- [x] Confirmed TDD "red": `cargo build -p quarto-hub --tests` failed with 3× |
| 130 | + `error[E0599]: no method named is_allowed` against the old dep. |
| 131 | +- [x] Left `automerge_api_tests.rs` untouched as the compiler-driven checklist |
| 132 | + (it turned out to compile & pass unchanged — no automerge break surfaced). |
| 133 | + |
| 134 | +### Phase 2 — Dependency bump |
| 135 | + |
| 136 | +- [x] `crates/quarto-hub/Cargo.toml`: samod → `git = shikokuchuo/samod.git, |
| 137 | + branch = "access-policy", version = "0.12.1"`; `automerge` `0.8.0` → `0.10.0` |
| 138 | + (kept `utf16-indexing`). Pinned by **branch** (user decision). Added a |
| 139 | + one-line "vendored fork (long-term home)" comment. |
| 140 | +- [x] `crates/quarto-preview/Cargo.toml`: same samod + automerge bump. |
| 141 | +- [x] axum comment left as-is — new samod still uses axum `0.8.x`, so `axum 0.8` |
| 142 | + stays correct (workspace built clean; no axum resolution change). |
| 143 | +- [x] `cargo update -p samod` re-resolved: automerge 0.8→0.10, samod 0.9→0.12.1, |
| 144 | + samod-core 0.9→0.12.0 (both at `c5a06c39`), added transitive `chacha20`, |
| 145 | + `rand 0.10.1`, `rand_core 0.10.1`. |
| 146 | +- [x] Version unification confirmed: exactly **one** `automerge` entry in |
| 147 | + `Cargo.lock` (0.10.0); `cargo tree -p quarto-hub -i automerge` shows the |
| 148 | + direct dep and samod/samod-core share it. No dual-automerge red flag. |
| 149 | + |
| 150 | +### Phase 3 — Migrate the source |
| 151 | + |
| 152 | +- [x] `access_policy.rs`: `impl AccessPolicy` now `fn is_allowed(&self, doc_id: |
| 153 | + &DocumentId, peer_id: &PeerId) -> bool`; reads `.get(peer_id)`, logs on hit, |
| 154 | + returns `true`. Dropped `use std::future::Future;` and the `async { true }`. |
| 155 | + Kept `#[derive(Clone)]` (harmless; no longer required by the bound). |
| 156 | +- [x] `context.rs`: `.with_access_policy(audit_policy)` type-checks unchanged |
| 157 | + (`quarto-hub` compiled clean) — no source change. |
| 158 | +- [x] **No automerge 0.8→0.10 breaks materialized.** `quarto-hub` (lib + tests) |
| 159 | + and `quarto-preview` compiled clean against automerge 0.10 with zero edits |
| 160 | + to the 8 anticipated files. The automerge API surface these crates use |
| 161 | + (`ReadDoc`, `Transactable`, `ScalarValue`, `ROOT`, `ObjType`, `ChangeHash`, |
| 162 | + `Automerge::merge`, `TextEncoding::{platform_default,Utf16CodeUnit}`, …) is |
| 163 | + stable across 0.8→0.10 for our usage. The single hand-edit was the trait impl. |
| 164 | + |
| 165 | +### Phase 4 — Rust test suite |
| 166 | + |
| 167 | +- [x] `cargo nextest run -p quarto-hub -p quarto-preview` → 360 passed, 1 skipped |
| 168 | + (matches baseline). Targeted run: 3 access-policy + 24 automerge-API tests |
| 169 | + all pass. |
| 170 | +- [x] `cargo nextest run --workspace` → **9855 passed, 197 skipped** (exit 0). |
| 171 | + No downstream regression. |
| 172 | + |
| 173 | +### Phase 5 — End-to-end interop verification (the part tests can't do) |
| 174 | + |
| 175 | +- [x] **On-disk backward-compat verified.** Started the NEW (automerge 0.10 / |
| 176 | + samod 0.12) `target/debug/hub` in standalone mode pointed at a copy of the |
| 177 | + Phase-0 `automerge_dir` written by the OLD binary: |
| 178 | + `QUARTO_HUB_DATA_DIR=<copy> ./target/debug/hub -P 3988 -H 127.0.0.1 -v`. |
| 179 | + Log: `Loaded existing index document doc_id=2J1DWLvMjrSn3KdfZe3VQosnQ3Sw` |
| 180 | + (the exact Phase-0 doc), and `hub.json`'s `index_document_id` unchanged. No |
| 181 | + re-init, no corruption. |
| 182 | +- [x] **Live JS↔Rust wire interop verified (automated).** Ran the sync-client |
| 183 | + Rust-hub interop suite against the new binary from the worktree: |
| 184 | + `vitest run offline-creation-rust-hub restart-window-creation exit-drain` |
| 185 | + (ts-packages/quarto-sync-client) → **3 files / 5 tests passed**. These |
| 186 | + spawn the worktree's new `hub` and drive the real JS client |
| 187 | + (`@automerge/automerge 3.2.x` / `automerge-repo 2.5.x`): a JS-created |
| 188 | + project + file syncs over the wire and lands in the new hub's on-disk |
| 189 | + storage (`automerge/<shard>/<rest>`), proving both the wire and on-disk |
| 190 | + formats interoperate across automerge 0.8→0.10 / samod 0.9→0.12. |
| 191 | +- [x] **Audit log verified (deterministic, in-repo).** Added a `tracing`-capture |
| 192 | + test `access_policy::tests::logs_document_accessed_with_email_for_known_peer` |
| 193 | + asserting the `"Document accessed"` line + email are emitted from the now |
| 194 | + synchronous `is_allowed`, and strengthened `no_log_when_peer_unknown` to |
| 195 | + assert the line is absent. The peer→email wiring (`server.rs:974`, on |
| 196 | + `AcceptorEvent::ClientConnected`) compiles clean against samod 0.12 and the |
| 197 | + accept path is exercised by the interop tests above. |
| 198 | + **Honest limitation:** the *live-auth* grep of a running hub's stdout was |
| 199 | + NOT performed — it needs either the embedded MCP bundle (a `PLACEHOLDER` in |
| 200 | + this fresh worktree) or the mock-OIDC bearer harness (`auth_bearer.rs`); |
| 201 | + both are unrelated to and out of scope for the samod/automerge migration. |
| 202 | + The `tracing::info!(… "Document accessed")` call itself is byte-identical |
| 203 | + pre/post migration (only the enclosing fn signature changed). |
| 204 | + |
| 205 | +### Phase 6 — Full verification & handoff |
| 206 | + |
| 207 | +- [x] `cargo xtask verify` → **✓ All verification steps passed!** The in-scope |
| 208 | + Rust legs are green at CI strictness: clippy `--workspace --all-targets |
| 209 | + -D warnings`, `cargo fmt --check`, `cargo build --workspace -D warnings`, |
| 210 | + tree-sitter grammar tests, and `cargo nextest run --workspace -D warnings` |
| 211 | + (**9856 passed, 197 skipped**). The JS/browser legs (hub-client build+tests, |
| 212 | + ts-packages build, shared preview-renderer/runtime, trace-viewer, |
| 213 | + q2-preview-spa, hub-mcp) were skipped — they are environmentally blocked in |
| 214 | + a fresh worktree (`wasm-quarto-hub-client` is not built, so every JS test |
| 215 | + leg fails with `Cannot find package 'wasm-quarto-hub-client'`) and are out |
| 216 | + of scope (the WASM crate does not depend on `quarto-hub`/`quarto-preview`). |
| 217 | + Verified this by a first run with only `--skip-hub-build`: the sole non- |
| 218 | + environmental failure was `intelligenceService.test.ts` (an LSP |
| 219 | + semantic-tokens parsing test, disjoint from samod/automerge) — pre-existing |
| 220 | + / unrelated to this Rust change. |
| 221 | +- [x] `cargo xtask lint` → All checks passed (812 files). |
| 222 | +- [x] Staged + committed as `9967abf9` on branch |
| 223 | + `braid/bd-qp353u2b-migrate-samod-quarto-devsamodq2` (worktree). Commit |
| 224 | + message calls out the automerge 0.8→0.10 bump + the sync AccessPolicy trait |
| 225 | + change. **Not pushed** — awaiting explicit approval (GIT PUSH POLICY). |
| 226 | +- [x] Reported E2E interop result + `Cargo.lock` diff summary; push approval |
| 227 | + requested. |
| 228 | + |
| 229 | +## Open questions — RESOLVED |
| 230 | + |
| 231 | +1. **Pin to rev vs branch?** → **Branch** (`branch = "access-policy"`), per user |
| 232 | + decision. Cargo froze rev `c5a06c39…` in `Cargo.lock`. Mirrors the old `q2` |
| 233 | + branch-pin. (A force-push to `access-policy` moves the build on next |
| 234 | + `cargo update`; acceptable — it is the user's own fork.) |
| 235 | +2. **JS package bump?** → **No.** Phase 5's live JS↔Rust round-trip passed with |
| 236 | + the *existing* JS versions (`@automerge/automerge ^3.2.6`, |
| 237 | + `@automerge/automerge-repo ^2.5.x`) against the automerge-0.10 / samod-0.12 |
| 238 | + hub. Wire + on-disk formats interoperate; hub-client/ts-packages stay out of |
| 239 | + scope. |
| 240 | +3. **Upstreaming intent** → **Long-term fork** (user decision). Dep comment reads |
| 241 | + "Vendored samod fork carrying the synchronous AccessPolicy trait (long-term |
| 242 | + home)." |
| 243 | + |
| 244 | +## References |
| 245 | + |
| 246 | +- Fork: `quarto-dev/samod` branch `access-policy`, samod `0.12.1` / |
| 247 | + samod-core `0.12.0` / automerge `0.10.0`. Access-policy commit `c5a06c3`. |
| 248 | + (Developed on `shikokuchuo/samod`; relocated to `quarto-dev/samod` — same |
| 249 | + commit — as the canonical home. See "Post-migration follow-ups".) |
| 250 | +- Old fork: `quarto-dev/samod` branch `q2`, rev `0b50c16` ("Implement access |
| 251 | + policy" on samod `0.9.0` / automerge `0.8.0`). |
| 252 | +- samod CHANGELOG (0.9→0.12): automerge 0.8→0.10; added `Repo::search` / |
| 253 | + `SearchState`; `Repo::find` hang-after-unavailable fix; exponential sync-time |
| 254 | + fix; `DocHandle::{changes,ephemeral}` now `'static`. |
| 255 | +- Consumers: `crates/quarto-hub/src/{access_policy,context,server,sync,index, |
| 256 | + sync_state,resource,automerge_api_tests}.rs`, |
| 257 | + `crates/quarto-preview/src/capture_driver.rs`. |
| 258 | + |
| 259 | +## Post-migration follow-ups |
| 260 | + |
| 261 | +Discovered during review of the migration branch, after the Phase 0–6 work |
| 262 | +above landed as `bfcee64f`: |
| 263 | + |
| 264 | +- [x] **Audit-log dedup (`d1935ec7`).** samod 0.12 consults the access policy |
| 265 | + *synchronously on every inbound sync message* (three gates in |
| 266 | + `samod-core/hub/state.rs`; the inbound `handle_doc_message` gate is |
| 267 | + un-memoized), whereas samod 0.9 memoized the check per `(peer, doc)` |
| 268 | + connection (`AccessPolicyState`). `AuditAccessPolicy` logged |
| 269 | + unconditionally, so a single document open emitted 2–3 identical |
| 270 | + "Document accessed" lines. Restored once-per-`(peer, doc)` semantics with |
| 271 | + a dedup `HashSet` in `AuditAccessPolicy`, pruned per-peer on disconnect |
| 272 | + via `forget_peer` (called from `server.rs`'s `ClientDisconnected` arm next |
| 273 | + to the existing `peer_emails` removal). Three tracing-capture tests cover |
| 274 | + dedup, per-doc distinctness, and re-log-after-forget. Full workspace green |
| 275 | + (9859 passed). |
| 276 | + |
| 277 | +- [x] **Fork relocated to `quarto-dev/samod` (`46f1da95`).** The `access-policy` |
| 278 | + branch was pushed to `quarto-dev/samod` (same commit `c5a06c39`); the |
| 279 | + `git = …` source in both `crates/quarto-hub/Cargo.toml` and |
| 280 | + `crates/quarto-preview/Cargo.toml` now points there instead of |
| 281 | + `shikokuchuo/samod`. The `Cargo.lock` change is exactly the two samod |
| 282 | + `source =` lines — verified with `cargo check --locked` that no |
| 283 | + re-resolution (e.g. transitive `windows-sys` churn) is needed; a stray |
| 284 | + `cargo update -p samod` had incidentally normalized 11 unrelated |
| 285 | + Windows-only `windows-sys` edges, which was reverted to keep the diff |
| 286 | + minimal. |
0 commit comments