Skip to content

docs: replace the CLAUDE.md memory dump with a spec, threat model, ADRs and bridge contracts - #365

Merged
bahdotsh merged 7 commits into
mainfrom
docs/restructure-spec-threat-model-adrs
Aug 15, 2026
Merged

docs: replace the CLAUDE.md memory dump with a spec, threat model, ADRs and bridge contracts#365
bahdotsh merged 7 commits into
mainfrom
docs/restructure-spec-threat-model-adrs

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

CLAUDE.md had grown to 61 KB in a handful of paragraph-long bullets. It was unreadable by people, unreviewable in a diff, and the only home for knowledge that several parts of the codebase depend on. CHANGELOG.md had the same problem for a different reason: 2,425 lines with no archiving story.

This relocates the durable knowledge into documents that can be reviewed, linked and cited, and cuts CLAUDE.md to repository instructions plus a pointer table: 61,107 → 7,606 bytes.

New reference material

Path Scope
docs/spec/ Implementation-independent protocol specification: identity and address derivation, the message model with both encodings and the frozen binary layout, the reserved prefix registry with the signing gate and its two exemption classes, the three encryption envelopes, the group protocol, capability negotiation
docs/security/threat-model.md Assets, six adversary classes, five trust boundaries, a control-to-attack table, and residual risks R1 through R8 stated plainly
docs/state-machines/ Delivery and acknowledgement, outbox and retries, session lifecycle, group message lifecycle, transport lifecycle, with 10 Mermaid diagrams
docs/adr/ Fifteen decisions that are expensive to reverse or easy to undo by accident, each with a "what would undo this" note
docs/bridges/ The Rust to Swift/Kotlin/Python/TypeScript contract, every rule of which fails silently when violated

Changelog archiving

CHANGELOG.md now holds unreleased changes plus the current release; older releases move to docs/changelog/, one file per minor series with its own release table.

All 31 release sections are accounted for, none duplicated and none lost, verified by diffing the old and new release bodies section by section. Content is otherwise byte-identical apart from a blank line at the split boundary and three docs/UPGRADING.md links in the 0.19 and 0.20 sections, which had to become ../UPGRADING.md to keep resolving from their new depth. No CI job or script parses the changelog, so the split is safe.

This adds a step to the release cut: after cutting, move the now-previous release's section into docs/changelog/<major>.<minor>.md and update both archive tables. The procedure is written down in the archive index.

Related issues

None.

Type of change

  • docs — documentation only

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes (2,132 tests, 0 failures, doctests included)
  • cargo-deny unaffected (no dependency, license or manifest changes)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated
  • No new unsafe
  • UDL unchanged, so no binding regeneration needed

Breaking changes

None. No behavioural change of any kind. The non-markdown edits are all comments: five Rust doc comments that pointed at CLAUDE.md sections which no longer exist there and now name the specific document, plus one TypeScript and one Kotlin comment in 174016b that named a guard test by a stale name.

Notes for reviewers

Deliberately not carried over: the per-feature Coverage: test_x, test_y, ... lists. They were the most staleness-prone part of the old file, and enumerating test names in prose recreates exactly the problem this change is addressing. The docs name the behaviour that is pinned instead, and grep finds the tests. Happy to add a generated test index if reviewers want that back, but it should not be hand-maintained prose.

One claim in the old CLAUDE.md was already stale, which is itself an argument for the move: it described the wire v1 extension TLV registry as having only tag 1, but wire.rs has tag 2 (reply context) as well. The spec documents both, along with the rule that distinguishes them: tag 1 shipped in v1's first release so its absence would truncate content, which fixes the constraint that no future v1 tag may change meaning by being ignored.

Where to focus review. The docs are long, but the load-bearing parts are the ones that describe shapes which are correct only as a whole, since those are what a future change is most likely to undo by accident:

  • the six pieces of the deferred-acknowledgement atom (state machine, ADR 0005)
  • the desync classification gating the re-key rather than the acknowledgement (ADR 0006)
  • the inverted logical-identifier marking rules on the mesh versus relay group paths (ADR 0011)
  • the fail-open rule in membership enforcement (ADR 0009)
  • the four-source commit walk in the leaf identity binding (ADR 0010)

