Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

docs(python): clarify editable profile scope - #25

Draft
Sam Lijin (sxlijin) wants to merge 9 commits into
indexable-inc:up/python-bridge-oncefrom
BoundaryML:agent/pr4482-release-comment
Draft

Sam Lijin (sxlijin) wants to merge 9 commits into
indexable-inc:up/python-bridge-oncefrom
BoundaryML:agent/pr4482-release-comment

Conversation

@sxlijin

@sxlijin Sam Lijin (sxlijin) commented Aug 19, 2026

Copy link
Copy Markdown

Summary

Remove the verbose explanatory comments introduced by BoundaryML#4482 and retain only a short clarification that editable-profile applies to PEP 660 editable builds such as uv sync, not maturin build or the Python release workflow.

This is stacked directly on BoundaryML#4482, so its diff contains only the documentation clarification.

Validation

  • Parsed pyproject.toml with Python's tomllib
  • git diff --check

Sam Lijin (sxlijin) and others added 9 commits August 19, 2026 03:21
## Summary

- bump all legacy BAML v0 release surfaces from 0.226.0 to 0.226.1 using
the repository version-bump configuration
- release the OpenAI token-detail compatibility fix from #4503
- synchronize the new 0.226.1 sections in the top-level and Fern
changelogs
- regenerate all nine integration clients and refresh the engine, Rust
SDK, and generated Rust integration lockfile versions

## Release boundary

- previous release: 0.226.0 / merge commit
2fde4f7
- sole shipped engine change: #4503 / merge commit
378cdcc
- the reviewed #4503 head and final merge diff have the same stable
patch ID
- this branch is based exactly on the #4503 merge commit in canary
- `gh stack link` was attempted while preparing the dependency, but
#4503 merged before submission and the extension does not allow merged
PRs in a new stack; #4524 is therefore a one-layer gh-stack rooted on
the fulfilled dependency in canary

## Validation

- TypeScript runtime build and nine-client generation
- Python runtime build/install and nine-client generation
- deterministic regeneration: complete binary diff hash unchanged on
repeat
- `cargo fmt --manifest-path engine/Cargo.toml --package baml-runtime --
--check`
- `cargo test --manifest-path engine/Cargo.toml -p baml-runtime --lib`:
185 passed
- locked Cargo metadata for engine, Rust SDK, and generated Rust
integration workspaces
- generated Rust integration library build: `cargo test --manifest-path
integ-tests/rust/Cargo.toml --lib --locked`
- release-owned stale 0.226.0 audit clean
- exact 38-file match to the 0.226.0 release workflow; all 36
non-changelog files are pure 0.226.0 to 0.226.1 substitutions

## Status

- all applicable GitHub Actions checks passed
- repository CodeRabbit review completed with no findings; hosted
CodeRabbit status is green and there are no review threads
- current with canary and ready to merge
Follows #4501, which carried runtime *definitions* through interface
dispatch and closed
by naming what it did **not** fix:

> **Identity does not cross dispatch, only definitions do.**
`type.of<T>()` inside an
> interface-impl method re-mints […] Nothing here depends on mint
equality; making
> identity survive dispatch means carrying exact values through
`realize_frame`, which is
> a separate change.

This is that change. Antonio ratified carrying the exact values through,
on the same
discipline as the defs carry.

## The hole

`VirtualCall` resolves the impl from the receiver's realized `Self` and
seeds the callee
frame from `resolver.realize_frame`, which produces **realized types
only**. The exact
`TypeValue`s the caller minted reach the callee frame's
`FrameTypeMetadata.values` from
`append_virtual_method_type_args` alone — i.e. from *method-level* type
arguments. The
receiver's class-level slots got definitions but no values, so
`LoadType(TypeArgRef)` in
the body fell through to `alloc_static_type_with_defs` and derived a
**fresh static
digest**.

The result was a type value that named the same definition and rendered,
parsed and
reflected identically, but was `==`-distinct from the one the caller
passed:

```baml
class Holder<T> {
    implements Probe<T> {
        function same(self, t: type) -> bool { type.of<T>() == t }   // false
    }
}
```

Structural checks all passed, so the failure was silent: a map keyed by
type missed, an
`==` against a stored type went the wrong way, and nothing reported an
error.

The direct paths were already correct — a generic function call and a
generic-class
instance method both thread the call site's type-argument operands,
values included. Only
the resolver path dropped them.

## The fix

A runtime definition records the mint it was created with
(`RuntimeTypeProvenance`), so
the caller's identity can be **read back off the definition** instead of
derived afresh —
never a new mint, which is what BEP-066 I-1 requires. The interface
operand already
carries the definitions the receiver's class-level slots name (that is
#4501's overlay),
so those two facts together reconstruct the exact value:

- `BexVm::runtime_declaration_identity(definition_ptr)` rebuilds the
minted value for a
runtime class or enum, adding the definition's own pointer back to its
provenance defs.
`type.of_value` was already doing exactly this inline; both now share
the one
  construction.
- `BexVm::minted_declaration_value(ty, defs)` resolves a frame slot's
realized type
against the operand's overlay, **only for a mint-unique name** — see
below. The
reconstructed value must also describe the same type or it is refused,
so a decorated or
  parameterized spelling can never borrow a definition's mint.
- `VirtualCall` fills the owner slots with what that recovers and hands
them to the callee
frame ahead of any method-level slots, which keep their existing
positions.

### Why recovery is restricted to `$dyn` names

A `DynTypeDefs` is keyed by `QualifiedTypeName`, and **only a
`runtime_local` name carries
its mint in the name** (`user.$dyn.N.Foo` — what `reflect.class.new` and
`reflect.enum.new`
produce). A static declaration and a compiled package's declaration are
both plain
`user.Foo`. An overlay also reaches a frame whether or not the spelling
being recovered is
the one that pulled it in, because `LoadType` staples the whole frame
overlay onto anything
materialized there.

Matching an ordinary name against the overlay therefore answers from a
*different*
definition. Both shapes are now regressions in this PR, and both were
verified failing
without the `is_runtime_minted` gate:

| shape | ungated | gated |
| --- | --- | --- |
| static `Holder<Item>` in a frame that also bound a compiled package's
`Item` | `type.of<T>() == type.of<Item>()` is **false**, `==
package_item` is **true** | `true`, `false` |
| two compiled packages each declaring `Item`, dispatching on
`Holder<B>` | `== b_item` is **false**, `== a_item` is **true** |
`false`, `false` |

The first is a regression against canary, which got it right by never
recovering at all.
The second is `==` lying about a type it is not, which is worse than not
knowing. So
anything but a mint-unique name declines and the body re-derives
normally.

## Cost

The added work is gated on the interface operand carrying definitions at
all. A static
interface operand leaves `iface_defs` empty, so the owner-slot walk
never runs and the
values vec never allocates; the existing no-type-args fast path is
entered on exactly the
same condition as before. The four `interfaces/*` speedtest workloads
are static-interface
dispatch and are structurally untouched by that reasoning — but note
honestly that **no
bench covers the runtime-definitions dispatch path**, and CodSpeed was
not run for this
branch, so the "definitions present" case is argued, not measured.

When the operand does carry definitions, the walk is `O(owner slots)`
(0–2 in practice)
map lookups, and each *recovered* slot clones the definition's
provenance defs — the same
`O(defs)` shape as the overlay clone #4501's F4 note flagged. Note the
frames this runs in
are exactly the ones where `LoadType`'s static cache is already disabled
(a non-empty
overlay disables it), so the recovery does not add an allocation that
was previously
avoided. `Arc<DynTypeDefs>` remains the lever for both; it is not a
drop-in, because GC
forwarding rewrites the pointers *inside* a `DynTypeDefs` in place, so
sharing would have
to be unshared again exactly where it pays off. That reasoning is now a
comment at the
clone rather than only in a PR body.

## Documented gaps

Both are the same shape — the receiver is the only thing that could
carry the identity, and
`Instance` drops its class type-argument *values* at construction (it
stores realized types
only; `Object`'s 64-byte assert and its Borsh wire form make a values
lane a change to the
object model, not an implementation detail).

**Derived runtime types.** `t.array()`, `t.optional()` and
`type.meta(…)` mint a fresh
runtime id per evaluation and attach it to no definition, so there is
nothing to read back:
`Holder<RuntimeOutput[]>`'s impl body still sees a re-derived value. The
alternative —
deriving a derived type's mint from its parts — would make `t.array() ==
t.array()` true in
ordinary code too, and contradicts I-1's "one per constructor
evaluation".

**Declarations from a compiled `reflect.Package`.** Their names are not
mint-unique, which
is exactly the restriction above. Their *definitions* still travel
(#4501), so an impl body
can read, render and parse the type; it just does not hold the caller's
identity token for
it. `runtime_package_declarations_keep_definitions_but_not_identity`
pins that contract
rather than leaving it silent. The honest futures are to give
compiled-package declarations
mint-unique names, or to carry the values on the receiver — the same
lever as the derived
case.

Neither is a ruling this PR should make; written up separately.

## Follow-up noted in code, not fixed

`execute_call_from_locals_offset_with_type_args` restores
`pending_call_type_values`
(the rooted copy) before reading `options.type_values`, which borrows an
unrooted caller
local. A collection in between would leave those pointers stale. It is
unreachable as
written — the callee-entry helper pushes a frame and sizes the eval
stack with no TLAB
allocation, and the native path that can allocate pushes no bytecode
frame, so the write
guard declines — and it predates this PR (#4501 introduced the lane).
Recorded as a comment
because this lane now carries recovered identities too.

## Tests

Runtime-output oracles in `runtime_type_bindings.rs`:

| Test | Covers |
| --- | --- |
| `minted_type_identity_survives_interface_dispatch` | implements-block
method, inherited default method, two-hop dispatch out of an impl body |
| `interface_impl_methods_look_up_a_type_keyed_registry` | the registry
pattern — entries keyed by the call site's type value, matched by `==`
inside the impl, with a same-shape same-name entry as the in-test miss |
|
`dispatch_identity_separates_distinct_mints_and_leaves_static_generics_alone`
| negative control (two mints of the same shape stay unequal) and static
generics (`Holder<string>`) unchanged in both directions |
| `dispatch_identity_covers_owner_and_method_slots_together` | a generic
method on a generic impl: owner slot recovered, method slot supplied,
neither crossing |
| `dispatch_identity_covers_a_runtime_enum_slot` | the enum arm of the
recovery, which nothing else reaches |
|
`static_class_slots_are_not_answered_from_a_same_named_runtime_definition`
| the static-vs-package collision above |
| `same_named_declarations_from_two_packages_do_not_cross_match` | the
two-packages collision above |
| `runtime_package_declarations_keep_definitions_but_not_identity` | the
documented gap, pinned |

Unit tests in `vm.rs` pin the owner/method slot alignment: recovered
owner values precede
method-level slots, and a non-generic method still receives them.

## Verification

Focused: `runtime_type_bindings` (18/18), `bex_vm` unit + integration.
The two collision
regressions were re-verified failing with the name gate removed
(`false|true` in both
cases). Full pinned gate below.


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

* **Bug Fixes**
* Preserved runtime-minted type identity across interface, inherited
default-method, and multi-hop dispatch.
* Improved type comparisons and registry lookups for runtime-generated
classes and enums.
  * Maintained correct runtime package ownership during reflection.
* Fixed type propagation through sparse owner slots and non-generic
method dispatch.
  * Corrected truthiness handling for supported values and negation.

* **Tests**
* Added coverage for runtime classes, enums, separate packages,
same-named declarations, and dispatch scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- replace `arduino/setup-protoc` with a local cross-platform installer
pinned to protoc 23.4
- download official release assets directly instead of querying the
GitHub Releases API
- verify each platform archive against a pinned SHA-256 checksum
- route the Rust SDK workflow and shared Rust setup action through the
local installer

## Root cause

Both 0.226.1 release runs failed their Windows ARM64 TypeScript build
while `arduino/setup-protoc@v3` paginated the GitHub Releases API. The
installation token had exhausted its API quota, so protoc setup failed
before compilation.

## Validation

- `actionlint .github/workflows/test-rust-sdk.yml`
- YAML parse validation for both composite actions and the workflow
- downloaded and checksum-verified the official macOS ARM64 asset
- installed the archive locally and verified `libprotoc 23.4`
- independently downloaded and recorded SHA-256 checksums for all six
selected protobuf 23.4 release assets

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

## Summary by CodeRabbit

- **Chores**
- Added a repository-managed setup process for Protocol Buffers compiler
installation on Unix and Windows environments.
- Added checksum verification and explicit platform validation for
compiler downloads.
  - Updated Rust setup to optionally skip compiler installation.
- Updated Rust SDK validation workflows to use the shared setup process
and respond to related configuration changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Implements the reserved half of
[B-1582](https://linear.app/boundaryml2/issue/B-1582) item 3 — the
ratified
specialization API. #4501 fixed everything about runtime types that did
not need
a new surface; this is the surface.

## What it looks like

```baml
let descriptor = pkg.functions().get("root.Extract$render_prompt")
    ?? throw "not listed"

descriptor.is_generic()          // true
descriptor.generic_params()      // [ GenericParam { name: "T" } ]

let specialized = descriptor.specialize([record.as_type()])
let render = specialized.get<PromptFn>() ?? throw "no callable"
render("records").text()         // embeds the runtime class's schema
```

`baml.reflect.function.Type` gains:

| method | contract |
|---|---|
| `is_generic(self) -> bool throws never` | still expects type arguments
|
| `generic_params(self) -> GenericParam[] throws never` | names + count,
declaration order |
| `specialize(self, args: type[]) -> Type throws CompilationError` |
arity + bounds checked |
| `get<F>(self) -> F? throws CompilationError` | the callable, through
an `F` contract |

`Package.functions()` now lists **every** declared function, generics
included.

## Why the listing changed

The omission was never a decision: `functions()` `filter_map`ped over
`function_type`, which returned `None` whenever `callable_signature`
failed —
which is exactly what an unspecialized generic does
(`TyTemplate::substitute`
hits `TypeArgRefOutOfRange`, and `.ok()` erases it). "Listed but not
extractable"
already existed on canary for generic companions since #4501. This PR
makes both
states first-class and actionable instead of a dead end.

## How it works

A reflection kind view *is* the `Object::Type` value (`as_type` returns
the
receiver), so a descriptor has to be a `type` value that also remembers
its
callable. Two additive, provenance-only payload fields:

- **`TypeValue.callable: HeapPtr`** — the `Object::GenericFunction` a
descriptor
was reflected from, null everywhere else. Outside the identity tuple:
`==`/`Hash`
stay mint-only, so two descriptors of equal type remain equal type
values. GC
  traces it exactly like `owner`.
- **`GenericFunction.exact_type_values:
Option<Box<[Option<TypeValue>]>>`**
(`#[borsh(skip)]`, `None` for every compile-time instantiation, so
pooled
  interned objects stay byte-identical) — the exact `type` values behind
  `type_args`. `execute_call_from_locals_offset` seeds the callee's
  `FrameTypeMetadata` from it, which is how `LoadType` hands the body's
`type.of<T>()` back the caller's own minted value with its `DynTypeDefs`
overlay attached. Without that lane the specialized `$render_prompt`
companion
would render a bare unresolvable name instead of the runtime class's
schema.

Everything else is assembly of parts that already existed:

- **arity/genericity** — `type_args.len() < generic_param_bounds.len()`,
the same
  question `unspecialized_generic_callable_name` asks (`vm.rs:2636`).
- **bounds** — the proof `validate_runtime_generic_bounds` runs before
entering a
runtime-checked generic call: substitute the bound's `args`/`assoc`
against the
completed frame, then `ImplResolver::type_implements`. Rooted at the
*supplied
value's* dynamic world (`for_value`) so a runtime-minted type's impls
are
visible, and reported as a typed diagnostic instead of a bare
"mismatched types".
  `baml.AnyClass` keeps its #4493 carve-out for free.
- **`specialize`** — build a `GenericFunction` with the completed
`type_args` plus
the exact values; `callable_signature` then reconstructs. Specialize ≈
"make
  `callable_signature` succeed".
- **`get<F>`** — `Package.get_function`'s contract check, factored into
  `check_function_contract` and shared verbatim.

## Contract changes to existing pins

Each of these is a deliberate change to something previously pinned:

1. **`Package.functions()` lists unspecialized generics.**
   `function_listing_omits_unspecialized_generics` →
`function_listing_includes_unspecialized_generics`, now also asserting
`is_generic()` on the generic entry and its absence on the concrete one.
The stdlib docstring's "unspecialized generic functions are omitted"
claim is
   deleted.
2. **`function.Type.params()` / `return_type()` gained a throws
channel**
   (`throws never` → `throws baml.reflect.errors.CompilationError`). An
unspecialized generic descriptor has no realized function type to
decompose;
reading one was an `unreachable!` before it was reachable, and is now
E0165.
`type_kinds.rs`'s `read_views` helper declares the channel accordingly.
3. **E0165's two messages changed.** Both said reflection "cannot supply
type
   arguments yet". It can now, so both name the route that works:
`Package.functions()` → `specialize`. Extraction *by name* is still
refused —
   a name lookup has nowhere to put type arguments — so
`unspecialized_generic_get_function_reports_reflection_limit` and the
four
`reflect_call_any` message pins keep their shape and take the new text.
`generic_function_companion_extraction_reports_reflection_limit` is
unchanged
   in behaviour; its doc comment now points at the route that does work.
4. **New E0169 `ReflectSpecializationFailed`**, appended at the end of
`DiagnosticId` (borsh discriminants are declaration-ordered), with six
shared
factories and their oracle rows in `runtime_type.rs`'s message table:
arity
mismatch, bound violation, not-generic, already-specialized,
not-a-descriptor,
   and the unreconstructible-signature backstop. (E0167 went to #4498's
always-constant-condition lint and E0168 to #4518's escaping-`unreflect`
diagnostic while this branch was open; E0169 is the next free code at
the
   rebase head.)

## Review round

**GC edges are structural now (blocker).** `TypeValue` carries three
heap
pointers — its owning package, its definition overlay, and (new here) a
descriptor's callable — and six sites walked that set by hand: the
collector's
major/forwarding/young arms, a frame's exact type arguments, the
pending-call
lane, and the `runtime_type` provenance on classes and enums. Adding a
field
meant editing all six, and missing one leaves a dangling pointer that
`get_object`'s unchecked deref turns into UB rather than a panic — which
is
exactly what the first cut of this PR did. The walk now lives on the
payload as
`TypeValue::gc_edges()` / `forward_gc_edges()` (with the same pair on
`DynTypeDefs` and `RuntimeTypeProvenance`, and a `young_edges` filter
for the
minor-collection arms), and every site calls it. The six-copy pattern is
gone,
so the next pointer-bearing field cannot repeat this. Two pre-existing
gaps fell
out: a runtime class field's type value never had its `owner` forwarded,
and the
class/enum provenance arms duplicated the same walk a third time.

Covered by a unit test that roots and forwards a callable-bearing frame
value,
plus two BAML tests that collect between every step — one holding a
descriptor
across collections and then specializing/extracting/calling it, one
specializing
with a runtime-minted class and reading `type.of<T>()` back out of the
callee
after a collection.

**Bounds resolve in the descriptor's world, not the caller's.**
`lookup_interface`
goes through `package_for_type`, which roots at the *executing frame's*
runtime
package. A bound declared inside a `Package.compile`d package is `Local`
to that
package, so proving it from a host call site found no interface, no
rules, and
rejected every argument — including conforming ones. `ImplResolver` now
resolves
the interface name against its `root_package` first and falls back to
the
lexical lookup (the name-resolution half of what `for_package` already
did for
rules), and `specialize` roots at the descriptor's package. The fallback
matters:
a goal can equally name an interface the inspected package *borrowed*
from a
mounted dependency, which is local to that dependency and only
resolvable the
lexical way — rooting alone broke `scenario_6`'s `PlanThenAct implements
app.AgentAction`. Pinned by a test that compiles a package declaring the
interface, a conforming class, and a non-conforming one: before the fix
the
*conforming* type was rejected.

**Exact values are positional, not structural.**
`params()`/`return_type()`
matched the supplied values against the reconstructed types by
structural
equality, so a parameter the author wrote as a concrete type could be
handed the
caller's type argument whenever the two happened to coincide. The
mapping is now
read off the callable's own `TyTemplate`s — a position reports an exact
value
only when it is written as exactly that type parameter
(`TyTemplate::TypeArgRef`);
a nested occurrence (`T[]`) decomposes normally and keeps the overlay.
Pinned by
a test whose second parameter is declared as the very class supplied for
`T`.

**Also:** re-specializing an already-bound descriptor gets its own
message
(telling the caller it "is not generic" pointed at the wrong mistake); a
fully
supplied frame that still fails to reconstruct now throws instead of
silently
producing an `unknown` descriptor that denies being generic; the call
hot path
reads a `GenericFunction`'s two carried lanes from one deref; and
`is_generic`
no longer allocates a name and an argument vector to answer an arity
question.

## Notes

- A descriptor is still a `type` value, so descriptor **equality is type
equality** — mint-only, and a specialization's mint is the static digest
of
its reconstructed function type. Two descriptors specialized from
different
runtime classes therefore compare equal when their signatures do not
mention
the type parameter (a `$render_prompt` companion is exactly that shape),
even
though their `return_type()`s differ. That is the BEP-066 rule working
as
designed — the descriptor denotes a function type, not an instantiation
— but
  it is worth knowing before anyone keys a cache on one.
- The arity message is phrased against the parameters *still* awaiting
arguments. Specialization is all-at-once today, so that is always the
full
count; if partial specialization ever lands, the message already says
the
  right thing.

## Deferred

- **Bounds in `generic_params()`.** A bound's args are `TyTemplate`s
over the
callee's own frame, so `T extends Comparable<T>` has no `type` value to
report
and every workaround is a policy choice (drop silently / substitute
`unknown` /
render a string / introduce a `Bound` row). Shipped names + count; the
ruling is
  written up separately. `specialize` enforces every bound regardless.
- **Static sugar `specialize<T1, …>()`.** Did not fall out cheaply — it
needs a
turbofish-to-`type.of<T>()` desugaring at the call site rather than a
native.
- **Family specialize.** Companions are specialized individually, as
ratified
  (`GenericList` and `GenericList$render_prompt` are separate entries).

## Tests

New `crates/baml_tests/tests/reflect_specialize.rs`, 13 cases: the
item-3 flow end
to end with a runtime-minted type (asserting the rendered prompt carries
the
runtime class's fields), the same shape with a static type, the
`is_generic`
truth table over all four cells, `generic_params` names/count,
specialized
signature readback, mint identity on both the descriptor and the
callable side,
arity mismatch, an interface-bound violation, an `AnyClass`-bound
violation,
specialize-on-non-generic, the unspecialized signature read, a
non-descriptor
function type, and contract enforcement on extraction.

## Snapshot churn

Eight files — **6 modified, 1 added, 1 deleted** — every one a
consequence of
adding one class and four methods to the stdlib:

1.
`baml_cli__…__describe_package_functions_documents_unspecialized_generic_omission.snap`
— **deleted with its test.** It existed to pin the omission contract in
   `baml describe`, and that contract is gone. Replaced by

`…__describe_package_functions_documents_the_generic_listing_contract.snap`,
   asserting the new docstring instead.
2. `baml_cli__…__render_builtin_package_listing.snap` — one added row,
`class baml.reflect.function.GenericParam`, plus the line-number shifts
in
   `reflect.baml` from the docstring edits.
3. `baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap`
—
`baml.reflect.function` gains `class GenericParam { methods: [] }`, and
`class Type`'s method list gains `is_generic, generic_params,
specialize, get`.
4-6. `__baml_std__` `03_ppir`, `04_5_mir`, `06_codegen` — the same class
(with
its generated `GenericParam$stream` companion) and the same four builtin
methods, at each stage. `05_diagnostics` is unchanged: the stdlib still
   compiles clean.
7-8. `bytecode_format__bytecode_display_expanded{,_unoptimized}` —
global slot
indices shift by exactly +4 (`call 917` → `call 921` and so on), the
four new
builtin functions in the global table. No instruction changes.
Regenerated
after the rebase rather than hand-merged, since the base numbers moved
too.

The reflection test suites themselves are `assert_eq!` on engine values,
so they
contribute nothing here.


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

* **New Features**
* Added reflection support for generic functions, including parameter
inspection, type specialization, signature access, and callable
retrieval.
* Generic functions and generated companions now appear in package
function listings.
* Added validation for specialization arguments, including count and
type-bound checks.
* Expanded truthiness behavior for the `!` operator beyond boolean
values.

* **Bug Fixes**
* Improved reflection errors and guidance for incomplete, invalid, or
unavailable generic signatures.
* Improved runtime type diagnostics with clearer messages and suggested
corrections.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ding (#4529)

Two defects from MIG_BRIEF Fix 4(b), shipped together because they are
one story — *a `let`
binding that lives in a global should behave like an ordinary binding* —
and because
splitting them would leave a catchable→uncatchable regression on canary
in between.

## 1 — a method call on such a binding reached the VM with no receiver

```baml
client MyClient = openai.ResponsesClient.new(model = "gpt-4o-mini", …)
function main() -> string { MyClient.id() }   // VM internal: expected instance, got any
```

**This is ordinary BAML, no Session anywhere** — a `client` declaration
lowers to a top-level
`let`, so it took the same defect. That is the largest behavioral
surface of this PR and it
is verified by A/B against canary's lowering.

The cause: a top-level `let` is an initialized **global**, not a lexical
local, so MIR's
`place_for_path` correctly finds nothing. Several roads read that
absence as *"no receiver"*
rather than *"not a local"* — a single-segment receiver became
`Constant::Null`, a
member-access base failed `base_is_value` so the receiver was dropped
from the call, and the
container/interface dispatch block was gated on `local_for_path`. The
null **is** the `any`
in the message: `Type::of` maps `ValueKind::Null` to `ObjectType::Any`.
Field access and
indexing were never affected, because those roads already loaded the
global — which is why
the bug presented as "method dispatch on a binding, specifically".

All roads now go through one `load_top_level_let_root`, factored out of
the field-chain road
that already did this inline.

### The `.length()` holdout, and why my first diagnosis was wrong

An earlier revision of this PR shipped with `v.length()` still broken
and blamed "container
methods whose owner is generic" / `builtin_kind: Some(Vm)` / emit's
inline opcodes. **All
three were wrong discriminators.** `v.join(…)`, `m.keys()` and every
other container method
already worked off the loaded global — A/B verified.

The real cause is one line of asymmetry in MIR: the `.length()` special
case emits
`Rvalue::Len(place)`, and `Len` is the **only** consumer that takes a
`Place` where every
other takes an `Operand`. The temp `lower_item_ref` defines is
`Use(Constant::GlobalItem)`, which emit's analysis classifies as a pure
constant and
*virtualizes* — re-emitted at each use rather than stored. Operand
consumers re-emit it
happily; the Place road read a slot nothing had written. Handing out a
materialized copy
makes the place a real defined local. `v.length()` and `m.length()` are
fixed, with
regressions.

Note-only, spotted there: that same match tests `"baml.string.length"`
lowercase against a
class named `baml.String` — a stale dead branch.

## 2 — a literal binding took the literal's type

`s.eval("let n = 5")` bound `n` at the type `5`. A `let` item has no
declaration signature,
so a reference recovers its type from the initializer's inference result
—
`type_of_expr[root_expr]`, the **expression's** type. Ordinary `let`
never binds that: it
applies `widen_fresh` first. This road skipped that step.

Unconditional here because a Session binding cannot opt out:
`lower_session_let` refuses any
pattern ascription, so `let n: int = 5` is not a legal submission at all
(pinned as a test,
since it is the reason there is no annotation branch).

### Consequences of the widening, all deliberate and all pinned

- **An eval contract naming the literal no longer accepts the binding.**
`s.eval<5>("n")`
was accepted and is now `submission result has type int, which is not a
subtype of
requested contract 5`. This is the PR's cleanest oracle — it flips
exactly at the change.
- **Match exhaustiveness moves to the base type.** `match (n) { 5 => …
}` on a session
binding is now `non-exhaustive match on type int; missing: _`. The
mirror also holds: a
complete `true`/`false` match on a bool binding is now legal where it
previously matched
  the literal `true` alone.
- The eval **result contract** itself still reads the unwidened
initializer type
  (`let_initializer_type`), so `s.eval<5>("5")` is unchanged.

## Correction: this PR does have one regressing spelling

An earlier revision claimed "there is no spelling that regresses".
**That is false**, and
here is the case: an interface-dispatched method on such a binding —
`n.compare(m)`, or a
session-local `implements` on a primitive — reaches a **pre-existing
broken road** and fails
with an uncatchable `InvalidArgumentCount { expected: 2, got: 1 }`,
where before the widening
the same spelling was a catchable `E0007: type \`5\` has no member
\`compare\``.

Verified identical before and after this PR's MIR change, so the
receiver fix does not cause
it; what the widening changes is **reachability** — a literal-typed
binding had no members at
all, so users never got there. Filed as MIG_BRIEF Fix 11 with the
evidence. Stated here
rather than papered over, because it is the honest cost of the widening.

## What this does not fix

- **Interface dispatch on a top-level-let receiver** — above; MIG_BRIEF
Fix 11.
- **A union-of-fresh-literals initializer does not widen.** `let picked
= if (c) { 1 } else
{ 2 }` binds `1 | 2` where ordinary `let` binds `int`. `widen_fresh`
keys on freshness, and
union canonicalization at `finish()` drops it, so by the time a
reference reads the
recorded initializer type there is nothing left to widen. Closing it
means recording the
let's *binding* type as its own product of the let's inference — which
then also has to
  avoid changing the result contract above.
- **`to_string` reports as absent** on these bindings (`type \`int\` has
no member
\`to_string\``), because the universal sugar road and the member walk
these paths use are
two different tiers and only the former carries universal members.
Cosmetic today — both
spellings error — but filed as MIG_BRIEF Fix 9, and the test that pins
one of these
  messages says so in its comment.
- **Mounted-package class methods inside a Session** (E0099 shadowing,
then "no member") —
  pre-existing, notebook-relevant, filed as MIG_BRIEF Fix 10.

## Related, filed not fixed

A Session assignment does not typecheck against its binding (`n =
"seven"` on an `int`
binding compiles), because an assignment lowers to a fresh `let __gen =
(value)` plus a
`commit_global` with no check. MIG_BRIEF Fix 8, with a recommendation
appended: check
through `let_initializer_type` while leaving `let` free to re-bind. Its
urgency rose with
this PR — an unchecked assignment used to be mostly inert on a `5`-typed
binding, and now
produces uncatchable crashes downstream.

## Cost

`load_top_level_let_root` runs a linear scope scan
(`resolve_name_at_in_scope`) on three
more roads than before. Measured in review: **+1.8% debug-compile
time**. The lever if that
ever matters is memoizing the resolution per `(expr, name)` —
deliberately not done here,
because caching the *loaded local* (rather than just the resolution)
would have to prove the
first load dominates the second use, and the two can land in different
blocks. Relatedly,
the dispatch-block `or_else` emits a dead duplicate global read when
that block declines the
call; both reads are pure constant fetches, and there is now a comment
saying so.

## Tests

`crates/baml_tests/tests/runtime_session.rs`, 27/27:

| Test | Covers |
| --- | --- |
| `session_top_level_lets_widen_literal_initializers` | the contract
oracle — `s.eval<5>("n")` refused post-widening |
| `session_let_widening_is_visible_through_member_resolution` | int,
string and bool each name their **base** type (annotated re: the
`to_string` gap) |
| `session_let_widening_moves_match_exhaustiveness_to_the_base_type` |
both directions of the exhaustiveness flip |
| `session_let_rebinding_across_submissions_is_unaffected` | widening
changes the binding, not the values |
| `session_let_annotations_are_still_rejected` | why widening is
unconditional |
| `session_let_narrowing_still_sees_the_literal` | `if (n is 5)` still
narrows |
| `method_calls_on_session_let_bindings_dispatch` | every road the fix
touches: primitive companion, container-as-`Call`,
container-as-`Rvalue::Len`, a session-declared class's own method, a
reflection handle's `fields()` |
| `method_calls_on_a_session_binding_work_in_its_own_submission` | the
defect never needed two submissions |
| `session_binding_field_access_and_indexing_still_work` | the controls
that always worked |
| `client_declaration_methods_dispatch` | `MyClient.id()` in ordinary
BAML |

## Verification

Focused: `runtime_session` 27/27. A/B against canary's lowering for the
client case, the
`join`/`keys`-vs-`length` split, and the pre-widening `E0007` for the
interface-dispatch
regression. Full pinned gate below.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed Session `let` and `client` bindings so literal values correctly
widen to their base types.
* Fixed method calls on bindings, including primitive, container,
user-defined class, reflection, and client values.
* Improved member access, indexing, rebinding, match exhaustiveness, and
evaluation behavior.
* Preserved constructed type identity through interface dispatch and
inherited default methods.
  * Improved failure handling for unsupported interface dispatch.
* **Tests**
* Added regression coverage for binding behavior, method dispatch, type
identity, and related Session operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Two small changes that cost nothing on your runners and unbreak CI for
anyone running this repo's workflows outside it.

**sccache log path.** `.envrc` wrote the sccache error log to a
hardcoded `/tmp`, which fails on any runner with an isolated or
read-only `/tmp`. It now respects `TMPDIR` and behaves exactly as before
everywhere else.

**benchmarks queue-wedge.** `benchmarks build (baml)` targets an ARM
runner label that only exists in this repository. On a fork the job
queues for 24 hours holding the whole run open, which also blocks
re-running any other job in that run. It is now gated on
`github.repository`.

Found while running this repo's full CI end to end on our own runner
fleet (context in the PRs that follow). Both fixes have been live on the
fork since 2026-08-16.


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

* **Chores**
* Updated development environment setup to support explicit Nix
configuration.
* Preserved temporary-directory handling for build cache error logs,
with `/tmp` used as a fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Sam Lijin <sam@boundaryml.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants