Skip to content

fix(engine): materialize host-supplied json with json container typing - #4346

Merged
aaronvg merged 6 commits into
canaryfrom
fix/inbound-json-container-typing
Aug 8, 2026
Merged

fix(engine): materialize host-supplied json with json container typing#4346
aaronvg merged 6 commits into
canaryfrom
fix/inbound-json-container-typing

Conversation

@aaronvg

@aaronvg aaronvg commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

A json-typed BAML parameter receiving an object from a host SDK materializes as a map that fails BAML's typed narrowing: match (j) { let m: map<string, json> => ... } never matches, so baml.json.path / path_or throw/return-default ("field access '.type' on a non-object") on host-supplied objects — while the structural $rust_functions baml.json.field and stringify work on the same value. Pure-BAML json objects (from baml.json.parse) match fine, so inbound FFI json diverged from BAML-born json.

Reproducible without any SDK:

baml-cli run --file f.baml -f json_kind -- json_kind --json-args '{"value": {"type": "ok"}}'

returned "other" where the same value built via baml.json.parse returned "object".

Root cause

All bridges (Go container.go, Python proto.py, TypeScript proto.ts) encode containers without a value_type annotation, and the wire decoder (bridge_ctypes::value_decode) defaults them to a scalar union (int|float|string|bool|uint8array|null). The engine's inbound coercion keeps baml.json.json nominal and had no container arm for it, so a map bound to a json slot materialized on the VM heap with that scalar-union value type. BAML-born baml.json.parse maps carry the json alias itself (serde_to_value), and the match type test (is_subtype, invariant in map value position) only matches the latter.

Fix (engine-side, covers every bridge + CLI)

In bex_engine::conversion:

  • New coercion arm: a container arriving in a baml.json.json slot that inhabits the JSON algebra (value_satisfies_json) gets its container annotations rewritten recursively to the alias — lists become json[], maps map<string, json>. Non-JSON trees are left untouched for the existing rejection paths.
  • The same hook at the top of convert_external_to_vm_value_with_ty, because host-callable return values convert without a coercion pass and had the identical bug.

Also fixes json nested inside declared map<string, json> / json[] params, class fields, and json | image unions.

Tests

  • Engine unit test: canonical_json_alias_reannotates_untyped_inbound_containers (deep re-annotation; non-JSON trees untouched).
  • Fixture (ns_go_json_tests): json_kind (match narrowing), json_path_string, json_path_string_or, json_callback_kind (host-return path).
  • Go SDK regression test: object/array/string/scalar narrowing, nested selector .nested.list[1].deep, path_or fallback, missing-field JsonPathError, callback-returned json.
  • Python SDK regression test mirroring the Go coverage.

Verified: full bex_engine suite, Go function_calls suite, Python function_calls suite (ruff + pyright + pytest), cargo fmt/clippy clean.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core FFI value coercion, type matching, and host-return validation across all bridges; behavior change for json typing is intentional but could affect edge cases (user aliases shadowing display names, annotated unions).

Overview
Host-supplied baml.json.json values from SDK bridges no longer fail BAML typed narrowing (match on map<string, json>, baml.json.path / path_or) because containers were materializing with wire-synthesized scalar-union element types instead of the canonical json alias.

The engine now re-annotates JSON-algebra containers at inbound coercion and on the host-return conversion path via annotate_json_container_types, and tightens matching/validation: is_canonical_json_alias (package-qualified identity, not display_name()), value_satisfies_json peeling sparse inbound leaf annotations, and host-return acceptance for algebra-scoped inbound annotations (e.g. C++/Swift).

Java bridge: codegen wraps callable arguments in BamlTypedCallable so dispatch decodes/encodes against declared param and return descriptors (not raw wire values).

Tests: engine unit tests, shared BAML fixtures (json_kind, path helpers, callback), and cross-language SDK parity tests (Go extended; new C++/Java/Python/Swift/TS; Rust gated).

Reviewed by Cursor Bugbot for commit 0cc59f8. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of externally supplied JSON values, including nested objects, arrays, maps, and scalar values.
    • JSON values returned from callbacks now behave consistently with native BAML JSON values.
    • Improved JSON type detection, union handling, and nested path access.
    • Added reliable fallback handling for missing paths and clearer errors for missing fields.
    • Preserved non-JSON containers and rejected invalid JSON annotations.
  • New Features

    • Java host callbacks can now provide type information for arguments and return values.
  • Tests

    • Added cross-language coverage for JSON narrowing, path access, callbacks, and nested values.

Inbound containers from the language bridges (Go, Python, TypeScript)
and `--json-args` carry no element-type annotation on the wire; the
decoder defaults them to a scalar union. A `baml.json.json`-declared
slot then materialized VM maps/lists with that synthesized type, so
runtime type tests — `match (j) { let m: map<string, json> => ... }`,
and therefore `baml.json.path` / `path_or` — failed on host-supplied
objects while succeeding on BAML-born `baml.json.parse` values, whose
containers carry the `json` alias itself.

Re-annotate JSON container trees with the declared alias in the inbound
argument coercion pass, and again at VM conversion for the paths that
convert without coercion (notably host-callable return values). Trees
outside the JSON algebra are left unchanged for the existing rejection
paths.

Regression coverage: engine unit test for the re-annotation, fixture
functions exercising match narrowing and path selectors over
host-supplied json (including a host-callback return), and Go + Python
SDK tests driving them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview Aug 8, 2026 12:32am
promptfiddle2 Ready Ready Preview Aug 8, 2026 12:32am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 27.8 MB 11.8 MB file 27.4 MB +393.2 KB (+1.4%) OK
packed-program Linux 🔒 18.0 MB 7.4 MB file 17.7 MB +289.8 KB (+1.6%) OK
baml-cli macOS 🔒 21.6 MB 10.3 MB file 21.3 MB +296.4 KB (+1.4%) OK
packed-program macOS 🔒 14.1 MB 6.5 MB file 13.8 MB +227.5 KB (+1.6%) OK
baml-cli Windows 🔒 23.2 MB 10.5 MB file 23.0 MB +274.8 KB (+1.2%) OK
packed-program Windows 🔒 15.0 MB 6.6 MB file 14.8 MB +213.5 KB (+1.4%) OK
bridge_wasm WASM 17.1 MB 🔒 4.7 MB gzip 4.6 MB +45.0 KB (+1.0%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

The new canonical test IDs exist only in the Go and Python suites (the
fix is engine-side and bridge-agnostic); annotate them with
SDK_PARITY_LINT(skip) so absent environments are waived by the ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Host-supplied JSON values now receive canonical recursive annotations during conversion. Validation distinguishes the builtin baml.json.json alias from user aliases. Java host callables now preserve type descriptors across argument decoding and return encoding. SDK tests cover both behaviors.

Changes

Canonical JSON narrowing

Layer / File(s) Summary
Canonical JSON contract validation
baml_language/crates/bex_external_types/src/host_return.rs, baml_language/crates/bex_external_types/src/lib.rs
Canonical alias detection uses structured type identity. Recursive validation accepts JSON-compatible annotations and rejects non-JSON values and shadowing aliases.
Recursive JSON conversion and matching
baml_language/crates/bex_engine/src/conversion.rs
Conversion preserves the canonical JSON type, recursively annotates compatible arrays and maps, and accepts valid sparse annotations.
Cross-SDK JSON narrowing coverage
baml_language/sdk_tests/fixtures/..., baml_language/sdk_tests/crates/{cpp,go,java,python_pydantic2,rust,swift,typescript}/..., baml_language/sdk_tests/harness_setup/src/rust.rs
Fixtures and SDK tests cover JSON classification, nested paths, fallbacks, missing-field errors, and callback-returned JSON.

Descriptor-aware Java host calls

Layer / File(s) Summary
Typed callable generation
baml_language/sdks/java/sdkgen_java/src/emit.rs
Generated callable values now carry positional, optional, and return descriptors when declared types require them.
Typed callable bridge dispatch
baml_language/sdks/java/baml_bridge/src/main/java/baml_bridge/...
The Java bridge recognizes BamlTypedCallable, decodes arguments with descriptors, and encodes typed return values.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: antoniosarosi

Poem

A rabbit tags each JSON leaf,
And keeps false aliases brief.
Java callables carry types,
Through bridge paths and return trips.
Tests check paths, errors, and maps,
While typed values leave their tracks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding JSON container typing for host-supplied JSON.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/inbound-json-container-typing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/bex_engine/src/conversion.rs`:
- Around line 747-755: Use the fully qualified canonical JSON alias name, not
TypeName::display_name(), in the conversion check around conversion.rs:747-755
and the corresponding check around conversion.rs:3632-3640; update the
alias-expansion exclusion around conversion.rs:3463-3465 to use the same
full-name comparison. Ensure user aliases such as user.baml.json.json retain
normal alias behavior while only the built-in baml.json.json alias receives JSON
container handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 69a8a288-f564-4fbf-b744-baeb98fbeaff

📥 Commits

Reviewing files that changed from the base of the PR and between 45ae165 and 1e5c636.

📒 Files selected for processing (4)
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/sdk_tests/crates/go/function_calls/customizable/test_json_test.go
  • baml_language/sdk_tests/crates/python_pydantic2/function_calls/customizable/test_json.py
  • baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_go_json_tests/main.baml

Comment thread baml_language/crates/bex_engine/src/conversion.rs
aaronvg and others added 2 commits August 7, 2026 15:14
…dentity

Two follow-on gaps surfaced by porting the host-json narrowing tests to
every SDK bridge:

Swift and C++ encode json with sparse inbound `value_type` annotations
(Swift on scalar leaves, C++ on a return's selected variant alternative).
`value_satisfies_json` rejected any Union carrier, so annotated trees
never received json container typing, and `validate_host_return`'s
annotation branch rejected container-annotated json returns outright
(the context-free subtype check cannot expand the alias), panicking with
HostContractViolation. Peel inbound-annotation carriers whose annotation
stays within the JSON algebra (new helpers runtime_ty_within_json_algebra
and expected_admits_json_alias); genuine union carriers and out-of-algebra
annotations (bigint, classes) stay rejected.

Per review: json alias identity is now the fully qualified name —
package `baml`, namespace `json`, name `json` — via the shared
`is_canonical_json_alias` predicate instead of `display_name()` string
comparison, which a local alias at namespace path `baml.json` could
collide with. All alias checks in bex_engine/bex_external_types switched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the canonical IDs host_supplied_json_supports_typed_narrowing and
json_returned_from_host_callback_supports_typed_narrowing from Go+Python
to TypeScript (node + web), Rust, Java, Swift, and C++ — each mirroring
the same semantics: json_kind narrowing on object/array/string/scalar,
nested path selectors, path_or fallback, missing-field JsonPathError,
and a host callback returning a json object.

The Rust port is checked in but gated off (registered in TEST_MODS as
Gate::Later) because sdkgen_rust has no projection for the canonical
baml.json.json alias yet; it follows the suite's established pattern
for ports awaiting codegen support. C# declares no function_calls suite
(its native coverage is Rust-wrapped integration tests), so the two
Python declarations carry the SDK_PARITY_LINT(skip) waiver naming that;
parity lint: required gaps 4565 -> 4565, present declarations +48, both
IDs present in all nine trackable environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/bex_engine/src/conversion.rs (1)

748-756: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle canonical JSON in union return types.

A raw JSON container can validate against a callback return type such as json | null. This branch only annotates when expected_ty is directly RuntimeTy::TypeAlias. With a union, conversion keeps the container's inbound unknown element or value type. Typed narrowing then fails for an accepted callback result.

Run the existing union selection and coercion path before materialization, or preserve the selected canonical JSON member as the container context. Add a callback regression for json? and a union containing json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_engine/src/conversion.rs` around lines 748 - 756,
The canonical JSON annotation in the conversion path only handles a direct
RuntimeTy::TypeAlias and fails for union return types such as json? or json |
null. Update the conversion flow around expected_ty and union selection so
accepted raw JSON containers are coerced or annotated using the selected
canonical JSON member before materialization, preserving typed container
narrowing. Add callback regressions covering nullable json and a union
containing json.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@baml_language/crates/bex_engine/src/conversion.rs`:
- Around line 748-756: The canonical JSON annotation in the conversion path only
handles a direct RuntimeTy::TypeAlias and fails for union return types such as
json? or json | null. Update the conversion flow around expected_ty and union
selection so accepted raw JSON containers are coerced or annotated using the
selected canonical JSON member before materialization, preserving typed
container narrowing. Add callback regressions covering nullable json and a union
containing json.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 83743cdb-b1ed-40e3-a8c4-08631d785360

📥 Commits

Reviewing files that changed from the base of the PR and between 1e5c636 and f30245a.

📒 Files selected for processing (11)
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/crates/bex_external_types/src/host_return.rs
  • baml_language/crates/bex_external_types/src/lib.rs
  • baml_language/sdk_tests/crates/cpp/function_calls/customizable/tests/test_json.cc
  • baml_language/sdk_tests/crates/go/function_calls/customizable/test_json_test.go
  • baml_language/sdk_tests/crates/java/function_calls/customizable/TestJson.java
  • baml_language/sdk_tests/crates/python_pydantic2/function_calls/customizable/test_json.py
  • baml_language/sdk_tests/crates/rust/function_calls/customizable/test_json.rs
  • baml_language/sdk_tests/crates/swift/function_calls/customizable/TestJson.swift
  • baml_language/sdk_tests/crates/typescript/function_calls/customizable/json.test.ts
  • baml_language/sdk_tests/harness_setup/src/rust.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/sdk_tests/crates/python_pydantic2/function_calls/customizable/test_json.py
  • baml_language/sdk_tests/crates/go/function_calls/customizable/test_json_test.go

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oding

The generated Java binding types a host callback parameter by its
declared BAML type (e.g. `Function<baml.json.json, baml.json.json>`),
but the bridge dispatched decoded RAW host values into it and encoded
returns generically — a typed lambda's erased cast threw
ClassCastException, forcing tests into unchecked laundering.

Codegen now wraps callable arguments in `BamlTypedCallable`, a carrier
pairing the callable with descriptors for its declared positional and
optional parameters and return type. The dispatch path threads those
descriptors through the same `decodeWithDesc` machinery the outbound
result path already uses, so callback parameters materialize as their
generated sealed-union/class types and returns encode against the
declared type on both the sync and awaited-future paths. Slots whose
types are entirely wire-driven fall back to the raw callable unchanged.

TestJson's callback test now uses the properly typed lambda with no
unchecked casts. sdkgen_java: 94/94; Java function_calls: green
(pre-existing TestCancellation timing flake aside); full Java suite:
9/10 targets green (same flake); baml_bridge Gradle unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aaronvg
aaronvg added this pull request to the merge queue Aug 8, 2026
Merged via the queue into canary with commit 2fa0d0d Aug 8, 2026
84 checks passed
@aaronvg
aaronvg deleted the fix/inbound-json-container-typing branch August 8, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant