Skip to content

fix(sdk-rust): box class cycles that pass through type aliases - #5374

Merged
rossirpaulo merged 1 commit into
canaryfrom
paulo/rust-alias-cycle-box
Oct 23, 2026
Merged

rossirpaulo merged 1 commit into
canaryfrom
paulo/rust-alias-cycle-box

Conversation

@rossirpaulo

@rossirpaulo rossirpaulo commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Stack: bottom of three. #5287 (json → serde_json::Value) is stacked on this PR, and #5375 (optional serde impls) on #5287. This one targets canary directly.

Bug

The Rust generator boxes a class field when the field sits on a containment cycle, but its cycle analysis ignored type aliases. It assumed alias references were inlined before codegen. They are not: they stay nominal in the symbol pool. So a cycle through an alias was never boxed:

class Text { text string }
class Extra { part Part }
type Part = Text | Extra

baml generate emits pub part: crate::Part, and the generated crate does not compile:

error[E0072]: recursive types `Extra` and `TextOrExtra` have infinite size

Nothing in the current fixtures hits it.

Fix

Emitted type aliases become nodes of the existing containment graph. Each one gets edges to whatever its target holds inline. The SCC pass and needs_box then treat an alias reference like a class reference. That covers a direct field, an optional field, a union arm and alias chains:

pub struct Extra {
    pub part: ::std::boxed::Box<crate::Part>,
    pub maybe: ::std::option::Option<::std::boxed::Box<crate::Part>>,
    pub alt: ::std::boxed::Box<crate::PartOrString>,   // `Part | string`
    pub outer: ::std::boxed::Box<crate::Outer>,        // `type Outer = Part`
    pub parts: ::std::vec::Vec<crate::Part>,           // a Vec already breaks the cycle
}
pub struct Holder { pub part: crate::Part }            // not on the cycle: no box

Production diff: +50/−32 in sdkgen_rust (analyze.rs, translate_ty.rs, one doc line in emit/type_alias.rs). Most of the removed lines are comments that described the old "aliases are inlined" assumption.

Tests

  • New recursion_through_a_type_alias_boxes_the_alias_reference in sdkgen_rust/src/lib.rs. With canary's generator it fails (the Extra fields come out unboxed); with the fix it passes.
  • cargo test -p sdkgen_rust: 63 passed.
  • cargo nextest run -p sdk_test_rust: 17/17 passed. That run generates, rustfmts, clippies and tests every Rust fixture crate.
  • End to end: the project above, generated with baml-cli and checked with cargo check and cargo clippy -D warnings. It fails with E0072 on canary and is clean with the fix.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved Rust SDK generation for recursive type aliases, including aliases referenced through class fields, nullable fields, and unions. Generated references are now boxed where needed to support recursive structures while leaving references outside the cycle unboxed. Generation no longer reports warnings for the covered recursive alias patterns.

Alias references stay nominal in the symbol pool, but the containment
analysis assumed they were inlined and gave them no edges. A cycle
through an alias, such as

    type Part = Text | Extra
    class Extra { part Part }

emitted `pub part: crate::Part`, and the generated crate failed to
compile with E0072 (recursive type has infinite size).

Make emitted aliases nodes of the containment graph, with edges to what
their target holds inline. An alias reference on a cycle (directly, as
an optional, or as a union arm) is then boxed like a class reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
developer-docs Ready Ready Preview Oct 22, 2026 7:20pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f51b40f2-7005-4d32-a904-c1649ea30b1b
📥 Commits

Reviewing files that changed from the base of the PR and between 6aa168e and e815ef6.

📒 Files selected for processing: 4
  • baml_language/sdks/rust/sdkgen_rust/src/analyze.rs
  • baml_language/sdks/rust/sdkgen_rust/src/emit/type_alias.rs
  • baml_language/sdks/rust/sdkgen_rust/src/lib.rs
  • baml_language/sdks/rust/sdkgen_rust/src/translate_ty.rs

Included review availability: This review used your included allowance. 6 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.

📝 Walkthrough

