Skip to content

Commit 681e9a9

Browse files
authored
Merge pull request #355 from quarto-dev/feature/bd-qp353u2b-migrate-samod-access-policy
chore(hub): migrate samod to quarto-dev/samod@access-policy (automerge 0.10)
2 parents 8cd9003 + 2988487 commit 681e9a9

7 files changed

Lines changed: 542 additions & 45 deletions

File tree

Cargo.lock

Lines changed: 37 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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

Comments
 (0)