Browser MAE: design ADRs and supporting spikes - #669
Merged
Conversation
Promotes issue #650's scope decision from descriptive to normative, so the boundary between the three KB-reaching surfaces (native MAE, external editors over MCP, Browser MAE) stops being re-derived in each planning pass. D1 scopes the browser to KB work and states the test for future features. D2 amends CLAUDE.md principle #12 narrowly and in the open, per principle #17: a browser client has no in-process core to fall back to, so for that surface alone the daemon is a hard requirement rather than an optimization. The amendment lands in CLAUDE.md in this same commit, which ADR-097's own Verification section requires. D3 records that a one-directional transport cannot serve a write surface, narrowing ADR-074's SSE reasoning to read-only views without deciding the transport itself. D4 distinguishes this from ADR-064's rejected "web app instead of a native frontend", which answers a different question and would otherwise read as blocking this work. Also fixes the ADR index in CLAUDE.md, which had gone stale at ADR-093 and was missing 095 and 096 — the exact failure its own standing warning describes. Citations re-derived against main at 33282fb per ADR-057's requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gates ADR-099 (transport) and ADR-100 (edit surface). Built first, and built to fail rather than reassure: if UTF-16 offsets, the yrs v1 update format or the nested shared-type layout did not survive the crossing into another runtime, the whole browser-KB design would need a different shape. The assumption holds. Stock yjs 13.6.32 — no MAE code, no shim — reads a real KbNodeDoc as live Y.Text/Y.Array/Y.Map, edits at UTF-16 offsets that land where Rust expects, and converges byte-identically with two concurrent native writers across all 6 apply orders. Adversarial per principle #14: N=3 writers with realistic derived client ids (never the client_id=1 stand-in that hid a real bug before), every apply order rather than one, the #625 no-duplication oracle, offline-then-reconnect, hostile/corrupt update inputs, and a negative control asserting the convergence oracle can actually fail. One real finding, recorded in docs/research/097-...: schema_v is stamped lazily, so a freshly created node is structurally v2 but reports v1. A browser reader must probe the containers, never the marker. Pinned by its own test. Harness follows crates/export/tests/browser/'s established Layer-2 pattern — Rust owns every assertion, Node plays the untrusted runtime and asserts nothing, so it cannot launder a Rust-side bug into a pass. Skips (not fails) without node_modules, and reports which mode it ran in so an all-skip run cannot masquerade as green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-device Gates ADR-098. CC3 in the browser-KB plan named this the single most likely thing to make Browser MAE unusable: a browser gives every profile its own keypair, against a membership model keyed on one fingerprint in a signed, peer-verifiable, append-only op-log. ADR-040's Rebind looked like it might already solve it — old key cross-signs new key, additive, citing Matrix cross-signing as prior art. It does not. Executed rather than read: - Enrolling a second device RETIRES the first. "Add a device" and "replace a device" are the same operation. - Rebinding once per device does not fan out — the first rebind retires the authority the second needs. Membership converges to owner + exactly one live device, never a device set. - An enrolled device holds no E2E content key until the OWNER authors a re-wrap: an owner action per device, per KB. Paired with its complement so the boundary is proven real rather than a lookup that never finds anything. Unexpected: the OWNER already has working multi-device. owner_principal_chain is a forward-closure set that never retires, so both keys stay authoritative after an owner rotation — the additive shape members lack already exists in the codebase. But ADR-040's threat model states "a retired key's post-rebind ops are fenced" without qualification, which is false for the owner. Filed as #661. Also pins the two properties any redesign must preserve: a device cannot elevate the role it inherits, and an unvouched device is not a member (the negative control — if it ever passed, every other assertion here would be vacuous). Report in docs/research/098-... maps the answer onto Matrix's actual multi-device primitive (the self-signing key, which MAE has no analogue for) plus SSSS for getting secret material to a new device, and owns the UX cost that model is documented to carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t members Second half of the multi-device question, gating ADR-098. The first spike left revocation and secret storage open and named them as the parts that reshape a design rather than fill in under it. Tested the cheapest hypothesis first, which the earlier spike had not considered: MAE already has delegated invite (can_invite) and an inviter-removal cascade, so perhaps a member can just admit their own devices. It half works, and the failures are decisive: - Delegated invite DOES express a concurrent device set — laptop and phone both members at once, which Rebind could not do. - But a member's own content-key wrap is IGNORED. find_wrapped_content_key honours only owner-chain authors, so an E2E KB still needs an owner action per device. This is the same wall the first spike hit via Rebind, reached by a completely different mechanism — which is what makes it a property of the design rather than of one op kind. - And a member cannot revoke their own device: Remove is owner-only, so the person who knows the laptop was stolen is the one who cannot act. The owner CAN revoke one device without disturbing the others, so the gap is who may author it, not whether it is representable. - Under the DEFAULT cascade policy, removing a member leaves their enrolled devices behind — correct for humans, wrong for devices, and it is a local per-peer setting so peers can disagree. Conclusion is the opposite of the hypothesis, which is why it was worth testing: devices must be something a member HAS, not a member. Part 3 shows the SSSS analogue is buildable from primitives already shipped — no new dependency, which matters given the deliberate sha2-not-hkdf crypto coherence constraint. Round-trip, four distinct wrong keys, an exhaustive per-byte tamper sweep, a byte-level key-blind leak scan, and a negative control proving the leak oracle can fire. Report names the sharpest thing still open: a revoked device already held the content key, so real revocation implies rotation — an owner action — and whether device revocation can avoid dragging the owner back in may decide the whole design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#176 Third and last of the multi-device arc, gating ADR-098. Both earlier spikes ended on the same unresolved point: a revoked device already held the content key, so real revocation implies rotation, which is an owner action. This measures it at the op-set layer, where the encryption actually happens, using real KbNodeDoc updates rather than opaque blobs. Forward secrecy works — a revoked device cannot open post-rotation content. Two things it does not do decide the design: - Revocation is forward-ONLY. The revoked device keeps every pre-rotation op permanently, and no owner action takes that back. Documented intent per ADR-037 §D3, but a hard boundary ADR-098 must design around. - Rotation truncates history for the innocent. A continuously-authorised member re-syncing from scratch opens 3 of 9 ops; three rotations leave a re-syncing member 2 of 8. Cumulative, so rotate-per-device-revocation degrades the KB monotonically and is unworkable as stated. The useful result: the blocker is removable and the fix's safety is measurable. Retaining every key restores all 9 ops while the revoked device still opens exactly the 6 it had — the two properties are INDEPENDENT, so history availability and forward secrecy need not be traded against each other. That reclassifies #176 from an adjacent lifecycle bug into a prerequisite of ADR-098, and rules out its "document it as intended" option: nothing is being bought by the loss. Negative controls included so the counts cannot be harness artefacts: a stranger's key opens nothing and reports every op undecryptable, and without a rotation one key opens the entire op-set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wner-bound Fourth and last of the multi-device arc, gating ADR-098. The forward-secrecy spike ended on an explicit untested hypothesis: that ADR-023's epoch fence might fence a revoked device's writes independently of key rotation. Half right, and the half that holds is the useful one. Removing a device stops its writes immediately with NO content-key rotation anywhere in the path. So write-revocation and read-revocation become two separately-priced operations: a lost laptop can be silenced immediately and for free, while rotation — which strands history for every re-syncing member until #176 is fixed — is reserved for when future confidentiality actually matters. That is materially better than where the forward-secrecy spike left things, where rotation looked mandatory for any revocation. But it is not decoupled from the owner. The rejection is asserted NOT to carry the fence's distinctive "rebase required ... stale-epoch client" message — the access gate stops it first, and that needs an owner-authored Remove. And the fence structurally cannot carry revocation: it discriminates on derive_kb_client_id(principal, epoch), while kb_member_epoch documents "absent member => 0" and a fresh grant also sits at epoch 0. For the common case the discriminator is identical before and after revocation. The complement is asserted too, so this cannot be misread as "the fence never discriminates" — it does, across epoch changes, which is its actual ADR-023 job. Tested against the real dispatch path rather than the fence in isolation, with a bystander control proving revocation is per-principal and not a KB-wide freeze, and a no-half-apply check on the rejected non-owner revocation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payoff of four spikes, all of which stay in the tree as regression guards. Every load-bearing claim here was executed rather than read, and two of the four answers contradicted what the design documents implied. D1 makes the membership subject a stable MEMBER key rather than a device key. This requires no protocol change at all — no new op kind, no derivation change, no new verification rule — which is the point: the peer-verifiable membership log is the hardest thing in this system to change safely, and this design does not change it. D2 has devices obtain that key from recovery-key-sealed secret storage, the shape Matrix uses for cross-signing material, buildable from content_crypto's shipped AEAD with no new dependency. D3 puts the OIDC-principal↔fingerprint binding in daemon state, OUTSIDE the CRDT. This is the disaster-recovery decision: an OIDC sub is provider-scoped, so binding membership to it would mean an Authentik→Entra migration orphans every grant in an append-only signed log that cannot be rewritten. Outside the CRDT, a migration rewrites a mutable table instead. D4 splits revocation into two speeds — AD groups gate the session with no CRDT write and no owner involvement, while CRDT membership stays owner-authored. The daemon is deliberately NOT given authority to author membership ops; a daemon that can is indistinguishable from the owner. D5 accepts that a lost device is handled by member-key rotation rather than per-device revocation, because the costs fall well: adding a device is zero owner actions (common), losing one is today's rotation cost (rare). Per-device certificates are deferred to Phase E with their own ADR — sequencing, not merit. D6 makes #176 a prerequisite: D5 makes rotation the device-loss path, so shipping it first would mean every lost laptop permanently truncates the KB's readable history for everyone else. Alternatives-rejected records the delegated-invite path that partly worked, since it needed testing rather than reasoning about, and the OIDC-sub-as- subject mistake that is technically available today and therefore worth rejecting explicitly. Verification carries forward the two gaps the spikes named as untested — the partition window and the hub-vs-mesh fence parity claim (#157 N1) — as obligations to measure rather than assume, plus one inference (whether a non-owner may author a rotation) stated rather than buried. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urface)
Both were gated on the Phase 0 spike, which established that the payload
crosses runtimes intact — stock yjs reads a real KbNodeDoc as live shared
types and converges byte-identically with native writers. Only the envelope
differs, so these two ADRs decide the envelope and the editing model.
ADR-099 chooses WebSocket over SSE for the write path, narrowly superseding
ADR-074 D1 while leaving its SSE decision correct and in force for read-only
views. The load-bearing call is D2: re-frame MAE's own envelope rather than
speak y-protocols. Multiplexing decides it — y-websocket is one connection per
document, the listener caps at 256, and a KB browsing session opens many
kb:{node_id} docs; MAE's envelope is already document-scoped (every sync/*
message carries params["doc"], every kb/* carries kb_id/node_id), so one
connection carrying N docs needs no new addressing. Authorization reinforces
it: kb_access, the epoch fence and signed-content-op verification all read
fields y-protocols has nowhere to carry. The forfeited Yjs ecosystem is
recorded as a real recurring cost, not dismissed.
ADR-100's first decision is to stop calling the edit target the org source.
ADR-092 D3's premise is already false in code — org.rs stores a
link-rewritten transform, title is lifted into its own field, and properties
live in both the drawer and props (#655). Building an editor on that premise
would have shipped an implied contract the storage layer never offered.
What follows from ADR-093's schema: typed fields bind directly to their own
CRDT types with ZERO round-trip, leaving body as the only text problem. Live
preview ships an honest subset — tables, src blocks and drawers stay as
source, because those are exactly where every prior implementation reports
breaking and org tables are common. D4 leaves the org-parser choice
deliberately undecided and gated on a WASM spike rather than picked by
plausibility; the last planning pass proposed reusing a tree-sitter-org
grammar that does not exist (#657), and the lesson is to gate on measurement.
D5 forecloses, while it is still cheap, the decision that would be hardest to
reverse later: block WYSIWYG is permitted only as a projection over the body
text, never as a second source of truth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
D4 posed a three-way choice: JS uniorg, daemon-served decoration ranges, or MAE's parser compiled to WASM. Measuring disposed of two and corrected the third. Latency is a non-issue and that was worth establishing, because it was the only rationale for the daemon-served option. Native parse over the real bundled corpus (98 files, 319KB) is MICROSECONDS: full structure p50 8.6us, max 39.3us; link scan max 6.2us. Even at a pessimistic 3x WASM factor that is ~1% of a frame, while a network round-trip is three orders of magnitude worse than the parse it would replace. An incidental result worth keeping: the slowest structure parse is a 1371-byte file, not the 11717-byte one — cost tracks heading count, not length, so a per-kilobyte budget would be the wrong shape. The premise correction: org.rs has NO inline-emphasis scanner at all. Its only contact with * / = / ~ is heading stars and skipping links inside verbatim spans, so compiling it alone cannot produce most of D3's decoration set. But the missing half already exists as offsets — mae-export's find_markup_end_str returns Option<(usize, &str)>, and only its public wrapper formats to HTML. So D4 now decides: extract the pure scanners from BOTH parsers into a WASM-compilable leaf crate. Chosen over uniorg because drift in the semantic layer (typed links, drawers, headings) produces a wrong graph rather than wrong-looking text, and that is exactly where a third implementation is most dangerous. It is also a principle #8 win — MAE has two hand-written org parsers today and this consolidates their scanning core. Bundle size stays UNMEASURED and is recorded as a prerequisite rather than estimated: wasm32 std is not installed here (Fedora rustc, not rustup) and CI would need the target added. This arc has already had one premise fail for being plausible rather than checked (#657), and this ADR's own first draft proposed reusing an emphasis scanner that does not exist. Both were caught by looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…osed
The wasm32 target is now installed, so ADR-100 D4's remaining "prerequisite,
deliberately not estimated" line becomes a measurement.
A probe crate carrying the REAL scanner sources — extracted verbatim from
shared/kb/src/{lib,org}.rs and crates/export/src/lib.rs rather than retyped —
built as a cdylib with opt-level=z, LTO, panic=abort, strip. A #[no_mangle]
entry point calls every extracted function so the optimiser cannot delete the
code being measured.
The control matters more than the number: an otherwise-identical crate whose
entry point only returns a string length compiles to 115 bytes. Without it the
figure would be indistinguishable from toolchain floor.
empty baseline 115 B
org scanning core 35,687 B (34.8 KiB)
gzipped 16,294 B (15.9 KiB)
~16 KB over the wire — a fraction of a typical web font. Bundle size does not
constrain D4.
Stated so the number is not over-read: the probe covers the decoration core
but omits parse_org_multi_result, drawer scanning and parse_typed_links (all
need crate types), so the real extracted crate will be larger — plausibly 2-3x,
still well under 50 KB gzipped. It also omits wasm-bindgen glue (adds) and
wasm-opt -Oz (subtracts, binaryen unavailable). The margin is wide enough that
the conclusion is insensitive to both.
Both of D4's conditions are now measured rather than assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ADR KB staleness gate (ADR-059 Phase E) was failing on this branch: four new ADRs were added without regenerating the bundled `assets/mae-adr.cozo`, so the shipped KB did not contain them and a `kb_search` over the ADR corpus would have missed the whole browser-MAE arc. `make adr-kb` output only; no ADR text changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cuttlefisch
enabled auto-merge
August 6, 2026 11:37
This was referenced Aug 6, 2026
cuttlefisch
disabled auto-merge
August 7, 2026 08:10
cuttlefisch
added a commit
that referenced
this pull request
Aug 7, 2026
…rerequisites Design-only pass (no implementation). Fleshes out the daemon AI-enrichment-of-KBs story to enterprise grade, grounded in code verification + a cited prior-art review. - ADR-101: links become first-class structured CRDT edges (provenance/status/confidence); projector unions human-inline (ADR-030) + machine-structured edges. Prerequisite: today a machine link written to the graph is destroyed on the next projection. - ADR-102: KB engine maturity/performance for the hosted daemon. Evidence-gated — benchmark Cozo-on-RocksDB at ~50 users/100K nodes, escalate to a replace-Cozo evaluation only on a missed bar. Sled deprecated; daemon-less editor keeps sqlite. Extends ADR-004/012, supersedes ADR-054's figure with new evidence. - ADR-103: autonomous local-LLM link enrichment (the link half ADR-061 deferred). Judgment over a fixed relation vocabulary, multi-signal calibrated confidence, conservative three-band agent-unsettable gate, status-flip materialization, 24/7 sweep + review buffer, over-linking/staleness/trust-cliff guardrails. - docs/research/103-...: cited Phase-0 prior-art review grounding 103's calibration/threshold. - docs/KB_ENRICHMENT_EPIC.md: dependency-ordered milestones M0-M6 with adversarial-test gates. - assets/mae-adr.cozo: regenerated (98 ADR nodes, corpus validated). Renumbered clear of PR #669's ADR-097-100 (Browser MAE). CLAUDE.md ADR index updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Aug 7, 2026
cuttlefisch
added a commit
that referenced
this pull request
Aug 7, 2026
Ten conflicts on the first attempt, two after #673 (untrack the ADR KB blob) landed — this branch was a live instance of the churn #673 removes: adding three ADRs regenerated the tracked 57 MB store and collided with main's copy. Resolutions: * assets/mae-adr.cozo/{db,blobs/*} — took the deletion. Post-#673 these are gitignored build artifacts derived from docs/adr/*.md; nothing reads the committed copy. * CLAUDE.md — union, not a pick. Both sides appended to the same ADR-index sentence: main added ADR-095–100 (via #669), this branch added ADR-101–103. Kept main's text, then appended this branch's paragraph after the "holistic sharing story" sentence, where it deliberately placed it. Verified: all of ADR-095…103 present, and zero ADR references dropped from either side (main 59 refs, branch 57, merged 64). * assets/mae-adr.cozo.sha256 — REGENERATED via `make adr-kb`, not taken from either side. This matters: the ADR-059 gate only checks that the file differs from base, so either side's value would have gone green. But this branch predates #669, so its checksum describes an ADR set missing ADR-097–100 — committing it would have produced a passing gate certifying something false. The regenerated value covers the merged corpus, and the build validated it (no dangling references, no Extends cycles). No ADRs were lost: no docs/adr/*.md has ever been deleted in history, ADR-094 was never created, and the merged tree has all 103 files main had plus the three new ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design and evidence for Browser MAE — a KB-only web frontend served by
mae-daemon. Four ADRs, six spikes, and no production code: every Rust change here is a test or a measurement tool.release:none— nothing user-facing ships.What's here
ADR-097 — Browser MAE is a KB surface, not a browser editor. Promotes issue #650's scope from descriptive to normative, so the boundary between native MAE, external editors over MCP, and the browser stops being re-derived each planning pass. Its D2 amends CLAUDE.md principle #12 in the open (per principle #17): a browser client has no in-process core to fall back to, so for that surface alone the daemon is a hard requirement. The amendment lands in
CLAUDE.mdin the same commit, which the ADR's own Verification requires.ADR-098 — Durable identity for network clients. The membership subject becomes a stable member key rather than a device key, which needs no protocol change at all — no new op kind, no derivation change, no new verification rule. Devices obtain that key from recovery-key-sealed secret storage. The OIDC-principal↔fingerprint binding lives in daemon state outside the CRDT, so migrating between identity providers rewrites a mutable table instead of orphaning every grant in an append-only signed log that cannot be rewritten. Directory group membership gates the session; CRDT membership stays owner-authored, and the daemon is deliberately never given authority to author membership ops.
ADR-099 — Bidirectional sync transport. WebSocket on the existing OAuth listener, re-framing MAE's own envelope rather than speaking y-protocols. Multiplexing decides it:
y-websocketis one connection per document, the listener caps at 256, and a KB session opens manykb:{node_id}docs — while MAE's envelope is already document-scoped. Narrowly supersedes ADR-074 D1 for the write path only.ADR-100 — The browser KB edit surface. Structured chrome bound directly to ADR-093's schema v2 (zero round-trip — nothing serializes), plus source-backed live preview over the body
Y.Text. Block WYSIWYG permitted only as a projection, never a second source of truth.The spikes, and what they falsified
Every load-bearing claim was executed rather than read. Two of the answers contradicted what the design documents implied.
KbNodeDocyjs, no MAE code, converges byte-identically with two native writers across all 6 apply orders.Rebindis succession, not concurrency — enrolling a device retires the previous one, and there is no fan-out path.absent member ⇒ epoch 0, and a fresh grant is also 0).org.rshas no inline-emphasis scanner, whilemae-export's already returns offsets — so D4 became "extract the scanners from both parsers", not "reuse one".Several tests deliberately assert current limitations and are written to fail loudly when a future change moves the wall, rather than silently encoding a workaround.
Issues filed from this work
#653 (
kb/listungated — security), #654 (kb/query.*bypasses the daemon's own Cozo/BM25 projection;kb/query.graphsilently truncates at 500 nodes), #655 (properties stored twice), #656 (upsert_with_crdtdrops v2 field values), #657 (CLAUDE.md liststree-sitter-orgas a dependency that does not exist), #658 (Playwright + browser CI), #659, #661 (ADR-040's "retired keys are fenced" is false for the owner). Plus a reclassification comment on #176 and a resolution comment on #650.Verification
cargo test -p mae-sync— all suites green, including the four spike files.cd daemon && cargo test— full suite green (194 lib + 170 bins + integration).cargo check --workspace --all-targets,cargo fmt --all --check, daemoncargo clippy --all-targets -- -D warnings— clean.parses_every_real_adr_file_cleanly,reciprocal_links_round_trip_over_the_full_real_corpus) green with all four new ADRs.main(fix(daemon): --config was ignored by subcommands (#645) + connected-client observability (#647 adjacent) #646) so ADR-099's citedConnLimiteraccessors are real rather than pending.Known-open, stated rather than buried
wasm32-unknown-unknowntarget added to.github/actions/setup-rustbefore any size gate on the ADR-100 D4 extraction. The size itself is now measured (16.3 KB gzipped for the scanning core); the figure omitswasm-bindgenglue (adds) andwasm-opt -Oz(subtracts), with a margin wide enough to be insensitive to both.#157 N1claim that the epoch fence is shared by hub and mesh dialer (only the hub path was exercised).🤖 Generated with Claude Code