The Rust SDK generator now includes emitted type aliases in non-heap containment analysis and SCC detection. Translation applies boxing requirements to emitted alias references and alias arms in unions. A regression test checks recursive references, list and non-recursive references, and warning-free generation.

Changes: Alias-mediated recursion boxing

Layer / File(s) Summary
Alias-aware containment and SCC analysis
baml_language/sdks/rust/sdkgen_rust/src/analyze.rs, baml_language/sdks/rust/sdkgen_rust/src/emit/type_alias.rs
Emitted aliases and their non-heap references now participate in the containment graph and SCC analysis. The documentation describes alias references as nominal references to emitted items.
Alias boxing in Rust type translation
baml_language/sdks/rust/sdkgen_rust/src/translate_ty.rs, baml_language/sdks/rust/sdkgen_rust/src/lib.rs
Translation applies boxing decisions to emitted alias references and alias arms in unions. The regression test checks recursive, nullable, and union references, plus list and non-recursive cases. It also checks that generation emits no warnings.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Analysis as Alias analysis
  participant Translation as Type translation
  participant Generator as Rust SDK generator
  participant Test as Regression test
  Analysis->>Analysis: Add emitted aliases to containment graph
  Analysis->>Translation: Provide SCC-based boxing decisions
  Translation->>Generator: Emit alias references with required boxing
  Generator->>Test: Produce output checked by regression assertions
Loading

Suggested reviewers: 2kai2kai2

Merge Risk: ⚪ Minimal · up to e815e

The alias-recursion change is ready to merge after normal checks; no actionable compatibility issue remains.

Security architecture risk: ⚪ Minimal · up to e815e

The change corrects recursive Rust type layouts without expanding runtime privileges or data access. Boxing changes affected field types, but existing conversions preserve their value representation.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Within the traced path, schema-controlled alias relationships affect generated Rust representation. The changed flow does not grant additional authority or introduce a new data-access sink; its externally visible effect is recursion-aware field boxing.
Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely identifies the main change: boxing class cycles that pass through type aliases in the Rust SDK generator.
Docstring Coverage Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 4 files.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.

A rabbit checks the alias trail,
Through SCC loops and paths that wind.
The boxes guard recursive links,
While lists keep their elements light.
No warning rustles in the output.
The rabbit hops back, pleased to find.

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

@github-actions

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

Copy link
Copy Markdown

Binary size checks failed

❌ 2 violations · ✅ 1 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 62.2 MB 26.5 MB file 73.1 MB -10.9 MB (-14.9%) OK
❌ packed-program Linux 🔒 40.4 MB 16.5 MB file 29.2 MB +11.3 MB (+38.7%) FAIL
❌ bridge_wasm WASM 26.5 MB 🔒 7.8 MB gzip 5.7 MB +2.1 MB (+37.0%) FAIL

🔒 = 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.

Details & how to fix

Violations:

  • packed-program (Linux) file_bytes: 40.4 MB exceeds limit of 30.1 MB (exceeded by +10.3 MB, policy: max_file_bytes)
  • packed-program (Linux) file_delta_pct: +38.7% exceeds limit of 3.0% (exceeded by +35.7pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 7.8 MB exceeds limit of 5.9 MB (exceeded by +1.9 MB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +37.0% exceeds limit of 3.0% (exceeded by +34.0pp, policy: max_delta_pct)

Add/update baselines:

.ci/size-gate/wasm32-unknown-unknown.toml:

[artifacts.bridge_wasm]
file_bytes = 26478742
gzip_bytes = 7756788

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.packed-program]
file_bytes = 40444040
gzip_bytes = 16459965

Generated by cargo size-gate · workflow run

@rossirpaulo
rossirpaulo marked this pull request as ready for review October 22, 2026 19:45
@rossirpaulo
rossirpaulo added this pull request to the merge queue Oct 23, 2026
Merged via the queue into canary with commit 83c3abe Oct 23, 2026
88 checks passed
@rossirpaulo
rossirpaulo deleted the paulo/rust-alias-cycle-box branch October 23, 2026 01:06
meefs pushed a commit to meefs/baml that referenced this pull request Oct 23, 2026
…oundaryML#5287)

**Stack:** middle of three. Stacked on BoundaryML#5374 (the alias-cycle fix,
number assigned when pushed); merge that first, then retarget this PR to
`canary`. BoundaryML#5375 (optional serde impls) is stacked on this one.

