Expose canonical message recipient (audience) from the parser (WASM + NIF) to kill the browser's hand-rolled derivation #11

Open
opened 2026-06-07 00:54:16 +00:00 by anuna-02 · 0 comments
anuna-02 commented 2026-06-07 00:54:16 +00:00 (Migrated from codeberg.org)

Summary

The parser does not expose a message's canonical recipient to consumers. Every
consumer therefore re-derives it, and the one consumer that cannot reuse the
parser — the browser chat client — hand-rolls a second S-expression parser to do
so. Because that recipient is signed into the SPEC-012 signed-member envelope as
audience, any drift between the hand-rolled derivation and the canonical parser
silently breaks every frame with bad-signature. This has already produced one
critical defect.

This issue asks cbcl-rs to expose the canonical recipient from the parser surface
(WASM, and ideally the NIF), so all consumers source it from one compiled
implementation instead of re-implementing it.

Background: where audience comes from

The signed-member envelope (SPEC-012 v0.5.2, REQ-001) binds the recipient into the
Ed25519 signature:

sig = Ed25519(sk, DS_TAG ‖ hub_id ‖ audience ‖ seq ‖ conn_nonce ‖ payload)

For a frame to verify, the audience bytes the client signs over must be
byte-for-byte identical to what the hub independently reconstructs. audience is
the canonical recipient of the message — the innermost recipient after descending
(lang <dialect> <inner>) wrappers, with the @ sigil retained.

The fragility

The recipient rule currently exists in two implementations:

  1. Hub side (LFE). Derives the recipient from the canonical parser's structured
    output (the NIF-parsed message map / cbcl-core-msg:room / innermost).
    Authoritative — it consumes the real parser.

  2. Browser client (app.js in the cbcl-bus umbrella, apps/cbcl_chat).
    Imports parse_message from cbcl_wasmthe same parser, compiled to WASM
    but parse_message returns only the canonical string
    (cbcl_wasm.d.ts: parse_message(input: string): string). To get the
    recipient it then re-parses that string with a hand-rolled
    parseSexpr + recipientOf (app.js ~lines 187, 267), approximating the hub's
    rule in JavaScript.

That hand-rolled shadow is the fragility:

  • It is the only place the recipient rule lives outside the canonical parser.
  • It has already caused a critical defect — the @-sigil byte-parity bug, where
    the client stripped the sigil the hub retains and every frame failed
    bad-signature.
  • It does not replicate the full rule (recipient-less terminals, meta/teach
    handling, fail-closed on ambiguous/multi-valued), so any non-plain-tell frame is
    a latent silent-total-failure.
  • Drift is undetectable until the wire breaks, and surfaces only as bad-signature.

Root cause

The browser already ships the canonical parser (cbcl_wasm_bg.wasm — the same Rust
code the hub runs as a NIF), but the WASM surface exposes only parse_message → String, not the recipient. The browser holds the authoritative parser and is forced
to ignore it for this one field, re-implementing recipient extraction by hand.

Proposed change (in cbcl-rs)

Expose the canonical recipient from the parser surface, backed by the same core
function the NIF/cbcl-core-msg uses (no new implementation of the rule):

  • Add to crates/cbcl-wasm (src/lib.rs) a function such as:
    /// Return the canonical recipient (audience) of a CBCL message — the innermost
    /// recipient after descending `(lang …)` wrappers, sigil retained — matching
    /// exactly what the hub binds as the signed-member envelope `audience`.
    #[wasm_bindgen]
    pub fn recipient_of(input: &str) -> Result<String, JsValue>;
    
    (Alternatively / additionally, give parse_message a structured JSON variant that
    includes the recipient, so the client gets canonical form + recipient in one call.)
  • If useful for the LFE hubs, expose the same via the cbcl-erl NIF so the hub
    reads the recipient from the parser rather than walking the parsed map.
  • Back both with one shared core recipient/innermost function so there is a
    single source of truth across NIF and WASM.

Acceptance criteria

  • WASM exports a recipient accessor returning bytes byte-identical to what the
    hub binds as audience, for the same canonical message.
  • Implemented via the same code path as the NIF/core — no second rule.
  • Downstream: app.js can delete recipientOf/parseSexpr (for audience) and call
    the WASM accessor; the hand-rolled shadow is removed.
  • A parity/golden test across a corpus covering: plain tell/ask, reply and
    other recipient-less terminals, meta and teach, (lang …)-wrapped messages,
    and ambiguous/multi-recipient (fail-closed) cases.

Downstream consumers to update (tracked separately, in cbcl-bus)

  • apps/cbcl_chat/priv/web/app.js — replace recipientOf/parseSexpr audience
    derivation with the WASM accessor; re-vendor the rebuilt
    cbcl_wasm.js / cbcl_wasm_bg.wasm into apps/cbcl_chat/priv/web/cbcl/.
  • Confirm the LFE hub recipient derivation (cbcl-core-msg:room /
    cbcl-frame-recipient) stays the reference, or is also repointed at the shared
    core.

Refs

  • SPEC-012 signed-member protocol, REQ-001 / CON-002 (v0.5.2 canonical
    per-performative recipient; envelope audience == payload recipient).
  • Slice-5 hub_core extraction plan — "audience byte-parity defect" review finding.
## Summary The parser does not expose a message's **canonical recipient** to consumers. Every consumer therefore re-derives it, and the one consumer that *cannot* reuse the parser — the browser chat client — hand-rolls a second S-expression parser to do so. Because that recipient is signed into the SPEC-012 signed-member envelope as `audience`, any drift between the hand-rolled derivation and the canonical parser silently breaks **every** frame with `bad-signature`. This has already produced one critical defect. This issue asks `cbcl-rs` to expose the canonical recipient from the parser surface (WASM, and ideally the NIF), so all consumers source it from one compiled implementation instead of re-implementing it. ## Background: where `audience` comes from The signed-member envelope (SPEC-012 v0.5.2, REQ-001) binds the recipient into the Ed25519 signature: ``` sig = Ed25519(sk, DS_TAG ‖ hub_id ‖ audience ‖ seq ‖ conn_nonce ‖ payload) ``` For a frame to verify, the `audience` **bytes** the client signs over must be byte-for-byte identical to what the hub independently reconstructs. `audience` is the *canonical recipient* of the message — the innermost recipient after descending `(lang <dialect> <inner>)` wrappers, with the `@` sigil retained. ## The fragility The recipient rule currently exists in **two** implementations: 1. **Hub side (LFE).** Derives the recipient from the canonical parser's structured output (the NIF-parsed message map / `cbcl-core-msg:room` / `innermost`). Authoritative — it consumes the real parser. 2. **Browser client (`app.js` in the `cbcl-bus` umbrella, `apps/cbcl_chat`).** Imports `parse_message` from `cbcl_wasm` — *the same parser, compiled to WASM* — but `parse_message` returns only the canonical **string** (`cbcl_wasm.d.ts`: `parse_message(input: string): string`). To get the recipient it then **re-parses that string** with a hand-rolled `parseSexpr` + `recipientOf` (`app.js` ~lines 187, 267), approximating the hub's rule in JavaScript. That hand-rolled shadow is the fragility: - It is the only place the recipient rule lives **outside** the canonical parser. - It has already caused a **critical** defect — the `@`-sigil byte-parity bug, where the client stripped the sigil the hub retains and *every* frame failed `bad-signature`. - It does not replicate the full rule (recipient-less terminals, `meta`/`teach` handling, fail-closed on ambiguous/multi-valued), so any non-plain-`tell` frame is a latent silent-total-failure. - Drift is undetectable until the wire breaks, and surfaces only as `bad-signature`. ## Root cause The browser already ships the canonical parser (`cbcl_wasm_bg.wasm` — the same Rust code the hub runs as a NIF), but the WASM surface exposes only `parse_message → String`, not the recipient. The browser holds the authoritative parser and is forced to ignore it for this one field, re-implementing recipient extraction by hand. ## Proposed change (in `cbcl-rs`) Expose the canonical recipient from the parser surface, backed by the **same** core function the NIF/`cbcl-core-msg` uses (no new implementation of the rule): - Add to `crates/cbcl-wasm` (`src/lib.rs`) a function such as: ```rust /// Return the canonical recipient (audience) of a CBCL message — the innermost /// recipient after descending `(lang …)` wrappers, sigil retained — matching /// exactly what the hub binds as the signed-member envelope `audience`. #[wasm_bindgen] pub fn recipient_of(input: &str) -> Result<String, JsValue>; ``` (Alternatively / additionally, give `parse_message` a structured JSON variant that includes the recipient, so the client gets canonical form + recipient in one call.) - If useful for the LFE hubs, expose the same via the `cbcl-erl` NIF so the hub reads the recipient from the parser rather than walking the parsed map. - Back both with one shared core `recipient`/`innermost` function so there is a single source of truth across NIF and WASM. ## Acceptance criteria - WASM exports a recipient accessor returning bytes **byte-identical** to what the hub binds as `audience`, for the same canonical message. - Implemented via the same code path as the NIF/core — no second rule. - Downstream: `app.js` can delete `recipientOf`/`parseSexpr` (for audience) and call the WASM accessor; the hand-rolled shadow is removed. - A parity/golden test across a corpus covering: plain `tell`/`ask`, `reply` and other recipient-less terminals, `meta` and `teach`, `(lang …)`-wrapped messages, and ambiguous/multi-recipient (fail-closed) cases. ## Downstream consumers to update (tracked separately, in `cbcl-bus`) - `apps/cbcl_chat/priv/web/app.js` — replace `recipientOf`/`parseSexpr` audience derivation with the WASM accessor; re-vendor the rebuilt `cbcl_wasm.js` / `cbcl_wasm_bg.wasm` into `apps/cbcl_chat/priv/web/cbcl/`. - Confirm the LFE hub recipient derivation (`cbcl-core-msg:room` / `cbcl-frame-recipient`) stays the reference, or is also repointed at the shared core. ## Refs - SPEC-012 signed-member protocol, REQ-001 / CON-002 (v0.5.2 canonical per-performative recipient; envelope `audience` == payload recipient). - Slice-5 hub_core extraction plan — "audience byte-parity defect" review finding.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
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-research/cbcl-rs#11
No description provided.