Corrections welcome on any of these: the docs are an attempt to state the invariants precisely, and if one is stated wrong it is better caught now than cited later.

Every relative link and anchor in the new and edited files was verified to resolve.


Review corrections (174016b)

Every load-bearing claim in the new documents was verified against the code at HEAD. The review found roughly two dozen statements that did not hold, all now fixed in 174016b. The classes worth calling out, since they say something about where this kind of document goes wrong:

Three stated pre-fix behaviour as current design. The parked-DM section said a parked message "cannot settle on its own", which was true before the offer-plus-settle pair landed; the delivery machine said the desync arm was unreachable from the drain, where it is in fact the routine case; the session machine said confirmation triggers only on a successful decrypt, when the joiner confirms on the Welcome itself. Prose that describes the bug rather than the fix is the failure mode this restructure is most exposed to, because the fix and the document were written at different times.

One security property was stated backwards in three places. Permanent refusals have two dispositions: a policy refusal is acknowledged, a security refusal is silent and unmarks the identifier. ADR 0005, ADR 0006 and the delivery state machine each carried only the first half, which would have licensed exactly the change the interception exists to prevent. The delivery document's own media section had it right, so the file contradicted itself.

ADR 0003's tiebreaker rule was wrong for three of its four named sites. Only session ownership compares hash bytes; leave election, admin promotion and fork leader sort rendered strings. Each converges on its own, so the correct invariant is per tiebreaker, and the hazard the ADR should have named is harmonizing one site onto the other order.

A bridge contract named a guard test that does not exist. react_native_mesh_wake_task_key_matches_native is really react_native_mesh_wake_wiring_is_present; the stale name was in two source comments as well, so those are fixed in the same commit. Also in that family: swift test does not cover error mapping (the suite is excluded from the package manifest), the FFI error match is no longer compiler-exhaustive now that the engine error types are #[non_exhaustive], and the Android local-test workaround now lives in docs/bridges/kotlin.md instead of pointing at a guide that never contained it.

Two of the findings are code defects rather than documentation defects, and are documented as such rather than papered over:

  • On the mesh path a security-refused group frame never releases its group-level dedup entry (the relay path does), so a verbatim replay is absorbed by the duplicate branch and acknowledged, which is the liveness confirmation the refusal exists to withhold. docs/spec/group-protocol.md states the conforming behaviour and marks the current mesh behaviour a defect.
  • mark_key_package_synced deletes a key package record without purging its provider key, which ADR 0012 presented as a removed leak. It is now recorded as a surviving exception with the reasoning for and against.

Both want their own issues; neither belongs in a docs-only PR.

No behavioural change in this PR. cargo fmt, RUSTDOCFLAGS="-D warnings" cargo doc, the 24 React Native pinning guards, and the TypeScript typecheck all pass on the corrections.

…Rs and bridge contracts

CLAUDE.md had grown to 61 KB in a handful of paragraph-long bullets. It was
unreadable by people, unreviewable in a diff, and the only home for knowledge
that several parts of the codebase depend on. CHANGELOG.md had the same
problem for a different reason: 2,425 lines with no archiving story.

Relocate the durable knowledge into documents that can be reviewed, linked and
cited, and cut CLAUDE.md to repository instructions plus a pointer table
(61,107 -> 7,606 bytes).

New reference material under docs/:

  spec/            implementation-independent protocol specification: identity
                   and address derivation, the message model with both
                   encodings and the frozen binary layout, the reserved prefix
                   registry with the signing gate and its two exemption
                   classes, the three encryption envelopes, the group
                   protocol, and capability negotiation
  security/        threat model: assets, six adversary classes, five trust
                   boundaries, a control-to-attack table, and residual risks
                   R1 through R8 stated plainly
  state-machines/  delivery and acknowledgement, outbox and retries, session
                   lifecycle, group message lifecycle, transport lifecycle,
                   with diagrams
  adr/             fifteen decisions that are expensive to reverse or easy to
                   undo by accident, each with a "what would undo this" note
  bridges/         the Rust to Swift/Kotlin/Python/TypeScript contract, every
                   rule of which fails silently when violated

CHANGELOG.md now holds unreleased changes plus the current release; older
releases move to docs/changelog/, one file per minor series. Content is
byte-identical apart from one blank line at the split boundary, verified by
diff. No CI job or script parses the changelog, so the split is safe.

Five Rust doc comments pointed at CLAUDE.md sections that no longer exist
there; they now name the specific document.

Deliberately not carried over: the per-feature "Coverage: test_x, test_y"
lists. They were the most staleness-prone part of the old file, and
enumerating test names in prose recreates the problem this change is
addressing. The docs name the behaviour that is pinned instead.

No behavioural change. cargo fmt, clippy and the full test suite are green.
Review of the restructure verified every load-bearing claim against the
code at HEAD. These are the ones that did not hold.

Three described pre-fix behaviour as current design:

- A parked DM "cannot settle on its own". The park offers the frame to
  the mesh and an arriving ACK settles it Delivered with no pending ACK
  to match against; the offer and the settle arm are one change. Also
  corrects the diagram: park is entered from Pending, Parked reaches
  Delivered, and a no-transport send is a deferral rather than terminal.
- The desync arm "not reachable from the drain". Only the parse-failure
  arm is; a drained frame classifies as desync routinely and schedules
  the re-key, so a re-key audit must include the drain.
- Confirmation triggering only on a successful decrypt. The joiner
  confirms on the Welcome itself, and a probe or its ack confirms too;
  decrypt is the trigger added so the both-create owner is covered.

Permanent refusals have two dispositions, not one. A policy refusal is
acknowledged; a security refusal (identity mismatch, foreign session
slot) is silent and unmarks the identifier, and is intercepted before
classification so it cannot inherit the policy disposition. ADR 0005,
ADR 0006 and the delivery state machine each stated only the first half.

Other corrections:

- ADR 0003 claimed every tiebreaker compares hash bytes. Only session
  ownership does; leave election, admin promotion and fork leader sort
  rendered strings. Each converges, so the rule is per tiebreaker and
  the hazard is harmonizing one site onto the other order.
- ADR 0001 and the envelope spec treated the media envelope as a third
  switch-plus-capability layer. It has neither; the third is the sealed
  rich payload, and the media v2 form shares rich_versions.
- The rich-field restore rules described an unconditional strip that
  would break the legacy-sender fallback the same document relies on.
- Group protocol claimed the refused-frame identifier is unmarked. The
  relay path does; the mesh path does not, so a replay is acknowledged.
  Stated as a defect against the spec, with the conforming behaviour.
- Current transport is device-global, not per peer; transport status has
  five values; scoring has seven factors including proximity.
- Relay reconciliation evaluates removes, only adds go unevaluated.
- Suppression keys on (group, committer, enforced): without the flag a
  report-only event suppresses the refusal alarm.
- Bridge contracts: the mesh-wake guard is named
  react_native_mesh_wake_wiring_is_present (the old name was in two
  source comments too), error mapping is excluded from swift test, the
  FFI error match is no longer compiler-exhaustive under non_exhaustive,
  and the Android local-test workaround is written down here instead of
  pointing at a guide that never had it.
- Size ratio is ~2.8x for envelope plus codec, ~2.7x for the envelope
  alone; the priority Medium fallback is binary-only; ten magic bytes
  remain, not eleven; only Wi-Fi Direct and Reticulum swallow a
  self-addressed frame, BLE fails closed.
- ADR 0012 now records mark_key_package_synced as a surviving
  record-only delete rather than implying none remain.

No behavioural change. The two source edits are comments naming the
renamed guard test.
@bahdotsh

Copy link
Copy Markdown
Member Author

Filed the two code defects surfaced by the verification pass, plus one adjacent pre-existing one, so they are not lost when this merges:

The documentation in this PR states the current behaviour for #366 and #367 rather than the intended behaviour, and marks each as a defect, so neither doc has to change again when they are fixed.

The first verification pass over these documents found roughly two
dozen statements that did not survive contact with the code, and
`174016b` fixed them. A second pass, checking every load-bearing claim
again, found nine more.

That is the interesting part. One pass over six thousand lines of
prose converges but does not finish, and the residue lands in the same
classes as the first round: a rule stated without the exception that
makes it interesting, and a property that holds at one site described
as if it held everywhere.

The worst of them is in the specification, which is the document that
claims a second implementation can be built from it. It described the
`__MLS_WELCOME__` body as base64. It is JSON, and has always been
JSON. Base64 is what an `__MLS_ENC__` body looks like, and what the
`welcome_data` field *inside* a group Welcome looks like, so the
mistake is at least explicable. An implementor following the spec
would have emitted frames nothing can parse.

Two invariants were stated stronger than the code delivers. Outbox S3
promised a resend is re-sealed and never replayed; re-seal provenance
holds plaintext, is deliberately never persisted, and an entry
restored after a restart therefore replays verbatim. Session E3
promised a reset keeps queued plaintext; that is true of the desync
re-key and false of the post-unblock reset, which drops the queue on
purpose, because delivering messages queued before a block on the
strength of an unblock is not what anyone asked for. Both now say
which case they mean and why the other one differs.

The group drain diagram said it fires on any successful decrypt. That
is the 1:1 rule, carried into the group document, where it is wrong:
the group drain fires when the epoch moves, and decrypting a message
does not move the epoch.

While at it, two code comments in the same family. `Address`'s
ordering note instructed the reader to compare `Address` values at all
four protocol tiebreakers. Exactly one of them does. The other three
sort rendered strings, they each converge on their own, and
"harmonizing" them is the specific change ADR 0003 exists to prevent.
The comment was a standing invitation to introduce the bug the ADR
forbids, sitting three files away from the ADR. A DORS comment
also named a default switch hysteresis of 10, which it has not been
for some time.

The rest are bridge documents overstating what their guard tests
cover: the TypeScript event guards pin tags, not field shapes, so a
renamed event field still passes everything in the repository; one of
the three Kotlin config-parser rules is pinned by a Rust guard rather
than the Kotlin suite named next to it, and the Kotlin file pins
literal defaults at *initial* parse, which is the opposite mechanism;
and the podspec does glob `ble/` and `mesh/`, so half the rule stated
above it does not apply. A document that claims more coverage than
exists is worse than one that claims none, because it tells you to
stop looking.

No behavioural change. Comments and prose only.
Two passes over these documents fixed roughly thirty-five statements that
did not survive contact with the code. A third pass, checking about four
hundred claims across the spec, the state machines, the ADRs and the
bridge contracts, found eighteen more.

The headline one is the reason this pass was worth running. `174016b`
corrected ADR 0003 and the `Address` rustdoc after finding that only one
of the four protocol tiebreakers compares hash bytes; the other three
sort rendered strings, and each converges on its own. It did not correct
`docs/spec/identity.md`, which went on stating the blanket rule in the
document that claims a second implementation can be built from it. A
conforming implementor following it would have disagreed with deployed
peers about who wins three of the four elections, with no way to detect
the disagreement locally.

That is a new error class, and it is the inverse of the one the second
pass named: not a document that was never checked, but a fact that was
corrected in one place and left standing in another. A correction has to
be swept across every restatement, and "where else did I say this" is
now the first question after fixing a claim.

The rest, by area.

Specification. The group refusal table justified acknowledging a refused
Welcome or Commit by calling them signature-gated, in contrast with
`__GRP_MLS_MSG__`. All three are gated; only `__MLS_ENC__` and
`__GROUP_MSG__` are data-plane exempt. The dispositions were right and
the reason was wrong, which is worse than it sounds in a column headed
"Why", because a wrong reason invites the simplification the table
exists to prevent. The real distinction is that a refused group message
is an attribution failure, so there is no party the acknowledgement is
owed to. "Base64 appears in two places only" was false: every group
ciphertext field carries it, and `__MLS_ENC__` carries it only in the
compact form. The reserved metadata table omitted `__ctrl_sig` and
`__ctrl_pk`, so the rule forbidding applications to write reserved keys
excluded the two that let an application forge the control plane. The
membership report rate limit is keyed on enforcement mode as well as
group and committer.

State machines. Delivery and acknowledgements described a fifth outcome,
`PolicyRejected`, that does not exist; a refused commit resolves to
`Consumed`, as the same document's own flowchart showed ninety lines
later. It also said the crypto-recovery switch gates the whole
recoverable family, when session-not-ready deferral is deliberately
unconditional. The outbox diagram carried a lifetime-expiry edge out of
`Pending` that the sweep's own skip rule makes not the designed path,
and described restart as dropping entries past the absolute cap when
restore requires both windows lapsed while the in-process sweep needs
either.

Bridge contracts. The Kotlin document explained at length why running
the tests locally fails; the predicate it blames was refined to test
`node_modules/react-native/android` before this branch, and the pinned
React Native ships no such directory, so the local run works. Swift said
the Bluetooth manager is typechecked only by the symlink-farm harness,
but CI typechecks it; two files are covered by neither, and the comment
in ci.yml claiming there is one is itself stale. TypeScript stated the
one-shot hold must be cleared behind a microtask yield, which names the
wrong operation: removal at replay time is synchronous by design, and
deferring it produces one delivery per registration. The eight-mebibyte
record ceiling was filed under Keychain and Keystore, where no such
limit exists, and described as "stay above" when the guard asserts
equality across four sites in three languages, Python included.

No behavioural change. Every claim in this commit was verified against
the code, and then re-verified adversarially, which caught two wrong
fixes and six overstatements before they landed.
Thirteen findings from an adversarial pass over ~570 verifiable claims. Three
of them were restated in more than one document, so eighteen edits.

Interop-grade:

- The commit-rejection principal set said "every proposal's sender". The code
  scopes it to Add/Remove senders and deliberately excludes Update and PSK,
  because rejecting an admin's Add for batching a member's key update would
  fork the group. Fixed in the spec and in ADR 0009.
- Priority was described as a lowercase-only closed set. The decoder also
  accepts the capitalized aliases the UniFFI bridges have always emitted, so a
  decoder built to the spec as written rejects frames this implementation
  produces.

The threat model inverted two security dispositions its own sources state
correctly:

- Signature-gated refusals were said to be ACK-able. That is exactly the class
  the code silences; the refusal that keeps its ACK is the post-decrypt
  enforcement refusal, whose sender is already authenticated. Since the
  codebase gives the name "policy refusal" to both dispositions, the rule now
  says so explicitly.
- Commit enforcement was called unsuitable for fleet-wide enablement. The
  config docs warn against enabling it on *part* of a fleet; a closed
  deployment is the sanctioned use.

Also corrected: the "absent administrative view" phrasing that read as
inverting the fail-open rule (three places), two Mermaid arms in the group
message lifecycle that contradicted correct prose in the same file (the drain's
missing PolicyRejected arm, and a retry edge that re-entered the logical-id
mint), the claim that mesh transports enqueue unconditionally (BLE is
fail-closed), the both-create owner "never receives a Welcome" premise (it
never *adopts* one; three places), the forged-registration impact that omitted
its correlation gate, a count of two hand-mirrored constant sets where the same
section names four, "address-ordered" group tiebreakers that sort rendered
strings, and two CLAUDE.md event names that do not exist.
Two fresh reviewers went over commit 1db49f7 and the doc set around it. One
finding was a correction that replaced a wrong rule with another wrong rule;
the rest were claims the earlier passes had not reached.

The ACK rule in the threat model, again. The previous pass said an
authenticated sender's permanent refusal keeps its acknowledgement. That is
falsified by the group data plane: a group message is signature-gated, so its
wire sender is proven, and a leaf credential that fails to bind is still
refused silently. Authentication was never the discriminator. The rule now
imports the one delivery-and-acks.md already stated correctly: a policy
refusal is a statement about a frame, a security refusal is a statement about
an attacker, and answering the second at all is the leak.

Claims that contradicted the code:

- ADR 0007 justified not persisting re-seal provenance with "a restart already
  re-derives what it needs". It does not: the field is #[serde(skip)] and a
  restored entry replays verbatim. The ADR now states that cost instead of
  arguing it away.
- The spec's tri-state table filed all relay reconciliation under "not
  evaluated". Relay-reconciled removes do run an administrator check; only adds
  cannot. An implementation following the spec would have omitted a check whose
  absence lets any peer evict members from the fan-out cache.
- The telemetry rule "never add a catch-all arm" is absolute in the document
  security work routes to, while its one carve-out and the condition that makes
  it safe (a fixed token, never the input) lived in an unlinked ADR.
- message-delivery.md documented a telemetry reason field as carrying the
  relay's text, which is the leak the code exists to prevent, and capped retry
  backoff at 30s where the ladder runs to 300s.

Added residual risk R9: the service prefix family is signature-gated but
exempt from encryption, so discovery gossip and service request and response
bodies travel in cleartext. This is the one application-supplied payload
boundary 5 does not cover, and the threat model did not mention it at all.

Also: three "every absent input fails open" overstatements (an unresolvable
principal fails closed), R2's session-reset and never-lost claims qualified,
R1's members cache named as the fan-out send cache it actually is, the
forged-registration window swept into ADR 0014's triage table, two diagram
edges qualified where their own prose already was, the drain's policy arm given
the ack disposition its siblings state, BLE's refusal corrected from a safety
net to something the transport manager's fallback defeats, the priority alias
tolerance reattributed from the wire to the FFI boundary, and C5's constant-set
enumeration reconciled with its own count in both places it appears.
Third and final correction round. Two reviewers went over 4a9fe0c; one
approved the PR outright, the other found that the rewritten acknowledgement
rule had picked up a new wrong qualifier.

The acknowledgement rule, once more. Calling the identity bindings
"post-decrypt" is wrong for the session slot binding, which runs before any
AEAD precisely because every failure the MLS library raises below it happens
before it authenticates anything. R2 of the same document already said so. The
bullet also named three identity bindings where the code and
delivery-and-acks.md name four. Both fixed, and the policy row now says its
acknowledgement is an arrival-path rule: a frame that reaches the group buffer
and later resolves as a policy refusal is never acknowledged, which is what
group-message-lifecycle.md says one file over.

R1 understated a forged membership answer. The members cache it corrupts also
feeds the sealed rich payload gate, which requires every non-self member to be
known rich-capable. An attacker-chosen identifier is not, so the gate closes
and reply context and forward attribution move from inside the MLS AEAD to
hop-visible cleartext while media secrets are dropped. That defeats a control
this document lists by name, reached by the adversary that control names.

The spec claimed a working relay-side remove path. The check is right and
required, but a genuinely relay-originated remove is injected unattributed, so
its sender can never be an administrator and the frame is always dropped:
relay-native remove reconciliation is inert, and the live path is the removing
administrator's own signed notification. Stated as the consequence of the rule
that it is.

Recovered the one thing the restructure actually lost: the leaf identity
binding's mutation-check inventory, ten named sabotages plus the adversarial
fixture behind the mls crate's test-utils feature, without which the use-time
seam cannot be tested at all. ADR 0010 also gains the adopt-path ordering rule
and the "what would undo this" section every other ADR carries.

Also: the BLE fallback caveat swept into its two restatements, R9 no longer
claims a design nobody can point at, the outbox repark edge qualified to plain
DMs, and C5 notes that C6's retry fallbacks are a fifth set of the same shape.
@bahdotsh
bahdotsh merged commit 895f795 into main Aug 15, 2026
23 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant