ADR-0015: String encoding tracking

Status: Accepted

Context

JavaScript strings are WTF-16: sequences of 16-bit code units with no requirement that surrogate pairs be well-formed (String.fromCharCode(0xD800) is a single unpaired high surrogate). The WebAssembly Component Model string type, by contrast, is a sequence of Unicode scalar values, canonically transferred as UTF-8 — there is no UTF-8 encoding for an unpaired surrogate.

When a JS string crosses a Component boundary today it must be scanned for scalar correctness and re-encoded WTF-16 → UTF-8, which allocates and copies. For long strings, or high-frequency small-string transfers (logging, RPC, structured output), this dominates the boundary cost.

In practice most strings at a boundary originate from sources that cannot introduce unpaired surrogates (source literals, JSON.parse, TextDecoder.decode, concatenations of such strings). If the compiler can prove a string is well-formed UTF-8, the boundary can use a cheap path.

This builds on ADR-0013 (explicit allocation sites): encoding annotations are attached to string AllocSites via the registry's namespaced metadata channel.

Decision

Introduce a static, advisory encoding lattice and a forward analysis pass (src/ir/analysis/encoding.ts) that annotates string allocation sites in the AllocSiteRegistry under the encoding namespace (ALLOC_NAMESPACES.encoding). The analysis never mutates the IR and is inert at lowering, so emitted Wasm is byte-identical whether or not it runs.

Lattice

              wtf16            (top — most permissive, conservative default)
                │
          utf8-guaranteed
                │
              ascii            (bottom — strongest claim, code points ≤ 0x7F)

ascii ⊑ utf8-guaranteed ⊑ wtf16. The join (least upper bound) is the conservative combination: any operand wtf16 forces wtf16; otherwise utf8-guaranteed, unless both operands are ascii (then ascii).

Left Right Result
ascii ascii ascii
ascii utf8-guaranteed utf8-guaranteed
utf8-guaranteed utf8-guaranteed utf8-guaranteed
any wtf16 wtf16

Origin rules (Phase 1)

Phase 2 origins (deferred — see below): JSON.parse (UTF-8 per RFC 8259), JSON.stringify (escapes lone surrogates per ES2019+ §24.5.2.2), TextDecoder.decode (UTF-8 per WHATWG Encoding), fetch().text().

Propagation rules (Phase 1)

Phase 2 propagation (deferred): .toUpperCase/.toLowerCase/.trim/ .normalize (preserve), .slice on statically known code-point boundaries (preserve else drop), .repeat/.padStart/.padEnd (preserve), .split/.replace (conditional). Any operation without an explicit rule drops to wtf16.

Component Model boundary integration (deferred to Phase 2/3)

The boundary lowering will read the annotation and select a path: zero-copy for ascii/utf8-guaranteed (when the runtime string representation permits), and the existing scan-and-encode path for wtf16. Two storage strategies are on the table — dual storage ((array i8) for proven-UTF-8 allocation sites, (array i16) for WTF-16) and a lazy re-encoding cache. Dual storage is preferred for new allocations because it eliminates the copy; it requires the annotation to be available before storage layout is committed. Neither is implemented in Phase 1; the analysis lands first so the boundary work has data to consume.

Why this scope for Phase 1

The issue (#1588) targets a useful initial version: lattice + analysis pass + the canonical origin/propagation set, banking the infrastructure that the boundary work depends on. The only string-producing IR instrs that currently carry an allocation-site id are string.const and string.concat, so those are the two rules the analysis can attach annotations to today. Call-result origins (JSON.parse, TextDecoder) and method propagation require the IR to mint string alloc ids on those results first — tracked as Phase 2 follow-up.

Phase 2 status

PR-A (landed, still inert). The builder now mints a "string" allocation-site id on call and extern.call instrs whose result type is string (emitCall / emitExternCall). This is inert at lowering — the emitted Wasm is byte-identical — and gives the analysis an attachment point for call-result rules:

PR-B part 1 (landed, gated scaffolding — inert when off). The dual-storage foundation, all behind --utf8-storage (default OFF, implies nativeStrings on the WasmGC backend):

PR-B part 2 (landed — storage path now active under --utf8-storage).

Still open (PR-B follow-up / PR-C): the alias-fusion soundness guard (issue §4) — before any future CSE may fuse string sites, enforce that a wtf16 site never aliases into a utf8 canonical (no string-fusing pass exists today, so this is a forward guard, not a present bug). Concat-result i8 storage (string.concat still produces i16; only literals take i8 in this PR — concat decodes its operands via flatten, so it stays correct).

PR-C (landed — revised scope). The original PR-C plan presumed a Component-Model encode-import infrastructure on the WasmGC backend that does not exist yet (no CM adapter, no boundary-lowering pass that reads the annotation, no declared encode imports). Edge B "import selection" therefore could not be built without first building that adapter — an infrastructure gap, not a wiring change. PR-C was rescoped to ship the missing transcode primitive that the boundary will consume, and to document the two boundary edges so the deferred work has a clear contract.

Delivered:

The two boundary edges (design unchanged from "## Phase 2 ABI Plan" §2 in the #1588 issue; deferred to #1650):

Consequences

References