## Bug

The Rust SDK generator silently drops everything that touches BAML's
`json` type. With

```baml
class Event {
    id: string,
    payload: json,
}
function echo(event: Event) -> Event { event }
function count(items: json[]) -> int { items.length() }
```

all three symbols are skipped for Rust. TypeScript, Python and Go emit
them.

```
warning: skipped `user.Event`: references skipped or unknown type `baml.json.json`
warning: skipped `user.count`: argument `items`: references skipped or unknown type `baml.json.json`
warning: skipped `user.echo`: return: references skipped or unknown type `user.Event`
```

The warnings only show with `baml generate -F display_all_warnings`.
Projects whose types contain free-form JSON (OpenAPI `{}` schemas,
`additionalProperties`, opaque payloads) lose most of their Rust
surface.

**Cause.** `json` is the recursive stdlib alias `null | bool | int |
bigint | float | string | json[] | map<string, json>`. `sdkgen_rust`
emits aliases as Rust `type` items, which cannot be recursive, so
`analyze.rs` skips every recursive alias, and the fixpoint then skips
every class, alias and function that references one.

## Fix

Special-case the canonical alias by its exact FQN (`BAML_JSON_JSON`, as
the Go generator does) and map it onto `serde_json::Value`.

- **Bridge (`baml_bridge`):**
- `baml_bridge::Json` (= `serde_json::Value`), with conversions in both
directions and `baml_ty()` naming the `baml.json.json` alias.
- `serde_json` is re-exported, so generated crates and their users need
no new dependency (`baml_bridge::serde_json::json!`).
  - New `DecodeError::NotJson` (`DecodeError` is `#[non_exhaustive]`).
- **Generator (`sdkgen_rust`):**
- `baml.json.json` translates to `::baml_bridge::Json` everywhere:
fields, arguments, returns, containers and generic arguments.
- The alias item is emitted as `pub type json = ::baml_bridge::Json;`,
and user aliases of it resolve to the same type.
  - Look-alike user recursive aliases keep the existing skip.
- **`json?` is `Option<Json>`**, following the generator's uniform `T?`
→ `Option<T>` rule. BAML null decodes as `None`.
- **Unions.** A bare `json` arm next to other arms (`json | Foo`)
overlaps them on the wire, so such unions still skip (fail closed).
Container arms (`json[] | string`) can be told apart and are emitted.
- **Clippy allow.** Emitting `json` makes the OpenAI vendor client
representable. Its verbatim docstring trips
`clippy::doc_lazy_continuation`, which failed the SDK suite's `clippy -D
warnings` gate, so the lint is allowed at the generated crate root next
to the existing naming allowances.

### Numbers

BAML `int` is 63-bit (the VM keeps a tag bit), so its range is `[-2^62,
2^62 - 1]`, and the engine rejects a wider host `int`. A json integer
outside that range is therefore sent as a BAML `bigint`, which is the
arm `baml.json.parse` gives the same token. A `bigint` coming back
decodes when it fits `i64` or `u64`. Every `i64` and `u64` round-trips,
encoding cannot fail, and no bigint type appears in the Rust API.

| Rust value sent | BAML sees | Comes back as |
| --- | --- | --- |
| `json!(42)` | `int` | `42` |
| `json!(4611686018427387904)` (2^62) | `bigint` | same (`i64`) |
| `json!(i64::MIN)` | `bigint` | same |
| `json!(u64::MAX)` | `bigint` | same (`u64`) |
| (BAML returns `(2n).pow(64n)` in a `json`) | `bigint` |
`Error::Decode(NotJson)`: "baml.json.json value has no serde_json form:
an integer outside the i64/u64 range" |
| (BAML returns `float.nan()` in a `json`) | `float` |
`Error::Decode(NotJson)`: "... a non-finite float" |

Floats are finite `f64`, and an integral float stays a float. Objects
follow `serde_json::Map` order. Decoding also fails with `NotJson` on a
value that is not JSON at all (class, enum, media, bytes, handle).

Only when some crate in the build enables `serde_json`'s
`arbitrary_precision` can a `Number` hold more than `i64`/`u64`/`f64`.
Then a wider integer is sent exactly as a `bigint`, and a float beyond
`f64`'s range (`1e400`) is sent as ±infinity, which is how
`str::parse::<f64>` reads it. Both are documented on `Json`.

### `serde_json` features (BoundaryML#4527)

Today `baml_bridge` turns on `serde_json`'s `arbitrary_precision` and
`preserve_order` in every downstream crate; BoundaryML#4527 stops that. This PR
neither needs nor adds either feature, and its tests pass with them on
(current `canary`) and off (`canary` + BoundaryML#4527). The bridge names its
`serde_json` in one place (`pub use serde_json;` in `lib.rs`; `json.rs`
imports through `crate::serde_json`), so whichever of the two PRs lands
second changes that one line.

## Tests

- `cargo test -p sdkgen_rust`: 69 passed. Five of the six new tests fail
on the base branch's generator: canonical json in fields and signatures
with no skips, no alias item needed in the pool, a user alias of `json`,
a bare-json union failing closed, and `json[] | string` being emitted.
The sixth pins that a look-alike recursive alias still skips.
- `cargo nextest run -p baml_bridge --all-features`: 100 passed.
- Unit tests: nested round trips; BAML int bounds; `2^62`, `i64::MIN`,
`i64::MAX` and `u64::MAX` sent as `bigint` and decoded back exactly;
rejection of out-of-range bigint, invalid hex, NaN/±inf, class, media
and bytes; literal envelopes; `Option<Json>`; the
`arbitrary_precision`-only numbers; and `CallOptions::argument(..,
json!({"id": u64::MAX}))`, which panicked on the previous revision of
this PR.
- Live engine tests: round trips at the top level, in lists, maps and
classes, as `json?`, as generic `T = json` and as `json[] | string`, all
with full-width integers; BAML-side narrowing (a host `2^62` matches
`let b: bigint`); `baml.json.parse` of `i64::MAX`/`i64::MIN`; a BAML
bigint `2^64 - 1` decoding to `u64::MAX`; `2^70` and NaN failing to
decode.
- `cargo nextest run -p sdk_test_rust --all-features`: 17/17 passed.
`function_calls/test_json.rs`, previously gated on this projection, is
now enabled. It covers the Go suite's canonical cases plus full-width
integers as arguments and as host-callback return values.
- `cargo fmt --check` with the repo's import options, and `cargo clippy
--all-targets --all-features -- -D warnings` on `baml_bridge`,
`sdkgen_rust`, `sdk_test_codegen` and `sdk_test_rust`: clean.

## Open questions for reviewers

1. **`1e400` under `arbitrary_precision`** becomes ±infinity rather than
an error. Encoding is infallible (`to_baml` has no error channel), so
the alternative is an error side channel just for this case.
2. **`json?` as `Option<Json>`**, versus collapsing it to `Json` the way
Go does (`any`, not `*any`).
3. **Unions with a bare `json` arm** stay skipped. The engine keeps
`baml.json.json` intact in a union's `self_type` and sends the selected
index, so `json | SomeClass` looks supportable later.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added JSON value support to the Rust SDK, including nested values,
optional nulls, and JSON fields in generated types.
* Made `serde_json` available through the SDK for constructing JSON
values without a separate dependency.
* **Bug Fixes**
* Improved error messages when BAML values cannot be represented as
JSON.
* **Tests**
* Expanded coverage for JSON round trips, numeric boundaries, unions,
callbacks, and decoding errors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
meefs pushed a commit to meefs/baml that referenced this pull request Oct 23, 2026
)

**Stack:** top of three. Stacked on BoundaryML#5287 (`json` →
`serde_json::Value`), which is stacked on BoundaryML#5374. Merge those first, then
retarget this PR to `canary`.

Closes BoundaryML#4969.

The old engine's Rust generator gave generated types serde impls
(BoundaryML#3118). The new generator's types don't have them, so a Rust host can't
persist or replay a typed result without writing its own mapping code.
This PR restores that, behind an opt-in `serde` feature on the generated
crate:

```toml
baml_sdk = { path = "baml_sdk", features = ["serde"] }
```

The feature adds only a `serde` dependency (with `derive`); union impls
use the `serde_json` that `baml_bridge` already re-exports. Without the
feature, the serde attributes and impls compile away.

## Shape

The JSON matches BAML's own `baml.json` (`to_string` and `from_string`):

- **Classes** derive `Serialize`/`Deserialize`. Keys are the BAML field
names, so a field whose Rust name differs (`r#type`, `crate_`) gets
`#[serde(rename = "...")]`. `None` serializes as `null`. A missing
optional field reads as `None`, and unknown keys are ignored.
- **Enums** serialize as the variant name.
- **Unions** are untagged. Deserializing tries the arms in declaration
order and takes the first that fits, as BAML does. A string-literal arm
is its string.
- **`json` fields** are `baml_bridge::Json` (`serde_json::Value`, from
BoundaryML#5287), so they serialize as the JSON itself, including full-width
`i64`/`u64` integers.

```rust
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))]
pub struct Label {
    pub text: ::std::string::String,
    pub note: ::std::option::Option<::std::string::String>,
}
```

Union impls are written out instead of using `#[serde(untagged)]`.
`baml_bridge` turns on `serde_json/arbitrary_precision` (until BoundaryML#4527
lands), and with that feature serde's untagged buffer cannot read floats
(`1.5`, or a class arm with a float field). The impls buffer through
`serde_json::Value` instead, which works with the feature on or off.

## Not covered

These types get no impls, and the generated docs name the field that
blocks it:

- classes holding a `bigint` (num-bigint's serde form is `[sign,
digits]`, not BAML's JSON number);
- classes holding a `uint8array` (BAML has no JSON form for it);
- classes holding functions or handles;
- BAML standard-library classes, because several of them serialize
through a `baml.ToJson` override (`baml.time.PlainDate` is a date
string).

The impls are structural, so a `baml.ToJson` override on a user class is
not applied. The symbol pool doesn't record `implements` yet. This is
noted in the generated `Cargo.toml`.

## Tests

- `sdkgen_rust` unit tests (74 passed) cover the manifest feature, the
class, enum and union shapes, classes and unions holding `json`, and the
types that get no impls.
- A new `type_shapes` fixture namespace, `ns_json_interchange`, adds
generic `to_json<T>` / `from_json<T>` wrappers around `baml.json`. Seven
Rust end-to-end tests compare serde_json and BAML on the same values and
the same text:
- classes, optional fields (absent and `null`), enums, unions, maps,
lists, nested classes and keyword-named fields;
- a class with `json`, `json?` and `json[]` fields, including `i64::MIN`
and `u64::MAX`;
  - BAML reads serde's output back, and serde reads BAML's;
  - inputs that BAML rejects are rejected by both.
- The Rust sdk-test harness now runs `clippy` and `cargo test` on each
fixture crate with `--all-features`, so every generated fixture type
compiles with the feature on and is linted with `-D warnings`. `cargo
nextest run -p sdk_test_rust`: 17/17 passed.
- The new fixture namespace is shared: the Go, Python and TypeScript
`type_shapes` suites pass with it (10/10), and the Java, C++, Swift and
TypeScript-web generators emit it without errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Generated Rust SDK types now support optional Serde serialization and
deserialization for compatible classes, enums, and unions, using their
BAML field and variant names.
* Compatible types can be used with BAML JSON interchange, including
nested values, optional fields, and JSON fields.
* **Bug Fixes**
* Union deserialization now checks matching variants in declaration
order.
* **Tests**
* Rust SDK checks now include all features and cover JSON interchange,
serialization, and invalid input handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

This branch was successfully deployed

1 active deployment
Preview – developer-docs — e815ef65 Deployed Oct 22, 2026 by vercel[bot]
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