5
0
Fork
You've already forked hark
0

feat: native cbcl-chat transport, V1 (SPEC-003 / IMPL-003) #8

Merged
hugooconnor merged 39 commits from feat/spec-003-chat-transport into main 2026年06月10日 06:41:57 +02:00

Implements V1 of the native cbcl-chat transport for hark — cbcl-chat SPEC-003 / IMPL-003. A hark agent joins an cbcl-chat channel directly over /chat/v1 as an ordinary Ed25519-signed member, no cbcl-router required.

What's here (all tested)

Module Role Tests
selector.rs RendezvousHash answerer-selection (argmin H(ask_id ‖ handle)), Selector trait 8
chat_frame.rs signed-frame codec (len ‖ payload ‖ 64-byte sig) + FrameSigner seam 5
identity.rs Ed25519 chat identity via ed25519-dalek 3
chat.rs /chat/v1 transport: connect, signed hello, join, recv/send loop 2 + 1 live

124/124 lib tests pass.

Live-verified

The ignored tests/chat_live.rs was run against a real cbcl-chat hub: a hark agent joins #general, the hub accepts the ed25519-dalek signature (libsodium verify-sender), and returns roomcfg + presence — the agent appears in the roster. The Ed25519/frame interop (the risky part) works end to end. This is SPEC-003 REQ-001 (signed membership).

Design notes

  • Selector = RendezvousHash, not VRF. No audited RFC-9381 ed25519 Rust crate exists (IMPL-003 OQ-003), so V1 uses RendezvousHash behind the pluggable Selector seam; the VRF is the gated upgrade.
  • Only crypto is standard Ed25519 (ed25519-dalek), mirroring the cbcl-chat browser frame format. The FrameSigner trait isolates it at one seam.
  • Reuses the transport-agnostic AgentStore, the DialectCache, and the cbcl_parser pipeline. chat.rs is an additive sibling to router.rs — the router transport is untouched, and a daemon can hold both connections at once.

Not in this PR (next)

Capability filter (REQ-002), the claim round + selection wiring (REQ-005/007), multi-channel join on one connection, the chat CLI subcommand + ChatConfig, and the two-agent ask→reply demo. The VRF selector stays deferred (Tier-1 gated).

🤖 Generated with Claude Code

Implements **V1 of the native cbcl-chat transport** for hark — `cbcl-chat` SPEC-003 / IMPL-003. A hark agent joins an `cbcl-chat` channel directly over `/chat/v1` as an ordinary Ed25519-signed member, no `cbcl-router` required. ## What's here (all tested) | Module | Role | Tests | |---|---|---| | `selector.rs` | RendezvousHash answerer-selection (`argmin H(ask_id ‖ handle)`), `Selector` trait | 8 | | `chat_frame.rs` | signed-frame codec (`len ‖ payload ‖ 64-byte sig`) + `FrameSigner` seam | 5 | | `identity.rs` | Ed25519 chat identity via `ed25519-dalek` | 3 | | `chat.rs` | `/chat/v1` transport: connect, signed `hello`, join, recv/send loop | 2 + 1 live | 124/124 lib tests pass. ## Live-verified The ignored `tests/chat_live.rs` was run against a real cbcl-chat hub: a hark agent joins `#general`, the hub **accepts the `ed25519-dalek` signature** (libsodium `verify-sender`), and returns `roomcfg` + `presence` — the agent appears in the roster. The Ed25519/frame interop (the risky part) works end to end. This is SPEC-003 **REQ-001 (signed membership)**. ## Design notes - **Selector = RendezvousHash, not VRF.** No audited RFC-9381 ed25519 Rust crate exists (IMPL-003 OQ-003), so V1 uses RendezvousHash behind the pluggable `Selector` seam; the VRF is the gated upgrade. - **Only crypto is standard Ed25519** (`ed25519-dalek`), mirroring the cbcl-chat browser frame format. The `FrameSigner` trait isolates it at one seam. - **Reuses** the transport-agnostic `AgentStore`, the `DialectCache`, and the `cbcl_parser` pipeline. `chat.rs` is an additive sibling to `router.rs` — the router transport is untouched, and a daemon can hold both connections at once. ## Not in this PR (next) Capability filter (REQ-002), the claim round + selection wiring (REQ-005/007), multi-channel join on one connection, the `chat` CLI subcommand + `ChatConfig`, and the two-agent ask→reply demo. The VRF selector stays deferred (Tier-1 gated). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The pure answerer-selection core for the cbcl-chat transport: argmin
H(ask_id ‖ handle) over the contenders, winner first, ranking drives the
liveness fallback. No keys, no proofs, no I/O — the VRF selector (gated)
will implement the same Selector trait later. 8 unit tests (determinism,
order-independence, dedup, argmin, empty/single).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
len(4,BE) ‖ payload ‖ 64-byte sig, byte-identical to the browser. Framing
is ungated; the signature is isolated behind the FrameSigner trait (Phase-2
ed25519-dalek plugs in here). NullSigner stub for now. 5 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persisted RFC 8032 Ed25519 keypair via ed25519-dalek — the agent's
on-wire identity, TOFU-enrolled by the hub. Implements FrameSigner, so it
plugs into the frame codec at the one crypto seam. Same primitive as the
browser (WebCrypto) + hub (libsodium); interoperable. 3 tests (sign/verify,
pubkey b64, persist/reload). Standard crypto; VRF still deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_chat_agent connects to /chat/v1, sends an Ed25519-signed hello to
join a channel, and bridges the socket to the daemon's inbound/outbound
queues (mirrors router.rs). Outbound = validated CBCL text, signed +
length-framed; inbound = payload extracted + enqueued (hub vouches for the
signer, like the browser).
LIVE-VERIFIED against a running hub: a hark agent joins #general and the
hub accepts the dalek signature (libsodium verify) — roomcfg + presence
returned, agent appears in presence. The ed25519/canonical-bytes interop
(the risky part) works. Ignored integration test tests/chat_live.rs.
Next: capability filter + claim round + RendezvousHash selection + recv/
reply CLI wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hark now grows a second transport (src/chat.rs) joining cbcl-chat /chat/v1
directly as a signed member (SPEC-003 / IMPL-003). Marked WIP — connect +
signed hello + join is live-verified; capability filter, selection, and the
chat CLI are not wired yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three findings from review of feat/spec-003-chat-transport:
- chat_frame: decode_payload now requires the frame to be exactly
 len(4) ‖ payload(len) ‖ sig(64). Previously it accepted any frame
 >= 4 + len, silently ignoring a missing/short signature and trailing
 junk. Truncated, over-long, or wrong-format frames are now rejected.
- identity: load_or_create only generates a new key on ErrorKind::NotFound.
 A transient read error (EACCES/EIO) no longer silently rotates the
 agent's identity. The seed is created atomically with create_new + mode
 0o600 (no post-write chmod race), and read/write errors propagate.
- chat: create_chat_agent now blocks on the hub's join verdict before
 returning Ok. It awaits the acknowledging roomcfg/presence frame, or
 surfaces a rejection (bad-signature / no-such-channel / forbidden-room,
 which the hub sends as `(error @room "slug")` with the socket left open)
 as ChatError::JoinRejected, with JoinTimeout as a backstop. Frames are
 classified by parsed performative, not substring. Rejection happens
 before any store entry exists, so there is nothing to clean up.
Live-verified against a running hub: accept path returns Ok (+ presence
flows), and entering a non-existent channel returns JoinRejected("no-such-channel").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat transport was a tested library module with no way to invoke it.
This integrates it through the existing subcommands, selecting router vs
chat from the configured hub URL's path — no new subcommands.
- config: ws_url path decides the transport (`/chat/v1` -> chat, else
 router). New [chat] section (channel default, per-handle identity_dir);
 validate_ws_url is shared, validate_chat applies defaults, and chat
 needs no auth token (it authenticates per frame by signature). Env
 overrides CBCL_CHAT_CHANNEL / CBCL_CHAT_IDENTITY_DIR.
- daemon create_agent branches on the transport. The chat branch loads or
 creates a per-handle Ed25519 key at <identity_dir>/<handle>.key, joins
 the channel as a signed member, and skips the @router auto-install step.
 recv/reply/error/progress/close are unchanged — they already operate on
 the local handle and are transport-agnostic.
- Each agent process is its own member: a per-process wire handle (--handle
 @name, required for chat) with its own key, enrolled independently by the
 hub (TOFU, handle -> key). The store now records the chat @handle as the
 agent's wire id so `status` reports the real identity.
- Private channels: create_chat_agent takes an optional cap, emitted as
 `:cap "<token>"` in the hello (--cap on init). Public channels omit it.
- Dialect subcommands return not_supported_on_chat_hub (no @router; SPEC-005
 per-channel dialects are a separate task).
