Skip to content

Browser MAE: design ADRs and supporting spikes - #669

Merged
cuttlefisch merged 19 commits into
mainfrom
feat/browser-kb-surface
Aug 7, 2026
Merged

Browser MAE: design ADRs and supporting spikes#669
cuttlefisch merged 19 commits into
mainfrom
feat/browser-kb-surface

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

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.md in 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-websocket is one connection per document, the listener caps at 256, and a KB session opens many kb:{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.

Spike Result
Browser Yjs ↔ real KbNodeDoc Holds. Stock yjs, no MAE code, converges byte-identically with two native writers across all 6 apply orders.
Multi-device identity ADR-040's Rebind is succession, not concurrency — enrolling a device retires the previous one, and there is no fan-out path.
Device secret storage + revocation Delegated invite does express a device set — but a member's own content-key wrap is ignored, and they cannot revoke their own device. Devices must be something a member has, not members.
Revocation forward secrecy Rotation strands history for the innocent: a re-syncing member opens 3 of 9 ops, and it compounds. #176 is a prerequisite, not an adjacent bug — and its fix restores history without helping the revoked device, so the two properties are independent.
Write fencing Removal stops writes with no rotation, but via the access gate, not the epoch fence — which structurally cannot carry revocation (absent member ⇒ epoch 0, and a fresh grant is also 0).
ADR-100 D4 org parser Neither latency nor size constrains it — parse is microseconds (p50 8.6 µs), and the scanning core is 16.3 KB gzipped against a 115-byte empty-crate control. But org.rs has no inline-emphasis scanner, while mae-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/list ungated — security), #654 (kb/query.* bypasses the daemon's own Cozo/BM25 projection; kb/query.graph silently truncates at 500 nodes), #655 (properties stored twice), #656 (upsert_with_crdt drops v2 field values), #657 (CLAUDE.md lists tree-sitter-org as 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, daemon cargo clippy --all-targets -- -D warnings — clean.
  • ADR corpus gate (parses_every_real_adr_file_cleanly, reciprocal_links_round_trip_over_the_full_real_corpus) green with all four new ADRs.
  • Back-merged main (fix(daemon): --config was ignored by subcommands (#645) + connected-client observability (#647 adjacent) #646) so ADR-099's cited ConnLimiter accessors are real rather than pending.

Known-open, stated rather than buried

  • CI needs a wasm32-unknown-unknown target added to .github/actions/setup-rust before 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 omits wasm-bindgen glue (adds) and wasm-opt -Oz (subtracts), with a margin wide enough to be insensitive to both.
  • ADR-098 inherits two gaps it must close rather than assume: the partition window, and the #157 N1 claim that the epoch fence is shared by hub and mesh dialer (only the hub path was exercised).
  • One inference stated, not measured: whether a non-owner may author a content-key rotation. ADR-098 D5 assumes not; if wrong, its cost table is wrong in the user's favour.

🤖 Generated with Claude Code

cuttlefisch and others added 11 commits August 5, 2026 22:04
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>
@cuttlefisch cuttlefisch added the release:none Skip version bump on merge label Aug 5, 2026
cuttlefisch and others added 2 commits August 6, 2026 09:56
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
cuttlefisch disabled auto-merge August 7, 2026 08:10
@cuttlefisch
cuttlefisch merged commit 2e24787 into main Aug 7, 2026
23 checks passed
@cuttlefisch
cuttlefisch deleted the feat/browser-kb-surface branch August 7, 2026 11:33
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none Skip version bump on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant