Implements the hark side of SPEC-013 (Tier-1 gate CLEARED 2026年06月10日), planned and executed via hence (plans/impl-013-mls.spl, 11/11 tasks done).
What
New src/mls/ subsystem (provider, pins, keypackages, group, validation, removal, safety, session) wired into the chat transport, local API, and a new hark safety-number command:
- Identity binding (REQ-007/ADR-002): MLS leaf signer is the wire Ed25519 key, byte-for-byte.
- Durable state (ADR-004, REQ-009, NFR-004): 0600 atomic snapshots under
identity_dir; deletes verified absent on disk (condition I); version bump = re-join.
- Pins (REQ-011/019, condition A-t): TOFU pins that flag-never-rotate;
idkey/rekey ceremonies under dedicated DS labels; cross-protocol no-collision property test.
- KeyPackages (REQ-002/013/015/022): durable consumed-ref ledger; failed Welcome validation can never burn an init key (provider rollback); last-resort lifetime enforced by the primitive.
- Group lifecycle (REQ-001/003/004/008/012/016, condition K-2): genesis GroupContext extension with creation-time capability guard; election over MLS leaves; full-tree pin-checked Welcome validation.
- Merge gate (REQ-005/006/017/018): rejects the spike-proven R3-07 credential rebind; fail-closed proposal allowlist (genesis immutable); removal evidence verified by every validator; sender-authenticated
:from; drop-but-count fork signal.
- Removal (REQ-014, condition K-1): exact-epoch evidence; race-then-retry path tested; creator fallback; third-party evidence rejected.
- Safety numbers (REQ-021/024): pinned byte-exact encoding + cross-stack vector; stable across ratchet commits; offline CLI surface.
- Mode pin (REQ-023, NFR-002): derived from the admission path (cap ⇒ encrypted), persisted;
roomcfg :enc false downgrade refused fail-closed; no plaintext fallback anywhere.
Verification
- 210 lib tests + 2 integration tests pass (52 new); clippy clean on the new code.
- REQ→TEST trace:
docs/decisions/IMPL-013-trace.md.
- Pre-existing live-router suites (
router_integration, e2e_mvp, agent_workflow_cli) fail identically on clean main — unrelated.
Affected repos (changes staged locally, not in this PR)
- cbcl-chat: verified
bye fanned room-wide as removal evidence before leave; creator handle recorded at claim (59/59 eunit tests pass).
- cbcl-mls-wasm:
Identity::from_wire_key, genesis capability + K-2 guard, genesis()/member_handles() (8/8 tests, wasm32 clean).
- cbcl-bus web: election from MLS leaves with graceful fallback. Known seam: existing web identities are WebCrypto non-extractable — wire-key binding needs an identity-regeneration path (TODO in
app.js); browser wasm artifact needs re-vendoring.
🤖 Generated with Claude Code
Implements the hark side of SPEC-013 (Tier-1 gate CLEARED 2026年06月10日), planned and executed via hence (`plans/impl-013-mls.spl`, 11/11 tasks done).
## What
New `src/mls/` subsystem (provider, pins, keypackages, group, validation, removal, safety, session) wired into the chat transport, local API, and a new `hark safety-number` command:
- **Identity binding (REQ-007/ADR-002):** MLS leaf signer is the wire Ed25519 key, byte-for-byte.
- **Durable state (ADR-004, REQ-009, NFR-004):** 0600 atomic snapshots under `identity_dir`; deletes verified absent on disk (**condition I**); version bump = re-join.
- **Pins (REQ-011/019, condition A-t):** TOFU pins that flag-never-rotate; `idkey`/`rekey` ceremonies under dedicated DS labels; cross-protocol no-collision property test.
- **KeyPackages (REQ-002/013/015/022):** durable consumed-ref ledger; failed Welcome validation can never burn an init key (provider rollback); last-resort lifetime enforced by the primitive.
- **Group lifecycle (REQ-001/003/004/008/012/016, condition K-2):** genesis GroupContext extension with creation-time capability guard; election over MLS leaves; full-tree pin-checked Welcome validation.
- **Merge gate (REQ-005/006/017/018):** rejects the spike-proven R3-07 credential rebind; fail-closed proposal allowlist (genesis immutable); removal evidence verified by every validator; sender-authenticated `:from`; drop-but-count fork signal.
- **Removal (REQ-014, condition K-1):** exact-epoch evidence; race-then-retry path tested; creator fallback; third-party evidence rejected.
- **Safety numbers (REQ-021/024):** pinned byte-exact encoding + cross-stack vector; stable across ratchet commits; offline CLI surface.
- **Mode pin (REQ-023, NFR-002):** derived from the admission path (cap ⇒ encrypted), persisted; `roomcfg :enc false` downgrade refused fail-closed; no plaintext fallback anywhere.
## Verification
- 210 lib tests + 2 integration tests pass (52 new); clippy clean on the new code.
- REQ→TEST trace: `docs/decisions/IMPL-013-trace.md`.
- Pre-existing live-router suites (`router_integration`, `e2e_mvp`, `agent_workflow_cli`) fail identically on clean `main` — unrelated.
## Affected repos (changes staged locally, not in this PR)
- **cbcl-chat:** verified `bye` fanned room-wide as removal evidence before leave; creator handle recorded at claim (59/59 eunit tests pass).
- **cbcl-mls-wasm:** `Identity::from_wire_key`, genesis capability + K-2 guard, `genesis()`/`member_handles()` (8/8 tests, wasm32 clean).
- **cbcl-bus web:** election from MLS leaves with graceful fallback. Known seam: existing web identities are WebCrypto non-extractable — wire-key binding needs an identity-regeneration path (TODO in `app.js`); browser wasm artifact needs re-vendoring.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
New src/mls/ subsystem wired into the chat transport, local API, and CLI:
- identity binding: the MLS leaf signer IS the wire Ed25519 key (REQ-007/ADR-002)
- durable OpenMLS provider under identity_dir with on-disk delete fidelity,
0600 atomic snapshots, version-bump-is-a-rejoin (ADR-004, REQ-009, NFR-004,
condition I)
- handle→wire-key pin store with idkey/rekey ceremonies under their own DS
labels, plus the OQ-001 no-collision property test (REQ-011, REQ-019,
condition A-t)
- KeyPackage lifecycle: durable consumed-ref ledger, delete-after-successful-
join ordering via provider rollback, primitive-enforced last-resort lifetime
(REQ-002, REQ-013, REQ-015, REQ-022)
- group lifecycle: genesis GroupContext extension with creation-time
capability guard (condition K-2), leaf-derived owner election, REQ-008 adder
verification, full-tree pin-checked Welcome validation (REQ-001/003/004/012/016)
- inbound merge gate: credential immutability (rejects the spike-proven R3-07
rebind), fail-closed proposal allowlist, merge-time removal evidence,
sender-authenticated :from, drop-but-count fork signal (REQ-005/006/017/018)
- authenticated removal: exact-epoch signed evidence with the race-retry path
(REQ-014, condition K-1)
- safety numbers: pinned byte-exact identity-number encoding + epoch state
hash, surfaced via `hark safety-number` (REQ-021, REQ-024)
- session wiring: encryption-mode pin from the admission path, downgrade
fail-closed, no plaintext fallback (REQ-023, NFR-002)
52 new tests (unit + tests/mls_private_channel.rs); REQ→TEST trace in
docs/decisions/IMPL-013-trace.md; plan + execution journal in
plans/impl-013-mls.spl (hence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the no-trigger gap: MlsSession::create_group_as_creator existed and was
tested but no CLI/daemon path reached it, so a hark agent could join an
encrypted channel but never own/create the group over the live wire. The new
--mls-create flag (plumbed init -> CreateAgentRequest -> create_chat_agent ->
post-join, REQ-016 operator intent) calls it after the join ack when the
channel is pinned encrypted, before publishing KeyPackages, so the agent is the
sole member / elected owner and adds present members on presence.
Verified live against the cbcl-bus hub: the agent bootstraps the group (genesis
present, epoch 0, creator recorded, `hark safety-number` reports it).
Also documents the live playtest findings in docs/decisions/IMPL-013-trace.md,
including the one gap that blocks a 2-member group over the live wire: the
`idkey` pin assertion (REQ-019) is fanned once at join to then-connected members
and never replayed to late joiners, so the creator can't pin a peer to satisfy
REQ-008's adder check. Fix direction recorded; it's a REQ-019/REQ-011
distribution refinement independent of this trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Planning doc for SPEC-016 REQ-007 pairing. Key findings: no Rust SPAKE2 exists
(only an LFE module in cbcl-bus on the cbcl_ristretto NIF), so hark must build
an interoperable Rust SPAKE2 or take a shared NIF; the REQ-023 enc-mode pin
chain already works unchanged (pairing just feeds the record's cbcl-chat-invite
cap into CreateAgentRequest.cap, pinning enc=true via cap presence); `hark pair`
is a new Command variant mirroring init_command.
Records the ADR-006 caveat (pairing anchors capability + name, NOT MLS
identity), the three residuals a reviewer must accept, and a phased split:
Phase A (CLI shape, BIP39 parsing, record→request plumbing, cbcl-bus minting)
is safe to build now; Phase B (the SPAKE2 handshake) is Tier-1 and blocked on
condition J (the cbcl_ristretto point-validation audit) until a cross-model
review + human crypto sign-off clear it, per PROTO-001.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes for the live-playtest gap where a 2-member group never formed (the
owner could never pin a peer to satisfy the REQ-008 adder check):
1. **Address idkey to the room, not the handle.** The hub fans a frame only to
members of the room it is addressed to; hark addressed `(idkey @handle ...)`,
so the hub rejected it `not-a-member` and never fanned it — no peer ever
received an idkey (every pin store held only `self`). Now `(idkey @room
:handle @h ...)`, matching the contract `deliver`/`welcome` already use. The
signed context (handle, key, room, nonce) is unchanged.
2. **Re-broadcast idkey when a new member appears in presence.** The hub fans an
idkey once at join to then-connected members; a peer that joined earlier (or
the owner joining later) never receives it. On observing a new handle in
`presence` a member re-announces its idkey, and pinning a newly-seen member
(on_idkey) now immediately emits the owner's keyget — so the Add is gated on
having the pin and never races ahead of it, regardless of frame arrival order.
New test `owner_joining_after_peer_still_adds_via_idkey_rebroadcast` drives the
exact late-joiner ordering from the playtest and asserts the 2-member group
forms with agreeing safety numbers. 211 lib tests pass.
NOTE: verified at the session/frame level (unit). The live two-hark-agent
exchange over the running hub is NOT yet confirmed — see IMPL-013-trace.md;
diagnosing the live path is blocked on daemon log observability in the sandbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes found by tracing the live hub flow, on top of the idkey-rebroadcast
work — together they let two hark agents form a shared MLS group and exchange
encrypted messages over the real hub:
1. **idkey must carry `:from`.** The hub rejects any room-fanned frame without
a `:from` attribution (`error "missing-from"`) — so the idkey, which carried
the asserter in `:handle`, was dropped and never reached peers (no agent
ever pinned another). The asserter IS the sender, so the frame now uses
`:from @handle`, matching the `deliver`/`welcome` contract; on_idkey reads
the asserter from `:from`. The signed context is unchanged.
2. **Welcome committer check must use the pre-add roster.** REQ-012(b) elected
the owner over the delivered (post-add) tree, so when the creator bootstraps
the first member the new 2-member tree's elected owner could be the
newcomer, not the committing creator — the joiner then rejected its own
Welcome ("elected owner is @other"). The committer's authority comes from
owning the group BEFORE the Add, so we now elect over the tree minus the
joiner: the creator authorises the bootstrap Add, and the current elected
owner authorises steady-state adds (REQ-016).
Verified LIVE against the cbcl-bus hub: `@finch --mls-create` and `@sparrow`
form a group (identical REQ-024 identity safety number on both), and a `hark
reply` from @finch is delivered as an MLS `deliver` frame that @sparrow
decrypts back to the exact plaintext. 211 lib tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
High-effort code review surfaced four real defects:
1. **Durable consumed-ref ledger was dead code (REQ-013).** join_from_welcome
recorded refs via welcome_refs_of_staged(), which was hardcoded to return
Vec::new() — so the single-use ledger stayed empty forever and its
replay-rejection could never fire. Capture the refs before staging and
mark them consumed on a successful join; drop the dead helper. Regression
test asserts the ledger persists and a replayed Welcome is rejected.
2. **TOFU genesis was promoted to Authoritative on restart.** MlsSession::open
reloaded every group as Authoritative, erasing the TofuRequiresSafetyNumber
grade across a daemon restart (HP-3) — defeating the safety-number control
the whole REQ-016/ADR-006 first-contact mitigation leans on. Persist the
grade (tofu_pending) and restore it faithfully.
3. **Duplicate-member Add.** add_member didn't check the target wasn't already
a member, so an unsolicited/replayed keypkg for an existing handle (fresh
one-time ref) would seat a second leaf — corrupting the election, the
REQ-021 safety number (a false fork vs. web peers), and seating a duplicate
decryptor. Reject an Add whose handle is already a member. Regression test.
4. **persist_meta non-atomic.** The resume-state meta file used a plain
fs::write; a crash mid-write truncates it and silently forces a re-join
(REQ-009). Use the temp+fsync+rename pattern the provider/pins/ledger use.
Documented residuals (not fixed; fail-closed or accepted): the added_by adder
is not yet in the removal-authority allowlist (over-restrictive vs REQ-014(b),
needs added_by tracking); genesis-capability is not re-checked on Update leaves
(openmls enforces at the protocol layer); unbounded non-owner self-update epoch
churn (K-1 availability residual). 213 lib + 2 integration tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>