Verified end-to-end against a live hub: `hark init --handle @x --channel
@general --dialect cite` joins and returns a handle; recv shows presence;
a second handle coexists; `dialect list` errors clearly; a non-existent
channel is rejected (no half-joined agent); key rotation for an enrolled
handle is correctly refused (bad-signature); key files are 0600.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires SPEC-003 REQ-002/005/007 into the chat transport. A capable agent now
answers asks decentrally, with no coordinator.
- chat_responder.rs: the decision core (pure, unit-tested). Lang-only — only
 `(lang <dialect> <inner>)` asks whose dialect is in the agent's advertised
 set are answerable; bare cite/poll are the hub's chat verbs, ignored. Built
 on the canonical cbcl_core::Message API (dialect_name / innermost_simple /
 thread + the cbcl-chat :from convention) so hark reads messages exactly as
 the hub does.
- Flow: broadcast `(claim @ch :on "<ask_id>" :from @h)`, collect claims for Δ,
 then RendezvousHash-rank the contenders. Rank 0 delivers the ask to `recv`;
 a rank-k agent that sees no `reply` on the thread within k·T takes over
 (liveness fallback). A reply cancels pending work. ask_id = the ask's
 :thread, else a content hash.
- chat.rs receive loop drives it: claims signed+sent inline, Δ/T timers via a
 FuturesUnordered, and ONLY elected asks are enqueued to `recv` (REQ-002
 filters everything else). recv/reply unchanged.
- config: [chat] claim_window_ms (Δ=400) and liveness_timeout_ms (T=2000).
- VRF selector stays Phase 3 / Tier-1-gated; the claim round is VRF-less.
Live-verified end-to-end against a running hub (with the lang-aware from-handle
fix): two agents both advertising `summarize`, a lang-wrapped ask injected ->
exactly one elected, the other receives nothing. 149 lib tests + 1 live test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SPEC-003 REQ-011 proactive path, and the one primitive a concierge agent
(SPEC-003 ADR-007) needs: emit an arbitrary validated (lang ...) message, not
only reply/error/progress. hark's send path forces a reply/error/progress shape
and rejects Dialect/Wrapped envelopes (UnsupportedWrapper), so a dispatch could
not go out before this.
Packaged as a new *kind* on the existing /send, not a new endpoint or CLI verb
(it's the smallest surface; agents send it via the local API):
- cbcl_validation::validate_for_dispatch — runs the full R1–R5 pipeline against
 the agent's learned dialects but does NOT force a reply shape or :thread, and
 accepts a (lang ...) Dialect/Wrapped message. Refuses (meta ...) and malformed.
 Not an R5 bypass: a known-dialect shape violation is still rejected (tested).
- local_api: SendMessageKind::Dispatch (serialises as "dispatch") + a branch in
 the /send handler routing it to validate_for_dispatch. V1 does not append a
 dispatch to the per-handle causal store (a proactive ask is not part of the
 agent's reply chain). No new route/endpoint/client method/CLI subcommand.
Live-verified (on the equivalent pre-refactor path): `@disp` emitted
`(lang summarize (do @general ... :from @disp :thread t1))`; the hub fanned it and
a summarize-capable responder was elected and recv'd it — the full concierge
mechanism. 152 lib tests pass (3 new), clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"dispatch" collided with SPEC-003's established term: capability dispatch is the
claim-round routing of an ask to a capable answerer (ADR-002, cbcl-router as
"the central dispatcher"). Naming the agent's proactive send "dispatch" overloaded
that and named a generic primitive (emit any agent-originated message) after one
consumer (the concierge).
Renamed the kind to `emit`, which names the generic capability without collision:
- SendMessageKind::Emit (wire value "emit"); validate_for_emit.
- "dispatch" is reserved for SPEC-003 capability routing and for the concierge's
 own act of routing intent to specialists (a fair use at that layer).
Pure rename — no behaviour change. 152 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The load-bearing first step of collapsing hark's two transports (bearer-token
router + bare-payload chat) onto the one SPEC-012 signed-member wire. src/
signed_frame.rs is the Rust peer of the LFE cbcl-signed-frame (cbcl-bus): the
wire frame len(4) ‖ seq(8) ‖ payload ‖ sig(64) and the domain-separated envelope
DS_TAG ‖ hub_id ‖ audience ‖ seq ‖ conn_nonce ‖ payload (length-prefixed).
Proven byte-for-byte identical to the LFE codec via envelope_matches_lfe_bus
(hardcoded known-good vector). Purely additive; full lib suite still green. The
transports (router.rs + chat.rs), conn_nonce/seq, the signed hello, and dropping
the Bearer header come next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rust peer of cbcl-frame-verify's signing side: SignedConn captures the hub's
conn_nonce + hub_id at connect and folds them (plus a per-connection monotonic
seq) into every frame's Ed25519 envelope signature. Adds the conn-nonce bootstrap
parser, the signed-member router hello builder ((hello @router :from @<id>
:key "..." :dialects (...)) replacing the old unsigned (lang cbcl-router (tell
@router "hello" ...))), and the router audience (the substrate handle "router" —
every agent->router frame resolves to it).
Test signed_frame_verifies_as_the_hub_would proves a SignedConn frame verifies
against the agent's key over the exact envelope the hub reconstructs. 159 lib
tests green. Wiring into router.rs/chat.rs (dropping the Bearer header) is 6c/6d.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gated (CBCL_ROUTER_WS) end-to-end test: connect to a running router, receive
the conn-nonce bootstrap, send a SignedConn-signed hello + a signed
(meta (query (list))), and assert the hub accepts both (a reply comes back, no
bad-signature). Ran green against the cbcl-bus cbcl_router release on
ws://localhost:18080/agent/v1 — proving hark's 6b signing core interoperates
with the LFE hub over the signed-member wire, with NO bearer token. De-risks the
production router.rs rewrite (6c).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire hark's router transport to SPEC-012. connect() drops the
Authorization: Bearer header (REQ-009: no bearer/challenge-response connection
auth — TLS secures the channel, identity is per-frame by signature).
create_router_agent now: receives the hub's conn-nonce bootstrap, builds a
SignedConn, sends a per-frame-signed (hello @router :from @<id> :key "..."
:dialects (...)), and threads the SignedConn + the agent's Ed25519 identity into
the receive loop, which signs EVERY outbound path (heartbeat, the send channel,
auto-rehello) over the domain-separated envelope.
6e (config): validate_router no longer requires auth_token (a legacy token is
accepted but ignored); add default_router_identity_path() — the daemon's stable
router key (replacing the bearer credential), TOFU-enrolled by the hub. local_api
loads/creates it and passes it in.
Validated end-to-end: tests/signed_handshake_live passes against the live
cbcl_router hub (ws://localhost:18080/agent/v1). 159 unit tests green; all test
targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move hark's chat transport off the old bare-payload codec onto the same
SignedConn envelope wire as the router (6c). create_chat_agent now captures the
chat hub's conn-nonce bootstrap and signs the hello + every outbound frame over
the domain-separated envelope; both transports share one signing core, differing
only in audience derivation:
 - router: audience = the substrate handle "router" (sigil stripped)
 - chat: audience = the message's recipient WITH its @ sigil (sign_chat_frame
 derives it per frame, matching cbcl-core-msg:room)
parse_conn_bootstrap accepts the chat @client bootstrap as well as the router
@agent one.
Fix: the chat hub bare-frames its hub->client frames (len ‖ payload ‖ sig),
unlike the router's raw text — recv_bootstrap now decode_payloads before reading
the bootstrap text (caught by live validation: the chat hello was never reaching
the hub until the bootstrap parsed).
Validated end-to-end: tests/signed_handshake_live passes for BOTH the router
(ws://localhost:18080/agent/v1) and the chat hub (ws://localhost:8080/chat/v1) —
signed hello accepted, join/reply received, no bearer token. 161 unit tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /agent/v1 connection no longer uses a shared-secret bearer token; identity is
per-frame Ed25519 over a domain-separated envelope. Update the docs to match:
 - router-protocol.md: rewrite Authentication (conn-nonce bootstrap + signed
 hello, no Authorization header) and the Hello Frame (new shape
 (hello @router :from @<id> :key "..." :dialects (...)), signed over the
 envelope; capability ≡ dialect).
 - config.md: drop auth_token from the example; document the router identity key
 (<config-dir>/router-agent.key, TOFU-enrolled); auth_token accepted-but-ignored;
 fix the Future Auth / Error Handling sections.
 - local-api.md + README: router_auth_rejected now means a proxy 401/403 (the hub
 has no connection auth); missing_router_auth_token is legacy. Drop the
 auth-token config/env/troubleshooting lines.
 - README callout: both transports now share one signed-member envelope wire,
 selected by the ws_url path; validated live against running hubs.
The daemon's *local loopback* bearer token (daemon.md / local-api.md) is
unrelated and unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After moving the router to per-frame Ed25519 auth, two things became dead:
 - ConfigError::MissingRouterAuthToken — never constructed (validate_router no
 longer requires a token). Removed the variant, its local-API mapping
 (missing_router_auth_token), and the cli error-code match arm.
 - ValidatedRouterConfig.auth_token — computed but never read (connect no longer
 sends an Authorization header). Removed the field; validate_router returns
 just { ws_url }; its Debug redaction is gone (no secret to redact).
Kept (still live): RouterConfig.auth_token is parsed but ignored, so old configs
still load (and it stays redacted in Debug); RouterError::AuthRejected remains —
it fires on a 401/403 WebSocket-upgrade refusal (e.g. a proxy), which the hub
itself never produces. Clarified its message + added a doc comment.
161 unit tests green; all test targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-ups:
- recv_bootstrap (router + chat) wrapped the hub's first-frame read in no timeout:
 a hub that accepts the WebSocket upgrade then stalls would hang
 create_*_agent forever, blocking the local-API request. Bound it (10s) and fail
 closed with "timed out waiting for the conn-nonce bootstrap".
- signed_frame::decode_frame computed `len + SIG_LEN` without checked_add (a u32
 len near usize::MAX could overflow/panic on 32-bit); now matches the defensive
 chat_frame::decode_payload with `checked_add`.
- removed the unused SignedConn::next_seq.
161 unit tests green; all test targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three hark-owned specs under USDD (intent-anchored):
- SPEC-013 (Tier-1, no-go): MLS so an agent can join encrypted private
 channels — binds the MLS leaf to the wire Ed25519 key; one group per
 connection; cbcl-bus/cbcl-chat affected.
- SPEC-014 (Tier-1, no-go): host->agent delegation — a signed, verifiable
 "agent A acts on behalf of host H" carried on announce; identity only.
- SPEC-016 (Tier-3): onboarding DX — one-shot hark join, in-app pairing,
 hark say, and selective dialect auto-learn (chosen subset, validated
 against the channel's declared menu via SPEC-015).
All draft; the two Tier-1 specs gate on cross-model adversarial review +
human crypto sign-off before implementation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- REQ-009: an agent responds only to asks in dialects the channel declares
 AND it speaks (it may speak more elsewhere); empty declared set => empty
 response scope. (The agent-side scoping model, hub stays non-gating.)
- REQ-007 / ADR-003: the pairing token is bound to {channel, invite-cap,
 chosen (name, digest) dialects}; hark pair installs + advertises them by
 digest (no --speak). The short code references a hub-minted pairing record;
 OQ-001 now covers the mint/redeem endpoints + record format.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- REQ-010: an agent's channel membership records added_by (the member who
 paired/invited it) as per-channel provenance, distinct from the agent's
 SPEC-014 host (who it acts for); roster displays both. Pairing record (REQ-007/
 OQ-001) carries the adder.
- OQ-004: agent-removal authz (added_by member vs host vs any member) — mirrors
 dialect delete-by-adder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SPEC-016 v0.2.0: decouple from host delegation. REQ-006 legibility = just
 "render as an agent"; REQ-010 = added-by provenance (no host claim); new
 REQ-011 (adder sets the agent's channel handle, carried by the pairing token,
 --as override); REQ-007/OQ-001 pairing record carries the name; OQ-004 drops
 the host option.
- SPEC-014 marked superseded: the chat model identifies an agent by its own key
 + handle and attributes it by added-by, so the host->agent delegation isn't
 needed. A Tier-1 no-go crypto spec drops off the path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPEC-016 v0.3.0, status approved (Tier-3 DX; the SPAKE2 pairing handshake is the
one Tier-1 carve-out). + the decisions record.
- OQ-001: pairing = memorable BIP39 phrase + SPAKE2 (reusing cbcl-crypto-spake2),
 releasing a record that wraps a cbcl-chat-invite cap + {name, dialects}. Room
 admission reuses invite-as-cap. The handshake is Tier-1/no-go.
- OQ-002: hark emit exposes the existing kind=emit; wire frames stay valid CBCL
 ((tell ...)), the chat client unwraps for display.
- OQ-003: self-hosted curl install (files.anuna.io/hark/install.sh); no Homebrew/
 releases; curl skips macOS quarantine.
- OQ-004: an agent is removable only by its added_by member (mirrors dialects).
DX scope is implementation-ready; the SPAKE2 pairing gates on its Tier-1 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-model review found the core defect: MLS membership is anchored to
hub-mediated TOFU and unchecked KeyPackages/Welcomes, not to authenticated wire
identity. Tier-1 stays BLOCKED; folded all findings into requirements.
New/strengthened requirements:
- REQ-008 (BUG-001): adder verifies target handle AND leaf key == PINNED wire key
 (not hub-asserted).
- REQ-011 (BUG-003): pin handle->wire-key from the member's own signed frames,
 never hub assertion.
- REQ-012 (BUG-002): app-bound Welcome validation (room + authorised committer;
 no silent group replacement).
- REQ-013 (BUG-004): enforced single-use KeyPackages; bound last-resort reuse.
- REQ-014 (BUG-005): MLS Commit-out on room removal (removal moved into scope).
- REQ-015 (BUG-006): validate keypub/keyget inputs before mutating directory.
- REQ-016 (BUG-007): roster/committer authenticity; split-group resistance.
- NFR-004: precise local-compromise model + retention policy.
OQ-001 (key reuse): no collision found — acceptable pending a regression test.
OQ-002 (authenticated trust root) + OQ-003 (roster authenticity) remain OPEN and
gate sign-off. Re-review of v0.2.0 required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 found round-1's REQs protected the adder's local path but not inbound
membership changes, sender authenticity, or the wire support REQ-011 assumes:
- REQ-017 (BUG-008): inspect every inbound Commit + pending proposal BEFORE merge;
 Add leaves must satisfy target+pinned-key; reject unauthorized proposals.
- REQ-018 (BUG-009): bind decrypted :from to the authenticated MLS sender leaf
 (reject mismatches) — stops member :from forgery.
- REQ-019 (BUG-010): a peer-verifiable identity/key-assertion wire contract;
 REQ-011's pinning is not implementable on today's hub-attested wire without it.
- REQ-013 corrected: single-use enforced CLIENT-side (transcript-visible
 KeyPackageRefs), not at the untrusted directory; OQ-004 reopened.
- REQ-020 (BUG-011): until the gate clears, the UI must not claim E2EE for private
 channels — IMMEDIATE action on the live deployment.
OQ-002/003/004 OPEN. Tier-1 BLOCKED; round-3 re-review required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug class was the missing MLS Authentication Service (the hub is the
untrusted Delivery Service). v0.4 designs it:
- ADR-006 / OQ-002: AS = invite-anchored TOFU + safety numbers (humans) +
 SPAKE2 pairing (agents). NOT admin-rooted (a central authority re-introduces
 a hub-like trusted party, antithetical to peer E2EE); key transparency is the
 documented strong-future (TOFU pins are forward-compatible).
- REQ-019 sharpened: self-signed (idkey @handle :key K :room :nonce) assertion,
 peer-verified (not hub-attested) — makes REQ-011 implementable.
- REQ-021: safety-number (fingerprint) verification — human first-contact MITM check.
- REQ-016 / OQ-003: committer + add/remove authority derive from the verifiable
 MLS ratchet tree, not hub presence; deterministic committer over MLS leaves.
OQ-002/003 now PROPOSED (resolved-direction); OQ-004 (replay) OPEN. Round-3
cross-model review + human crypto sign-off still gate implementation; REQ-020
(live-UI claim) still pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move KeyPackage replay defence client-side (the directory is the untrusted
hub): delete-on-use of one-time init private keys + consumed-KeyPackageRef
ledger + transcript-visible refs (REQ-013); one-time replenishment with a
bounded, documented last-resort whose forward-secrecy residual is explicitly
accepted (REQ-022). Resolve OQ-005 by retention minimisation — keep only
current-epoch secrets, prune superseded, version bump = re-join (NFR-004).
All five OQs are now RESOLVED-direction (PROPOSED). Remaining gate: round-3
cross-model review + human crypto sign-off; REQ-020 live-UI fix independent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-3 adversarial review (docs/decisions/SPEC-013-round3-review-findings.md)
found 2 Critical + 4 High. Fold each disposition into the specs:
SPEC-013 v0.6.0:
- R3-05 (Crit) → REQ-023 (new): pin channel enc mode; fail closed on hub downgrade.
- R3-07 (Crit) → REQ-017: validate every leaf-changing object (Add/Update/Remove +
 UpdatePath) + credential immutability; closes reopened :from forgery.
- R3-06 → REQ-014: authenticated removal evidence, not hub presence.
- R3-08 → REQ-012/016: checkable creator bootstrap + full-tree leaf-vs-pin.
- R3-01 → ADR-006 re-scoped (SPAKE2 = capability, not agent identity).
- R3-09/10/11/12/13/14 → REQ-013/022/006/021, NFR-004, REQ-019/OQ-001;
 new REQ-024 (hark safety-number surface).
SPEC-016 v0.4.0:
- R3-02/03/04 → REQ-007 + ADR-003 rewritten: storage is password-equivalent
 (not a one-way HMAC), pairing-specific transcript constants, failed-attempt
 bound, release bound to PAKE key K, enc-mode field in the pairing record.
Gate stays BLOCKED: dispositions folded, not cleared — round-4 confirmation +
human crypto sign-off still required (SPEC-013 §8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Isolated, gate-permitted experiment (detached crate, no production code). Pins
openmls 0.8.1 to match cbcl-mls-wasm and turns the round-3 "could NOT assess"
items into observed evidence:
- R3-07 (Crit): OpenMLS ACCEPTS a self-Update rebinding a leaf credential
 bob->alice, and the peer accepts it — empirically confirms REQ-017's
 credential-immutability clause is load-bearing (RFC 9420 does not enforce
 credential continuity across Update; the app must).
- R3-10: KeyPackageIn::validate() REJECTS a 1970-expired KeyPackage
 (InvalidLifetime) — REQ-022's lifetime bound is enforced by the primitive.
- R3-11/OQ-005: max_past_epochs bounds the past-message window (0 -> undecryptable,
 1 -> decryptable); confirmed knobs max_past_epochs / number_of_resumption_psks /
 sender_ratchet_configuration on MlsGroupJoinConfigBuilder — NFR-004 can cite
 these concretely. Durable-provider disk-delete remains unassessable in-memory.
- NFR-001: create->add->welcome->encrypt->decrypt round-trips through bytes at the
 pinned ciphersuite; authenticated sender leaf resolves (the REQ-018 hook).
Running (not just compiling) caught two probe bugs that would have produced false
evidence — Lifetime::new vs init, and max_past_epochs belonging on the decrypting
group's JoinConfig — both fixed and recorded in the README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. Storage-pruning probe (R3-11 mechanism). Enables openmls_rust_crypto/test-utils
 to read MemoryStorage byte size. Entry COUNT is a false metric (past epochs live
 inside one serialized blob); persisted BYTES is the right one. After 12 epoch
 changes: max_past_epochs(0) -> ~8.4 KB vs (12) -> ~36 KB (~2.3 KB/epoch retained).
 => OpenMLS prunes superseded epoch secrets from PERSISTED state, not just memory;
 a durable provider (ADR-004) honouring deletes inherits the bound. Residual
 narrowed to that provider's own on-disk fsync.
2. Cross-stack native <-> real .wasm harness (NFR-001). build-wasm.sh compiles the
 actual cbcl-mls-wasm for nodejs (same openmls 0.8.1 + wasm-bindgen 0.2.114);
 src/bin/native_peer.rs is a JSON-line stdio peer driving native OpenMLS;
 cross-stack/cross_stack.mjs drives the wasm binding. Both directions PASS:
 native->wasm (Welcome + app msg) and wasm->native (app msg). This is the genuine
 cross-stack confirmation (native vs the shipped artifact), not native<->native.
Generated wasm-node/ is gitignored (rebuild via build-wasm.sh). Full suite: 6 Rust
probes + the node harness all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assemble the round-4 confirmation packet:
- SPEC-013 v0.6.1: thread the §10 spike results into REQ-017 (R3-07 confirmed
 empirically — credential-rebinding self-Update accepted, clause (c) load-bearing),
 REQ-022 (validate() rejects expired KeyPackage), NFR-004 + OQ-004/005 (retention
 knobs confirmed, pruning reflected in persisted state), and §8 (spike DONE, what
 it did/didn't close). OpenMLS-primitive items the round-3 review flagged as
 unassessable are now confirmed; residuals narrowed to the durable provider's
 on-disk fsync + cbcl_ristretto point validation.
- Round-3 findings doc: add a §10-spike subsection mapping each could-not-assess
 item to its evidence.
- New round-4 review brief (fresh-context / cross-model, Principle 12): focused not
 on re-running round-3 but on confirming the v0.6 dispositions actually close
 R3-01..R3-08 AND hunting the new gap each fix might open (first-contact mode TOFU,
 Update vs legitimate rotation, genesis-assertion forgeability, bye liveness,
 safety-number epoch churn, enc-field authenticity). Spike evidence is a provided
 input; app-level closures are the reviewer's job.
Gate still BLOCKED: round-4 + human crypto sign-off outstanding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 4 confirmed R3-01/R3-02 closed but found 1 Critical + 3 High +
1 Medium + 1 Low (findings doc + the prompt that produced them recorded
under docs/decisions/). Dispositions folded:
- R4-01 (Crit) → REQ-023 rewritten: the encryption pin derives from the
 admission path (cap/invite ⇒ private ⇒ encrypted, pinned pre-send) or
 explicit operator intent; first-observation mode-TOFU removed; unknown
 private mode fails closed. SPEC-016 v0.5.0: the pairing `enc` field is
 advisory; the invite-cap presence is the signal.
- R4-02 (High) → REQ-014/REQ-017(d): signed removal-evidence object
 (own DS label, room/group/epoch/target-bound), verified at merge by
 every validator; the bye is fanned as evidence (affected-repo change);
 creator as liveness-fallback removal authority (PROPOSED).
- R4-03 (High) → REQ-016: genesis authoritative only with a pinned or
 independently authenticated creator key, else documented
 first-group-wins TOFU; durable delivery via a GroupContext app
 extension; invite links SHOULD carry the creator-key fingerprint.
- R4-04 (High) → REQ-021/REQ-024: stable identity safety number split
 from the volatile epoch state hash so out-of-band comparison survives
 normal Commits.
- R4-05 (Med) → REQ-011/REQ-017(e): cross-signed rekey rotation
 ceremony; fail-closed proposal allowlist.
- R4-06 (Low) → OQ-001 condition (2) marked done (code lands separately).
Gate stays BLOCKED pending round-5 confirmation + human crypto sign-off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat_frame::encode_frame signed arbitrary caller-chosen bytes with the
wire identity key under no domain separation — the raw-bytes signing
oracle OQ-001 conditions ADR-002's key reuse on retiring. Production
already signs only the domain-separated envelope via SignedConn; the
sole remaining caller was the (stale) live responder test, which signed
bare payloads the envelope-verifying hub now rejects anyway.
Remove encode_frame + NullSigner; keep decode_payload (hub→client
framing) and the FrameSigner seam (fed only envelope bytes by
SignedConn). The live test now bootstraps the signed-member wire like
production. Module docs prohibit reintroducing a bare-payload encoder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paste-ready brief for the round-5 confirmation (fresh context,
different model — Principle 12): trace REQ-011/014/016/017/021/023/024
to mechanisms closing R4-01...R4-05, hunt the new gap each fix may have
opened, and explicitly endorse/reject the two author design calls
(D-1 admission-path encryption pin; D-2 creator as removal-authority
liveness fallback). Verdict gates on the updated residual list the
human signer must accept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native probes (tests/genesis_extension.rs) + cross-stack harness
(genesis_probe.mjs) at openmls 0.8.1: Unknown(0xF013) GroupContext extension
round-trips create→add→welcome→read with leaf capabilities advertised
(pre-finalize read from StagedWelcome confirmed); the REAL shipped
cbcl-mls-wasm KeyPackage fails closed (InsufficientCapabilities); method
nuance — a default-capability creator is accepted at MlsGroup::new and the
group bricks on its first path-commit, so the create config must set the
capability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign-off record (docs/decisions/SPEC-013-tier1-signoff.md): residuals A–H
explicitly accepted by the project owner in a structured walk-through;
D-1/D-2 ratified; ADRs APPROVED conditional. Conditions: K round-6
independent-model spot-check (prompt drafted, blocks implementation merge —
Principle 12), A-t no-collision property test, I durable-provider delete
fidelity, J cbcl_ristretto audit (binds IMPL-016). v0.7.2 R5-03 probe
evidence threaded into §10/REQ-016; SPEC-016 → v0.6.0 (pairing handshake
conditionally cleared with SPEC-013).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Condition K satisfied: independent spot-check confirmed R5-01 (closed, with
the retry-cost caveat), R5-02, R5-03, and endorsed D-1/D-2 — no re-block
(docs/decisions/SPEC-013-round6-spotcheck-findings.md). Carries folded:
K-1 remove-race retry behaviour + concrete-leaf binding clarification
(REQ-014, §9 test); K-2 creator-capability creation-time guard (REQ-016,
§9 test). Gate: CLEARED for implementation; IMPL-bound conditions A-t, I,
J, K-1, K-2. SPEC-016 → v0.6.1 (pairing handshake cleared, J binds
IMPL-016).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hugooconnor deleted branch feat/spec-003-chat-transport 2026年06月10日 06:41:58 +02:00
Sign in to join this conversation.
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna/hark!8
Reference in a new issue
anuna/hark
No description provided.
Delete branch "feat/spec-003-chat-transport"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?