Files
paseo/docs/protocol-validation.md
T
babo-jeongukandClaude Opus 5.5 df82edd218 Split the inbound WebSocket validator per message type (#6847)
* Split the inbound WebSocket validator per message type

zod-aot compiled WSOutboundMessageSchema into one 11 MB function. Every
inbound frame, heartbeats included, ran through it, so V8 hosts (daemon
clients, plugin hosts, the CLI, Electron) eventually Maglev-compiled the
whole function: about 800 MB of compiler memory and multi-second stalls,
again after each deoptimization.

Generate a tag-only envelope validator plus one validator per session
message, and dispatch on the message type. Issue paths, defaults and
passthrough behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use forward slashes in generated validator imports

path.relative() returns backslash paths on Windows, which break the
import specifiers written into the generated files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Declare the session export helper as a function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:08:03 +02:00

4.4 KiB

Protocol Validation

The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.

The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for JSON.parse plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.

Runtime Path

packages/protocol/src/validation/ws-outbound.ts is the shipped boundary. It validates the envelope with a generated validator that checks only each session message's type tag, then runs the generated validator for that one message type and returns the validated data. Issue paths from the message validator, including nested union branches, are prefixed with message, so callers see the same issues as when one validator covered the whole envelope. It does not normalize, repair, or re-validate the generated result.

Keep one generated function per message type. zod-aot inlines every nested schema into the function it compiles, so compiling WSOutboundMessageSchema as a whole produced one 11 MB function. On V8 (daemon clients, plugin hosts, the CLI, and Electron), the Maglev tier compiled that function once it was hot, allocating about 800 MB and stalling for seconds, and compiled it again after each deoptimization. Hermes has no JIT, so mobile never showed it. packages/protocol/tests/validation/ws-outbound.test.ts fails if a generated function grows past 1 MB.

Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known type and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.

Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.

Codegen Ownership

The protocol package owns generation.

  • packages/protocol/src/validation/ws-outbound-envelope.ts defines the tag-only envelope schema and exposes the session message schemas that get their own validators.
  • packages/protocol/scripts/generate-validation-aot.mjs writes the zod-aot discovery entry and the runtime schema metadata, runs the exact-pinned compiler, and applies the small local compiler patches before generation. It also emits the type → validator map the boundary dispatches through.
  • packages/protocol/scripts/watch-validation-aot.mjs reruns generation while editing protocol sources.
  • packages/protocol/codegen/generated/ws-outbound.compile.ts, packages/protocol/src/generated/validation/ws-outbound.aot.ts, and packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts are generated and gitignored. The metadata module lets generated code reach schema fallback/default values.

Generation runs from protocol-owned lifecycle hooks: prebuild, pretypecheck, pretest, and watch. Installs do not run generation: published packages consume protocol from prebuilt dist, and local build/typecheck/test flows generate the source file at the point it is actually needed.

Regression Tests

zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. packages/protocol/tests/validation/ws-outbound.test.ts keeps small regression tests for the patched cases:

  • discriminated-union branch output must propagate .default() fields
  • current sequential item routing must accept tool_call-like status branches
  • generated runtime imports must keep .js extensions for packaged Node ESM
  • the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one

Schema Purity

Message schemas are structural declarations. Do not put .transform(), .catch(), or .preprocess() on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.

Use z.discriminatedUnion() when every branch has a shared literal tag. Plain z.union() is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.

Defaults are allowed only on primitive leaves. Do not place .default() on large arrays, item schemas, or big containers in inbound message schemas.