Full CI green — all checks pass, including the 30 integration tests (which exercise the real --query/--format #39

Merged
navicore merged 5 commits from issue38 into main 2026-07-03 15:39:24 +00:00
Owner

Title: Plural wire encodings (json | bson) with capability-gated I/O

Implements the output + input design from #38 and docs/design/IO.md. Today every compiled binary is force-fed
JSON on stdout; this separates the envelope shape (fixed — count, exhausted, solutions, optional output) from
its encoding (plural), and lets a program declare which encodings its binary advertises. Two encodings cover the
space: json (the v1 text wire format, byte-identical) and bson (binary, dense, typed). The author picks the set;
the caller picks within it. Default behaviour — a [json] binary answering --query/--format json — is preserved
byte-for-byte.

What lands

  • crates/runtime/src/wire.rs (new) — the wire layer. Envelope (the fixed shape as a typed value), WireError, and
    EncoderDesc: encodings exposed as #[repr(C)] vtable statics (PLG_ENC_JSON, PLG_ENC_BSON) rather than a trait,
    so codegen can emit a per-binary capability table and --gc-sections can strip unlisted encoders. The json
    encoder is lifted verbatim from the old core::write_*_json; the bson encoder maps scalars to native bson types
    and term values to BinData(0x00) carrying a copyterm::TermBuf — the single-sourced plg-shared::cell ABI,
    lossless including cyclic terms. Also a bson reader for the one-field input request. core.rs shrinks to the
    solve side.
  • Binding + word (render.rs) — each captured binding now also retains the dereferenced root term word (free —
    already computed), which the bson encoder walks via copy_to_buf. json ignores it. All consumers updated.
  • Capability table — a new :- io_format([...]). directive (list / comma-chain / single atom; default [json]),
    parsed in the frontend, merged in the compiler, and emitted as @plg_caps — an array of EncoderDesc pointers —
    alongside the atom table and registry. plg_rt_init's signature gains the table; entry.rs resolves --format by
    scanning it (exit 2 if not advertised). Unknown encoder names are a build-time error.
  • bson output — --format bson switches the machine to capture mode (binary can't coexist with streamed write/1
    text) and emits one self-delimiting bson document. An explicit flush() is required: Rust's stdout is a
    LineWriter, and bson's no-newline bytes would sit buffered and never flush on exit — the json path only worked
    because its \n triggered a line flush.
  • bson input — --input-format text|bson (default text). text takes the query from argv --query (unchanged); bson
    reads a one-field request document {"query":"...","limit"?:N} from stdin — the "honest cheat" (the engine
    parses the inner string exactly as for argv, so there's no bson-term loader). Input and output encodings are
    orthogonal; all four {in}×{out} pairings are legal. The capability table gates both directions; argv --query
    (like the human text form) is always available, ungated. argv --limit overrides a doc limit when both are
    present.

Load-bearing decisions

  • Shape fixed, encoding plural — the Envelope type owns the contract once; encoders vary independently. Both the
    CLI and the WASM reactor build an Envelope and drive it through a chosen descriptor, so the shape can't drift
    between transports.
  • Descriptors, not a trait — the vtable-static + codegen @plg_caps design is what makes dead-stripping real:
    entry.rs dispatches through pointers and never names an unused encoder statically, so --gc-sections drops it.
  • TermBuf-in-BinData for bson terms — reuses the one cell ABI the fact tables and copy_term/2 already use,
    maximally dense, lossless for cyclic terms (the json path cuts X=f(X) to f(_N) for v1 byte-compat; bson
    round-trips it). This asymmetry is stated honestly in IO.md rather than hidden.

Verification

  • just ci green: existing 30 integration tests + the differential corpus unchanged (default [json] binary is
    byte-identical to v1); footprint gate (hello_world_binary_stays_lean, 1.3M ceiling) and ldd
    standalone-contract check green with both encoders available.
  • New: 4 frontend io_format tests; 15 capabilities.rs integration tests (gating, dead-stripping via nm, bson
    input, all four in/out pairings, missing-query/unknown-encoder rejection); ~20 wire.rs unit tests (json
    byte-shape, bson framing self-delimits, serialize_termbuf round-trips atoms/lists/cyclic-terms losslessly,
    bson-request parsing).
  • Dead-stripping verified end-to-end: a default [json] binary has PLG_ENC_JSON but no PLG_ENC_BSON (and no bson
    write fns); a [bson]-only binary is the inverse. A [json]-only binary pays zero bson cost.
  • bson decoded with a dependency-free Python struct parser to confirm spec-compliance (scalars readable, terms
    as BinData, X=f(X) round-trips with the cycle intact while json cuts it).

Out of scope (deliberate, separate PR)

  • The CLI rename (json→text for the wire encoding; the human X = foo form → --pretty). It's a breaking contract
    change, so it gets its own PR, ideally with a deprecation shim rather than a hard cutover. The current names
    work coherently today.
  • The WASM Tier-2 reactor ignores the capability table — it hardcodes PLG_ENC_JSON and is host-ABI-driven (no
    --format). Native binaries are correct (the reactor isn't linked, so dead-stripping works); for --target
    worker a [bson]-only binary still emits JSON from its reactor. Not a regression (the reactor always emitted
    JSON), just a loose end worth a one-line note in IO.md on whether/how io_format should apply to the worker
    target.
Title: Plural wire encodings (json | bson) with capability-gated I/O Implements the output + input design from #38 and docs/design/IO.md. Today every compiled binary is force-fed JSON on stdout; this separates the envelope shape (fixed — count, exhausted, solutions, optional output) from its encoding (plural), and lets a program declare which encodings its binary advertises. Two encodings cover the space: json (the v1 text wire format, byte-identical) and bson (binary, dense, typed). The author picks the set; the caller picks within it. Default behaviour — a [json] binary answering --query/--format json — is preserved byte-for-byte. What lands - crates/runtime/src/wire.rs (new) — the wire layer. Envelope (the fixed shape as a typed value), WireError, and EncoderDesc: encodings exposed as #[repr(C)] vtable statics (PLG_ENC_JSON, PLG_ENC_BSON) rather than a trait, so codegen can emit a per-binary capability table and --gc-sections can strip unlisted encoders. The json encoder is lifted verbatim from the old core::write_*_json; the bson encoder maps scalars to native bson types and term values to BinData(0x00) carrying a copyterm::TermBuf — the single-sourced plg-shared::cell ABI, lossless including cyclic terms. Also a bson reader for the one-field input request. core.rs shrinks to the solve side. - Binding + word (render.rs) — each captured binding now also retains the dereferenced root term word (free — already computed), which the bson encoder walks via copy_to_buf. json ignores it. All consumers updated. - Capability table — a new :- io_format([...]). directive (list / comma-chain / single atom; default [json]), parsed in the frontend, merged in the compiler, and emitted as @plg_caps — an array of EncoderDesc pointers — alongside the atom table and registry. plg_rt_init's signature gains the table; entry.rs resolves --format by scanning it (exit 2 if not advertised). Unknown encoder names are a build-time error. - bson output — --format bson switches the machine to capture mode (binary can't coexist with streamed write/1 text) and emits one self-delimiting bson document. An explicit flush() is required: Rust's stdout is a LineWriter, and bson's no-newline bytes would sit buffered and never flush on exit — the json path only worked because its \n triggered a line flush. - bson input — --input-format text|bson (default text). text takes the query from argv --query (unchanged); bson reads a one-field request document {"query":"...","limit"?:N} from stdin — the "honest cheat" (the engine parses the inner string exactly as for argv, so there's no bson-term loader). Input and output encodings are orthogonal; all four {in}×{out} pairings are legal. The capability table gates both directions; argv --query (like the human text form) is always available, ungated. argv --limit overrides a doc limit when both are present. Load-bearing decisions - Shape fixed, encoding plural — the Envelope type owns the contract once; encoders vary independently. Both the CLI and the WASM reactor build an Envelope and drive it through a chosen descriptor, so the shape can't drift between transports. - Descriptors, not a trait — the vtable-static + codegen @plg_caps design is what makes dead-stripping real: entry.rs dispatches through pointers and never names an unused encoder statically, so --gc-sections drops it. - TermBuf-in-BinData for bson terms — reuses the one cell ABI the fact tables and copy_term/2 already use, maximally dense, lossless for cyclic terms (the json path cuts X=f(X) to f(_N) for v1 byte-compat; bson round-trips it). This asymmetry is stated honestly in IO.md rather than hidden. Verification - just ci green: existing 30 integration tests + the differential corpus unchanged (default [json] binary is byte-identical to v1); footprint gate (hello_world_binary_stays_lean, 1.3M ceiling) and ldd standalone-contract check green with both encoders available. - New: 4 frontend io_format tests; 15 capabilities.rs integration tests (gating, dead-stripping via nm, bson input, all four in/out pairings, missing-query/unknown-encoder rejection); ~20 wire.rs unit tests (json byte-shape, bson framing self-delimits, serialize_termbuf round-trips atoms/lists/cyclic-terms losslessly, bson-request parsing). - Dead-stripping verified end-to-end: a default [json] binary has PLG_ENC_JSON but no PLG_ENC_BSON (and no bson write fns); a [bson]-only binary is the inverse. A [json]-only binary pays zero bson cost. - bson decoded with a dependency-free Python struct parser to confirm spec-compliance (scalars readable, terms as BinData, X=f(X) round-trips with the cycle intact while json cuts it). Out of scope (deliberate, separate PR) - The CLI rename (json→text for the wire encoding; the human X = foo form → --pretty). It's a breaking contract change, so it gets its own PR, ideally with a deprecation shim rather than a hard cutover. The current names work coherently today. - The WASM Tier-2 reactor ignores the capability table — it hardcodes PLG_ENC_JSON and is host-ABI-driven (no --format). Native binaries are correct (the reactor isn't linked, so dead-stripping works); for --target worker a [bson]-only binary still emits JSON from its reactor. Not a regression (the reactor always emitted JSON), just a loose end worth a one-line note in IO.md on whether/how io_format should apply to the worker target.
Full CI green — all checks pass, including the 30 integration tests (which exercise the real --query/--format
All checks were successful
WASM Gates / WASM gates (Tier 1 wasi + Tier 2 reactor) (pull_request) Successful in 29s
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m29s
384f528434
wire path end-to-end through entry.rs), the binary-size footprint gate, and the ldd standalone-contract check.

 Here's what landed for this first step:

 crates/runtime/src/wire.rs (new) — the seam. Defines:
 - Envelope<'a> (the fixed shape: count, exhausted, solutions, program_output) with a from_machine constructor
   that reads m.solutions and m.captured_output() (so it serves both the streaming CLI and the capture-mode
   reactor from one constructor).
 - WireError { Parse, Runtime }.
 - Encoder trait (write_envelope, write_error, can_stream).
 - encoder_for(name) registry — currently only "json", with "bson" deliberately returning None (lands in a later
   PR).
 - Json encoder impl — byte-identical to the old core::write_solutions_json / write_error_json (5 tests pin the
   shape).

 crates/runtime/src/core.rs — shrunk to the solve side: QueryResult, run_query, exhausted. The two JSON writers
 and their tests moved out; the exhausted test stays. Module doc rewritten to point at wire for the
 shape/encoding.

 crates/runtime/src/entry.rs & reactor.rs — both now build an Envelope and drive it through the Json encoder via
 the Encoder trait, instead of calling core::write_*_json. Behavior unchanged.

 Deliberately deferred (per the staged plan in IO.md, not this PR): the Request input type, --input-format, the
 capability table / io_format/1 directive, the Bson encoder, and the CLI flag rename (json→text, human form →
 --pretty). The seam is in place; everything else hangs off it.

 Next logical step is the Bson encoder + TermBuf-in-BinData term encoding, but that's a self-contained addition
 to wire.rs once you're ready. Want me to proceed to that, or pause here?
What landed
All checks were successful
WASM Gates / WASM gates (Tier 1 wasi + Tier 2 reactor) (pull_request) Successful in 29s
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m27s
cdbc7045d7
crates/runtime/src/wire.rs — added the Bson encoder:
 - A hand-rolled bson emitter (bson_doc_begin/end, bson_cstring, element helpers) building into a Vec<u8> (bson
   is length-prefixed and dyn Write isn't seekable — buffer then flush).
 - Scalar envelope fields map to native bson types (count→int32, exhausted→bool, output→string) so generic
   tooling reads them.
 - Term values are BinData(0x00) carrying serialize_termbuf output: version byte + cell count + root word + cells
   — the plg-shared::cell ABI verbatim, single-sourced via copyterm::copy_to_buf.
 - can_stream() == false (binary can't coexist with interleaved text), which forces capture mode.
 - Registered in encoder_for("bson").
 - Tests: bson framing self-delimits; serialize_termbuf round-trips atoms/lists/cyclic-terms losslessly.

 crates/runtime/src/render.rs — RenderedSolution's binding tuple (name, json, text) became a Binding struct with
 an added word: Word field (the dereferenced root term, free at capture time). The bson encoder walks it via
 copy_to_buf; json ignores it. Updated all consumers (control.rs, solve.rs, entry.rs).

 crates/runtime/src/entry.rs — --format bson now accepted; switches the machine to OutputSink::Capture before
 running (so write/1 bytes ride in the envelope's output field rather than corrupting the binary stream); routes
 solutions and errors through the Bson encoder. Added an explicit flush() — the real bug found in validation:
 Rust's stdout is a LineWriter, and bson's binary output (no trailing newline) sat buffered and never flushed on
 exit. The json path worked only because its \n triggered an immediate line flush. Flagged in a comment so it
 doesn't bite again.

 crates/runtime/src/reactor.rs — write_envelope now takes &Machine (the trait signature change), passed through.

 Validation done

 Built a real binary and decoded its bson output with a dependency-free Python struct decoder:
 - Spec-compliant (pure-Python parser reads it cleanly).
 - count/exhausted/output are native bson types.
 - X=bob carries as a 0-cell TermBuf with root word 0xa1 (ATOM tag, id 20) — matches (id<<3)|1.
 - X=point(3,4) carries as 3 cells (functor header + 2 args).
 - X=f(X) round-trips with the cycle preserved (cell[1] → STR index 0), while the same term via --format json is
   cut to f(_1) — proving the IO.md flag #1 losslessness asymmetry through the actual binary.

 Still deferred (per the staged plan)

 Capability-table gating (io_format/1 → codegen .rodata, so a [text]-only binary dead-strips the bson encoder —
 currently every binary links it), --input-format/bson input, and the CLI flag rename (json→text, human form →
 --pretty).
The capability table is implemented and CI-green. Summary of this step:
All checks were successful
WASM Gates / WASM gates (Tier 1 wasi + Tier 2 reactor) (pull_request) Successful in 29s
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m28s
df76b24753
What landed

 Frontend (crates/frontend/src/parser/):
 - ProgramDirectives gained io_format: Vec<String>.
 - process_directive recognizes :- io_format([...]), accepting list form ([json, bson]), comma-chain ((json,
   bson)), or a single atom. Dedup preserving order. Unknown directives still error. 4 new unit tests.

 Codegen (crates/compiler/src/codegen/):
 - emit_capabilities emits @plg_caps = [N x ptr] — one descriptor pointer per declared encoding (default [json]).
   Validates encoder names at build time (io_format: unknown encoder 'csv' → build error). The two @PLG_ENC_*
   symbols are declared as external globals in RUNTIME_DECLS so they're referenceable in the constant
   initializer.
 - plg_rt_init's signature grew the caps table params; the init call passes them.

 Runtime (crates/runtime/src/):
 - wire.rs refactored: the Encoder trait became EncoderDesc — a #[repr(C)] vtable struct of function pointers +
   name, with PLG_ENC_JSON / PLG_ENC_BSON as #[no_mangle] statics. EncoderDesc::find scans a capability table by
   name. This is the mechanism that lets codegen emit a per-binary table and lets --gc-sections strip unlisted
   encoders.
 - Machine gained capabilities: Vec<*const EncoderDesc>, populated by plg_rt_init.
 - entry.rs resolves --format via EncoderDesc::find (exit 2 if not advertised); the can_stream() flag drives both
   the capture-mode decision and the line discipline (json: trailing newline; bson: explicit flush). The human
   text form stays always-available.

 Dead-stripping — verified with nm: a default [json] binary has PLG_ENC_JSON but no PLG_ENC_BSON (and no bson
 write fns); a [bson]-only binary is the inverse. The doc's "zero bson cost for a text-only binary" checkpoint
 holds.

 Tests (crates/compiler/tests/capabilities.rs): 9 integration tests — default binary serves json, rejects
 undeclared bson and unknown formats; human text always available; [json,bson] serves both (list + comma-chain
 forms); [bson]-only rejects json; unknown encoder name is a build error; dead-stripping asserted via nm. The
 harness gained query_bytes (raw stdout) since bson is binary and the lossy-UTF-8 query helper corrupts it — a
 real subtlety caught here.

 Docs: RUNTIME_ABI.md (the plg_rt_init signature + @plg_caps table), ARCHITECTURE.md (wire contract now describes
 json/text/bson + capability gating), and IO.md status updated to "partially implemented."
Bson input is implemented and CI-green. Summary:
All checks were successful
WASM Gates / WASM gates (Tier 1 wasi + Tier 2 reactor) (pull_request) Successful in 28s
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m28s
c8644668d3
What landed

 crates/runtime/src/wire.rs — added the bson reader:
 - ParsedRequest { query, limit } and parse_bson_request(buf) -> Result<ParsedRequest, String>. Parses a
   one-field request document {query: string, limit?: int32|int64}, the IO.md "honest cheat" — the engine parses
   the inner query string exactly as for argv --query, so there's no bson-term loader.
 - Robust: skips unknown fields for forward-compat (via skip_value over the standard bson element types), errors
   on truncated/malformed documents, ignores trailing bytes (stdin may carry them), requires query to be present
   and a string.
 - 7 new unit tests (query-only, int32/int64 limit, unknown-field skipping, missing-query error, truncation,
   trailing bytes).

 crates/runtime/src/entry.rs — --input-format text|bson (default text):
 - parse_args gained the flag; --query is now Option (required only in text-input mode, enforced in plg_rt_main).
 - plg_rt_main resolves the query by input mode: text → argv --query (required); bson → reads stdin via
   read_to_end, parses the request, applies the doc's limit (argv --limit overrides if present). Output --format
   is orthogonal and still works normally — so all four {in}×{out} pairings are legal.
 - The capability table gates both directions: --input-format bson requires bson advertised (a [json]-only binary
   rejects it, exit 2), mirroring how argv --query is always available (like the human text display form).
 - Updated entry tests for the new flag and the now-optional --query.

 crates/compiler/tests/ — harness gained run_with_stdin (argv + stdin bytes → raw stdout); capabilities.rs gained
 6 bson-input integration tests (bson-in/json-out, limit honored via exhausted:false, bson-in/bson-out,
 capability rejection, missing-query rejection, argv --query still works). 15 capability tests total now.

 Docs — ARCHITECTURE.md wire contract now describes --input-format and the orthogonality; IO.md status updated to
 "output and input implemented."

 Verified end-to-end

 Through the real compiled binary: {"query":"parent(tom, X)","limit":1} via stdin →
 {"count":1,"exhausted":false,...} (limit correctly hit); bson-in/bson-out produces valid self-delimiting bson; a
 [json]-only binary rejects bson input; malformed/missing-query requests exit 2 cleanly.
Author
Owner

Reviewed the full diff — wire.rs, the entry.rs/core.rs/render.rs/machine.rs/reactor.rs changes, the frontend io_format directive parsing, the codegen capability table, and the capabilities.rs integration tests.

Overall

Clean and well-factored. Both flags raised on #38 are addressed head-on in the IO.md diff: cyclic-term losslessness is explicitly scoped to bson-only (with the text carve-out documented as deliberate v1 byte-compat), and the input/output orthogonality matrix is spelled out with the --input-format/--format separation and its rationale ("avoid the overload trap of absent --query ⇒ read bson"). The descriptor/vtable design over a trait-object is the right call for --gc-sections dead-stripping, and the nm-based dead-strip test proves it end-to-end. The LineWriter/flush subtlety is correctly handled. The cyclic-term round-trip test directly exercises the losslessness claim.

Three things worth attention before merge, ranked by substance:

1. WireError::Runtime is dead, and runtime errors are mislabeled on the wire

WireError::Runtime is never constructed in production code. Both callers hardcode Parse:

  • entry.rs output_result wraps every message in WireError::Parse(...), including the QueryResult::RuntimeError arm.
  • reactor.rs — same, for both ParseError and RuntimeError arms.

So a runtime error (exit 3) is serialized as a Parse-flavored error on the wire, and the Runtime variant only exists in a unit test. The encoders collapse both variants to the same message anyway (WireError::Parse(m) | WireError::Runtime(m) => m), so today it's invisible — but the enum advertises a distinction the implementation doesn't honor. Either thread the actual class through (output_result should take the class and construct the right variant), or collapse WireError to a single struct. As-is it's a latent footgun: the moment a bson error document wants a kind field, every runtime error is misreported as a parse error.

2. Pre-solve errors escape the wire encoding

There's a seam in error routing. Post-solve errors go through output_result(enc, ...) and are encoded in the chosen format (bson error document on stdout). But every pre-solve exit-2 path bypasses the encoder and writes plaintext to stderr:

  • unknown/undeclared --formateprintln!
  • undeclared --input-format bsoneprintln!
  • stdin read failure → eprintln!
  • parse_bson_request failureeprintln!("bson request parse error: {e}")

So a caller doing --input-format bson --format bson that sends a malformed request document gets a plaintext message on stderr, not a bson error document on stdout — breaking the "I only speak bson" contract at exactly the framing layer where a bson caller lives. Notably, a query-string parse error (exit 2) is encoded, but a bson-request parse error (also exit 2) is not. That asymmetry isn't documented. It may be a deliberate choice ("malformed framing = caller bug, text is fine"), but if so it should be a one-liner in IO.md; if not, the bson-request parse path should route through the chosen output encoder like query-parse errors do.

3. The reactor/worker note promised in the PR description isn't in IO.md

The PR description says the Tier-2 reactor ignoring the capability table (a [bson]-only --target worker binary still emits JSON) is "not a regression… just a loose end worth a one-line note in IO.md on whether/how io_format should apply to the worker target." Grepping the merged IO.md, that note was not added (the only reactor mention is the pre-existing structure section). Either add the note or file it as a follow-up issue; as-is the promise in the PR body is the sole record of the loose end.

Minor nits (don't block)

  • Double dedup. io_format dedups in the parser (collect_io_format_specs) and again in codegen (emit_capabilities), while parse_sources merges across files without dedup. Works, but the responsibility is smeared — pick one layer to own dedup.
  • :- io_format([]) silently becomes [json]. emit_capabilities treats an empty declared slice as "use the default," so an explicit empty declaration is indistinguishable from no declaration. A binary with zero encodings is useless so defaulting is reasonable, but the parser could either reject [] or the defaulting rule deserves a sentence in IO.md.
  • Improper-list leniency. :- io_format([json | bson]) parses as [json, bson] because the Term::Atom arm accepts the bson tail. Harmless, but a malformed spec silently succeeding is the kind of thing that bites later. An explicit "tail must be []" check would be tidier.
  • can_stream: fn() -> bool. A bool field would be simpler and equally const-constructible; the indirection through a function pointer reads like premature extensibility. Not wrong.
  • Test gap. No integration test for the bson error path through the binary (a runtime error or query-parse error under --format bson asserting a valid bson error document on stdout). bson_error_document_is_valid tests the encoder in isolation; the routing through entry.rs is unexercised for bson — which is exactly how #1 above could hide.

My read: #1 and #3 are merge-blockable (the mislabeling is a real correctness bug if WireError ever gains meaning; the missing note is a stated commitment unfulfilled), and #2 is a documented-design-decision call.

Reviewed the full diff — `wire.rs`, the `entry.rs`/`core.rs`/`render.rs`/`machine.rs`/`reactor.rs` changes, the frontend `io_format` directive parsing, the codegen capability table, and the `capabilities.rs` integration tests. ## Overall Clean and well-factored. Both flags raised on #38 are addressed head-on in the IO.md diff: cyclic-term losslessness is explicitly scoped to bson-only (with the text carve-out documented as deliberate v1 byte-compat), and the input/output orthogonality matrix is spelled out with the `--input-format`/`--format` separation and its rationale ("avoid the overload trap of absent `--query` ⇒ read bson"). The descriptor/vtable design over a trait-object is the right call for `--gc-sections` dead-stripping, and the `nm`-based dead-strip test proves it end-to-end. The LineWriter/flush subtlety is correctly handled. The cyclic-term round-trip test directly exercises the losslessness claim. Three things worth attention before merge, ranked by substance: ## 1. `WireError::Runtime` is dead, and runtime errors are mislabeled on the wire `WireError::Runtime` is never constructed in production code. Both callers hardcode `Parse`: - `entry.rs` `output_result` wraps every message in `WireError::Parse(...)`, including the `QueryResult::RuntimeError` arm. - `reactor.rs` — same, for both `ParseError` and `RuntimeError` arms. So a runtime error (exit 3) is serialized as a `Parse`-flavored error on the wire, and the `Runtime` variant only exists in a unit test. The encoders collapse both variants to the same message anyway (`WireError::Parse(m) | WireError::Runtime(m) => m`), so today it's invisible — but the enum advertises a distinction the implementation doesn't honor. Either thread the actual class through (`output_result` should take the class and construct the right variant), or collapse `WireError` to a single struct. As-is it's a latent footgun: the moment a bson error document wants a `kind` field, every runtime error is misreported as a parse error. ## 2. Pre-solve errors escape the wire encoding There's a seam in error routing. Post-solve errors go through `output_result(enc, ...)` and are encoded in the chosen format (bson error document on stdout). But every pre-solve exit-2 path bypasses the encoder and writes plaintext to stderr: - unknown/undeclared `--format` → `eprintln!` - undeclared `--input-format bson` → `eprintln!` - stdin read failure → `eprintln!` - **`parse_bson_request` failure** → `eprintln!("bson request parse error: {e}")` So a caller doing `--input-format bson --format bson` that sends a malformed request document gets a plaintext message on stderr, not a bson error document on stdout — breaking the "I only speak bson" contract at exactly the framing layer where a bson caller lives. Notably, a *query-string* parse error (exit 2) *is* encoded, but a *bson-request* parse error (also exit 2) is not. That asymmetry isn't documented. It may be a deliberate choice ("malformed framing = caller bug, text is fine"), but if so it should be a one-liner in IO.md; if not, the bson-request parse path should route through the chosen output encoder like query-parse errors do. ## 3. The reactor/worker note promised in the PR description isn't in IO.md The PR description says the Tier-2 reactor ignoring the capability table (a `[bson]`-only `--target worker` binary still emits JSON) is "not a regression… just a loose end worth a one-line note in IO.md on whether/how `io_format` should apply to the worker target." Grepping the merged IO.md, that note was not added (the only `reactor` mention is the pre-existing structure section). Either add the note or file it as a follow-up issue; as-is the promise in the PR body is the sole record of the loose end. ## Minor nits (don't block) - **Double dedup.** `io_format` dedups in the parser (`collect_io_format_specs`) and again in codegen (`emit_capabilities`), while `parse_sources` merges across files without dedup. Works, but the responsibility is smeared — pick one layer to own dedup. - **`:- io_format([])` silently becomes `[json]`.** `emit_capabilities` treats an empty `declared` slice as "use the default," so an explicit empty declaration is indistinguishable from no declaration. A binary with zero encodings is useless so defaulting is reasonable, but the parser could either reject `[]` or the defaulting rule deserves a sentence in IO.md. - **Improper-list leniency.** `:- io_format([json | bson])` parses as `[json, bson]` because the `Term::Atom` arm accepts the `bson` tail. Harmless, but a malformed spec silently succeeding is the kind of thing that bites later. An explicit "tail must be `[]`" check would be tidier. - **`can_stream: fn() -> bool`.** A `bool` field would be simpler and equally const-constructible; the indirection through a function pointer reads like premature extensibility. Not wrong. - **Test gap.** No integration test for the bson *error* path through the binary (a runtime error or query-parse error under `--format bson` asserting a valid bson error document on stdout). `bson_error_document_is_valid` tests the encoder in isolation; the routing through `entry.rs` is unexercised for bson — which is exactly how #1 above could hide. My read: #1 and #3 are merge-blockable (the mislabeling is a real correctness bug if `WireError` ever gains meaning; the missing note is a stated commitment unfulfilled), and #2 is a documented-design-decision call.
CI green. All review findings addressed. Summary of the follow-up changes:
All checks were successful
WASM Gates / WASM gates (Tier 1 wasi + Tier 2 reactor) (pull_request) Successful in 28s
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m28s
2ed32bb756
Merge-blockable (fixed)

 #1 — WireError::Runtime mislabeled. output_result now takes a WireError (constructed by the caller), so
 QueryResult::RuntimeError → WireError::Runtime and ParseError → WireError::Parse, honestly. The encoders still
 collapse both to the same {"error":"..."} today, but the variant is correct and ready for a future bson kind
 field. Both entry.rs and reactor.rs fixed (reactor split its merged arm into two).

 #3 — reactor note missing from IO.md. Added to the Capability-table data-shape bullet: the table governs the
 CLI/plg_rt_main contract; the WASM Tier-2 reactor is host-ABI-driven and always emits json, so a [bson]-only
 worker binary still emits json there (native binaries unaffected — reactor isn't linked, dead-stripping holds).
 Tagged as an open follow-up. Also fixed the doc's stale "default [text]" → [json] (the rename is part 2) and
 documented the io_format([]) defaulting rule.

 #2 (fixed rather than just documented)

 The bson-request parse error and stdin-read failure now route through the resolved output encoder
 (output_result(enc, …)), so a --input-format bson --format bson caller sending a malformed request gets a bson
 error document on stdout instead of plaintext stderr. Format/flag-validation errors (unknown --format,
 undeclared input format, missing --query) stay on stderr — no valid encoder exists for those, or they're
 meta-usage.

 Nits (fixed)

 - Dedup ownership — removed the parser-level dedup; codegen (emit_capabilities) is now the single owner (it sees
   the cross-file merge). Parse test updated to assert source-order preservation; an improper-list rejection test
   added.
 - Improper-list leniency — :- io_format([json | bson]) is now a parse error ("must be a proper list of atoms")
   rather than silently becoming [json, bson].
 - Test gap — added 3 bson error-path integration tests: runtime error (exit 3) and parse error (exit 2) under
   --format bson both emit valid self-delimiting bson error documents on stdout; a malformed bson request is
   encoded (not dropped to stderr). These lock in both #1 and #2.

 Left as-is

 can_stream: fn() -> bool — the review called it "not wrong"; keeping it for descriptor-internal consistency (the
 other two fields are fn pointers). Truly a toss-up; happy to flatten to bool if you'd rather.

 Net: +18 capability tests (was 15), the two blockers resolved, the framing-layer contract gap closed, and the
 IO.md promise fulfilled. Ready for your re-review.
navicore deleted branch issue38 2026-07-03 15:39:24 +00:00
Sign in to join this conversation.
No reviewers
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
navicore/patch-prolog!39
No description provided.