Skip to content

feat: upstream SDK codegen and runtime foundations - #4087

Merged
hellovai merged 25 commits into
canaryfrom
codex/upstream-sdk-codegen-foundations
Jul 23, 2026
Merged

feat: upstream SDK codegen and runtime foundations#4087
hellovai merged 25 commits into
canaryfrom
codex/upstream-sdk-codegen-foundations

Conversation

@hellovai

@hellovai hellovai commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What this PR actually changes

This PR changes the shared compiler/runtime/bridge contract and the generated/static SDK consumers that depend on it. Its central change is a node-local inbound type hint that complements the declared BAML context without turning the wire format into a recursively self-describing type tree:

message InboundValue {
  BamlTy value_type = 1;

  oneof value {
    string string_value = 2;
    int64 int_value = 3;
    double float_value = 4;
    bool bool_value = 5;
    InboundListValue list_value = 6;
    InboundMapValue map_value = 7;
    InboundClassValue class_value = 8;
    InboundEnumValue enum_value = 9;
    BamlHandle handle = 10;
    bytes uint8array_value = 11;
    string bigint_value = 12;
    BamlTy ty_value = 13;
  }
}

message InboundClassValue {
  reserved 1;
  repeated InboundMapEntry fields = 2;
}

Relative to canary, field 1 was reserved and class identity lived in InboundClassValue.class_ty. This PR:

  • uses field 1 as the optional-by-message-presence InboundValue.value_type annotation;
  • removes InboundClassValue.class_ty and moves class identity/type arguments into the same node-level annotation used by every other payload kind;
  • teaches the engine to combine sparse annotations, declared context, and payload shape recursively;
  • selects strict versus deterministic-default ambiguity behavior from the process-global registered SDK language;
  • rejects a root union or optional annotation because generated union/optional wrappers are projected to their selected BAML value before they cross the inbound boundary;
  • preserves exact literal descriptors instead of widening them during BamlTy decoding;
  • adds a canonical selected-arm index to outbound union envelopes;
  • lowers method-scope Self in SDK-facing argument and return types;
  • preserves declared union identity when BAML invokes a host callback, including empty-container arms and omitted optionals;
  • updates the C++, Go, Java, Python, Rust, Swift, legacy TypeScript, and TypeScript2 producers/bindings that share this protocol.

This is deliberately not a recursively self-describing inbound format. The protocol has two producer modes:

  • statically typed/generated producers recursively project generated union wrappers to their selected BAML arm and preserve that arm's exact node type wherever payload shape would lose it. Rust, C++, Java, and Swift now cover the empty string[] versus empty int[] case end to end; Go's checked-in protobuf/runtime surfaces are regenerated, while a full generated-Go union implementation remains separate;
  • dynamic producers such as Python and TypeScript normally send raw payloads without recursive type metadata. When recursive matching leaves multiple distinct arms, the engine uses a process-global policy selected from the bridge's already-registered SDK language.

value_type therefore never means “the enclosing union.” It is either the exact type of the selected current node or, for an erased generated class instance, a nominal class hint that can be refined from context.

The implementation snippets below are copied from the final code shape; comments such as /* decode the payload oneof */ and /* error */ elide only repetitive decoding/error construction, not a validation step.

Important base-to-head clarification

Earlier iterations of this branch considered InboundTypedValue, container-local item_type/key_type/value_type, and an outbound selected_type. None of those fields exist on the current canary base, so they are not removals in this PR's review diff. They are rejected design alternatives, not shipped base-to-head surface area.

Why payload shape is insufficient

Consider these signatures:

function nested(value: (int[] | string[])[]) -> string
function state(value: "draft" | string) -> string

The payloads below are not enough by themselves to recover a selected type:

nested([[]])     // the empty child can inhabit int[] and string[]
state("draft")   // the string can inhabit literal "draft" and broad string

The two calls are not equivalent from the bridge's perspective. A generated Go/Rust/C++ union wrapper already knows which arm it contains, so its recursive inbound encoder must preserve int[] versus string[] when it projects that wrapper to the selected value. A raw Python/TypeScript [] contains no such fact, so the registered dynamic-language policy chooses a deterministic default instead of pretending an element type was inferred from nonexistent entries.

The old inbound protocol had only one special type channel, InboundClassValue.class_ty, so it could preserve a class FQN and generic arguments but could not annotate an arbitrary list, scalar, media wrapper, or nested child. The old inbound matcher also chose the first matching union member using shallow predicates. For example, it treated every array as matching every list arm and treated a string payload as matching a string literal without checking the literal's value. That meant declaration order—not the contents of ["hello"]—could determine whether int[] | string[] selected int[] or string[].

The new contract separates three inputs:

  1. value_type, when a typed producer has exact selected-node information or a generated instance has nominal information that payload shape cannot carry;
  2. the declared/contextual type from the function signature, parent container, or class field;
  3. the payload itself, used to select a unique arm when it provides enough evidence;
  4. the registered SDK language, used only to choose a deterministic default when an unannotated dynamic payload remains ambiguous.

Inbound contract in detail

1. value_type belongs to the current node

value_type does not repeat an enclosing union. It describes the current node's selected type.

For a statically typed/generated producer, (int[] | string[])[] is encoded with the selected child type on both list nodes:

list_value {
  values {
    value_type: int[]
    list_value {}
  }
  values {
    value_type: string[]
    list_value {
      values { string_value: "hello" }
    }
  }
}

The non-empty second child deliberately carries value_type: string[] even though its element currently makes the arm discoverable. Typed producers do not ask Rust to reverse-engineer a container type from its entries: they serialize the selected node type that their generated host value already knows. This also makes the encoding stable if the list later becomes empty.

That design is now implemented by the typed producers in this diff. Rust Vec<T>/maps and C++ std::vector<T>/maps expose their exact BamlTy; generated union encoders project to the selected payload and attach the selected arm type without overwriting a more specific nested annotation. Java threads generated descriptors through an internal call carrier and annotates selected erased union arms. Swift's generic union family does the same through BamlTypeDescriptor.

Generated union wrappers are not themselves inbound BAML values. Their per-type encoder recursively projects them to the selected arm:

Class.ToInboundBamlValue()
  -> field.ToInboundBamlValue()
       -> IntOrString.ToInboundBamlValue()
            -> selected int or string value

List<IntOrString>.ToInboundBamlValue()
  -> each entry.ToInboundBamlValue()
       -> selected int or string value

This rule applies at the root, in class fields, in map values, and in list entries. No inbound union envelope is required. The declared/contextual union remains in the BAML function or parent type; the wire value carries the selected payload and, when needed, that payload's exact selected-node type.

A raw dynamic-language value may remain sparse:

// Python/TypeScript nested([[]])
list_value {
  values { list_value {} }
}

Because that empty child has no host-language element type, the registered dynamic-language policy chooses a deterministic default rather than inferring a list type from nonexistent entries.

At protobuf decode time, an annotation is represented inside the shared external value tree by a transient carrier:

pub fn typed(value: BexExternalValue, value_type: RuntimeTy) -> Self {
    let mut metadata = UnionMetadata::new(
        RuntimeTy::Union(vec![value_type.clone()], TyAttr::default()),
        value_type,
    );
    metadata.is_inbound_type_annotation = true;
    BexExternalValue::Union {
        value: Box::new(value),
        metadata,
    }
}

This reuses the existing external union carrier internally, but is_inbound_type_annotation distinguishes “this node has a sparse type annotation” from “this value is already materialized as a declared union.” The enclosing declared union still comes from context.

bridge_ctypes::inbound_to_external deliberately performs only syntax-level annotation validation and decoding:

let value_type = value
    .value_type
    .as_ref()
    .map(crate::ty_decode::proto_ty_to_runtime_ty)
    .transpose()?;

if matches!(value_type, Some(RuntimeTy::Union(..))) {
    return Err(CtypesError::InvalidInboundValueTypeRootUnion);
}

let decoded = /* decode the payload oneof */;
Ok(match value_type {
    Some(value_type) => BexExternalValue::typed(decoded, value_type),
    None => decoded,
})

Assignability and payload compatibility require the declared program type and class/alias definitions, so those checks happen later in the engine.

2. Root unions and optionals are rejected

A root union names a set of choices; it does not identify which choice the producer selected. Generated SDK union wrappers intentionally disappear during recursive inbound encoding, leaving the selected value:

IntOrString::String("hello")
  -> InboundValue { string_value: "hello" }

Encoding the wrapper type would add no selected-arm information:

Expected:   int | string
Payload:    int_value: 7
value_type: int | string   // rejected

An optional is represented as a union with null, so it is rejected for the same reason:

value_type: int?           // rejected
value_type: int            // valid non-null selection
value_type: null           // valid null selection

Nested unions remain valid beneath an exact outer type:

value_type: list<int | string>   // valid: the root type is list

The same projection rule applies recursively to union-valued class fields, map values, and list entries because each generated host type participates in the inbound encoding protocol. This is why the protocol does not need an InboundUnionValue: inbound reconstructs the declared union from context, while outbound needs a selected index so a generated SDK can reconstruct its host-language union wrapper.

Direct union/optional descriptors are rejected in bridge_ctypes. A named alias can only be recognized as union-shaped after the engine loads program-local aliases, so the engine also follows aliases defensively:

if let BexExternalValue::Union { metadata, .. } = &value
    && metadata.is_inbound_type_annotation
    && inbound_annotation_resolves_to_root_union(&metadata.selected_option, aliases)
{
    return Err(EngineError::TypeMismatch {
        message: "inbound value_type must identify one exact selected type, not a root union or optional"
            .to_string(),
    });
}

Coverage:

  • bridge_ctypes::value_decode::root_union_value_type_is_rejected
  • bridge_ctypes::value_decode::root_optional_value_type_is_rejected
  • bridge_ctypes::value_decode::nested_union_value_type_is_allowed
  • bex_engine::conversion::host_root_union_annotation_is_rejected_before_arm_matching
  • bex_engine::conversion::host_alias_to_root_union_annotation_is_rejected

3. An annotation is checked against both context and payload

For a declared union, the engine first identifies the contextual member named by the annotation, accepting exact structural identity or ordinary assignability. It then coerces the payload using the annotation as its recursive type, verifies that the payload inhabits the annotation, and finally coerces/validates against the selected contextual member.

The important checks in coerce_arg_to_declared_type_with_aliases are:

let selected_type = members
    .iter()
    .find(|member| selected_arm_equal(member, value_type))
    .or_else(|| {
        members.iter().find(|member| {
            runtime_ty_assignable_with_aliases(value_type, member, aliases)
        })
    })
    .cloned();

let annotation_coerced =
    coerce_arg_to_declared_type_with_aliases(*value, value_type, aliases, classes)?;

if !value_matches_type_with_definitions(
    &annotation_coerced,
    value_type,
    aliases,
    classes,
) {
    return Err(EngineError::TypeMismatch { /* payload does not inhabit annotation */ });
}

That produces these outcomes:

declared string | int, value_type bool, payload true
=> rejected: bool is outside the contextual union

declared string, value_type int, payload 7
=> rejected: int is not assignable to string

declared string, value_type literal("draft"), payload "draft"
=> accepted: the literal is assignable to string

declared string | int, value_type literal("draft"), payload "draft"
=> accepted and materialized as the contextual string arm

Coverage:

  • host_type_annotation_rejects_type_outside_contextual_union
  • host_type_annotation_rejects_wrong_non_union_type
  • host_typed_literal_is_assignable_to_non_union_primitive
  • host_typed_literal_selects_broader_contextual_union_arm
  • host_typed_overlapping_arm_uses_contextual_union

4. Unannotated selection is structural first, then language-policy-specific

The new matcher recursively collects the structurally matching union members instead of accepting the first member with the same outer payload tag. The common cases are deterministic:

declared int[] | string[], payload ["hello"]
=> only string[] matches recursively

declared "draft" | "published", payload "published"
=> only literal("published") matches by value

declared CardPayment | WirePayment, payload { iban: "..." }
=> only the class whose loaded field schema accepts `iban` matches

A bare null receives one additional canonicalization rule. If it matches both an explicit null arm and an alias whose body merely contains null (for example type OptionalState = ResponseState? used as OptionalState?), the explicit null arm wins. The payload already identifies the exact null leaf, so this is not erased host intent like an empty container. Coverage: unannotated_null_prefers_exact_null_over_nested_optional_alias, plus the generated Go type_shapes round trip for nil.

When more than one structurally distinct member remains, the process-global policy determines whether the engine rejects the missing type information or chooses a dynamic default:

match matching.as_slice() {
    [member] => Ok((*member).clone()),
    [] => members
        .iter()
        .find(|member| matches!(member, RuntimeTy::BuiltinUnknown { .. }))
        .cloned()
        .ok_or_else(|| EngineError::TypeMismatch { /* no matching arm */ }),
    _ if ambiguity_policy == crate::InboundUnionAmbiguityPolicy::SelectDefault => {
        matching
            .iter()
            .find(|member| {
                matches!(member, RuntimeTy::Literal(..) | RuntimeTy::EnumVariant(..))
            })
            .or_else(|| matching.first())
            .map(|member| (*member).clone())
            .ok_or_else(|| EngineError::TypeMismatch { /* impossible empty match */ })
    }
    _ => Err(EngineError::TypeMismatch {
        message: format!(
            "value of type `{}` matches multiple union members; \
             add an inbound `value_type` annotation to select one",
            value.type_name()
        ),
    }),
}

This preserves specificity for overlapping literal/primitive and enum-variant/enum arms regardless of declaration order. Otherwise, the dynamic default is the first declared structurally matching member. For example:

function foo(value: int[] | string[])
foo([])  # Selects int[] because it is the first recursively matching arm.

The policy is set during the bridge's existing one-language-per-process registration:

Node.js / Python                 -> deterministic dynamic default
Go / Rust / C# / C++ / Java / Swift -> require exact selected-node information
no registered bridge            -> strict default

The native mapping and registration are explicit:

impl BridgeLanguage {
    const fn inbound_union_ambiguity_policy(self) -> bex_project::InboundUnionAmbiguityPolicy {
        match self {
            Self::NodeJs | Self::Python => {
                bex_project::InboundUnionAmbiguityPolicy::SelectDefault
            }
            Self::Go | Self::Rust | Self::CSharp | Self::Cpp | Self::Java | Self::Swift => {
                bex_project::InboundUnionAmbiguityPolicy::Reject
            }
        }
    }
}

let registered = REGISTERED_BRIDGE.register(info)?;
bex_project::register_inbound_union_ambiguity_policy(
    registered.language.inbound_union_ambiguity_policy(),
)?;

bex_engine::inbound_config owns a single-assignment OnceLock<InboundUnionAmbiguityPolicy>. bridge_cffi::register_bridge maps its registered BridgeLanguage to that policy, and the browser TypeScript/WASM start hook registers SelectDefault. Repeating the same policy is idempotent; conflicting registration is rejected. Direct engine users that do not register a bridge get Reject, the safe default. This is process state rather than FunctionCallContext state because the native registry already forbids two different host bridges in one process.

Defaulting happens only after recursive payload matching and structural duplicate removal, so this does not restore the old shallow “first outer tag wins” behavior. A future explicit dynamic arm-selector API can override this convention without changing the current wire contract.

Coverage:

  • unannotated_literal_vs_primitive_requires_value_type
  • unannotated_ambiguous_empty_child_requires_value_type
  • dynamic_default_selects_first_matching_empty_container_arm
  • dynamic_default_prefers_exact_literal_over_broad_primitive
  • unannotated_structurally_duplicate_members_select_first_canonical_arm
  • bridge_language_selects_one_process_wide_inbound_policy

5. Recursive context replaces recursive type serialization

Once the effective type is known, the coercer propagates it into the payload:

(BexExternalValue::Array { items, .. }, RuntimeTy::List(expected_element, _)) => {
    Ok(BexExternalValue::Array {
        element_type: expected_element.as_ref().clone(),
        items: items
            .into_iter()
            .map(|item| {
                coerce_arg_to_declared_type_with_aliases(
                    item,
                    expected_element,
                    aliases,
                    classes,
                )
            })
            .collect::<Result<_, _>>()?,
    })
}

Maps do the same for each value. During VM materialization, class field templates are substituted with the instance's concrete generic arguments and each field is recursively coerced/materialized with that field type.

Consequences:

  • [ ["hello"] ] against (int[] | string[])[] selects string[] without annotations;
  • a generated typed string[][]/selected string[] arm serializes the empty child's known value_type: string[] instead of deriving it from entries;
  • raw Python/TypeScript [ [] ] against the same type selects the first matching child arm under SelectDefault; strict/unregistered typed contexts still reject the same unannotated ambiguity;
  • recursive aliases are expanded only as payload structure is consumed, so productive recursive aliases can use payload shape without an eager full-tree type pass;
  • an empty realized VM array/map retains its element/value descriptor for outbound arm selection.

Coverage:

  • sparse_nested_hint_selects_only_the_ambiguous_empty_child
  • unannotated_nested_container_uses_payload_shape
  • unannotated_ambiguous_empty_child_requires_value_type
  • unannotated_recursive_alias_uses_contextual_payload_shape
  • empty_array_uses_its_declared_element_type_to_select_union_arm
  • empty_map_uses_its_declared_value_type_to_select_union_arm

6. Literal descriptors remain literals

Before this PR, proto_ty_to_runtime_ty widened a BamlTyLiteral to its primitive ("draft" became string, 42 became int). That made a literal value_type useless.

The decoder now constructs RuntimeTy::Literal for string, int, bigint, float, and bool. Decimal bigint type literals are bounded by MAX_BIGINT_DECIMAL_DIGITS/MAX_BIGINT_BITS before parsing; over-limit errors report only the input length, avoiding log amplification by hostile descriptors.

Inbound literal matching checks the actual value. Float literals are parsed and compared by f64::to_bits, preserving -0.0 versus 0.0 and the parsed floating-point identity of precise decimal spellings.

Coverage:

  • ty_encode::roundtrip_preserves_every_literal_identity
  • ty_decode::oversized_bigint_literal_is_rejected_before_parse_without_echoing_input
  • value_decode::typed_literal_preserves_identity_beyond_payload_shape
  • literal_matching_checks_value_and_prefers_exact_literal
  • float_literal_matching_preserves_negative_zero_and_decimal_precision

7. Class annotations preserve nominal identity; plain objects use structural matching

Class value_type is not merely an ambiguity escape hatch. Generated class instances carry nominal identity even when the current signature has only one possible class:

value_type: class(user.payments.CardPayment)
class_value { fields: ... }

Plain JavaScript/TypeScript object literals remain unannotated map_value payloads. When such a map is considered for a class union member, the engine uses the loaded class schema instead of treating every map as every class:

fn map_matches_class_shape(
    entries: &indexmap::IndexMap<String, BexExternalValue>,
    type_name: &baml_type::TypeName,
    aliases: &indexmap::IndexMap<baml_type::TypeName, RuntimeTy>,
    classes: &indexmap::IndexMap<baml_type::TypeName, sys_types::ClassDefinition>,
) -> bool {
    let Some(definition) = find_inbound_class_definition(classes, type_name) else {
        return true;
    };

    if entries.keys().any(|key| {
        !definition.fields.iter().any(|field| {
            field.name == *key || field.alias.as_deref() == Some(key.as_str())
        })
    }) {
        return false;
    }

    definition.fields.iter().all(|field| {
        let value = entries
            .get(&field.name)
            .or_else(|| field.alias.as_ref().and_then(|alias| entries.get(alias)));
        match value {
            Some(value) => value_matches_type_with_definitions(
                value,
                &field.field_type,
                aliases,
                classes,
            ),
            None => field.skip
                || matches!(
                    &field.field_type,
                    RuntimeTy::Union(members, _)
                        if members.iter().any(RuntimeTy::is_null)
                ),
        }
    })
}

This checks unknown keys, field aliases, required versus skipped/optional fields, and recursively visible field payloads. If both class schemas accept the same object, a strict typed/unregistered context rejects the ambiguity, while a registered dynamic language chooses the first declared structurally matching class. A typed producer is still expected to preserve its nominal selection rather than depend on that default.

These are two separate base-to-head changes:

  1. generated instances move their nominal FQN and generic arguments from InboundClassValue.class_ty to InboundValue.value_type.class_ty;
  2. unannotated plain objects gain schema-aware structural class-arm matching.

The end-to-end example for the second behavior is the generated TypeScript test round_trip_complex_profile accepts plain object literals (no class constructors) in baml_language/sdk_tests/crates/typescript/type_shapes/customizable/roundtrip_complex_models.test.ts. Its nested object contains a CardPayment | WirePayment | null field:

payment: {
  brand: "visa",
  last4: "4242",
  billing_address: {
    line1: "1 Compiler Way",
    line2: null,
    city: "San Francisco",
    region: "CA",
    postal_code: "94107",
    location: { lat: 37.7749, lng: -122.4194 },
  },
}

Before schema-aware matching, both class arms appeared to be generic map matches and declaration order could win. After this change, the loaded field schemas make only CardPayment accept the card keys. This pre-existing high-level test exercises the new matcher; it does not prove class annotations, because the value is intentionally a plain object with no generated constructor tag.

8. Generic classes: exact arguments when available, nominal refinement when erased

The wire location changes base-to-head:

before: InboundClassValue.class_ty = Box<int>
after:  InboundValue.value_type.class_ty = Box<int>
        InboundValue.class_value = { fields: ... }

Python Pydantic and TypeScript may know that an object is a Box while not retaining a concrete runtime T. Removing InboundClassValue.class_ty without nominal refinement would discard the only class discriminator.

The producers therefore use these encodings:

Box<int> with known args:
  value_type: class(Box, [int])

erased/unparameterized Box:
  value_type: class(Box, [])

The empty-argument form is the one deliberate exception to “value_type is exact”: it is a nominal class hint. The engine may refine it against one contextual instantiation:

annotation Box + declared Box<int>
=> refine to Box<int>

It cannot select between multiple concrete instantiations:

annotation Box + declared (Box<int> | Box<string>)
=> rejected; concrete generic arguments are required

Known arguments must remain compatible with the contextual arguments and arity. A bare map or shape-only instance can still become Box<int> when the declared slot already supplies Box<int>; this avoids requiring recursive metadata where context is sufficient.

Concrete behavior:

function takeIntBox(value: Box<int>)
host sends nominal Box with fields { value: 7 } but no runtime T
before this refinement: removing class_ty would lose Box identity
after: value_type class(Box, []) is refined from the one contextual Box<int>

function takeEither(value: Box<int> | Box<string>)
same erased nominal Box payload
after: rejected, because context has two concrete instantiations and the host did not retain T

The sparse annotation also participates in generic inference. synth_ty_from_value treats it as stronger evidence than payload shape, and formal-aware generic reconstruction peels the transient annotation carrier before inspecting an erased generic instance's fields.

Coverage:

  • nominal_generic_class_annotation_refines_from_context
  • nominal_generic_class_annotation_cannot_choose_concrete_union_arm
  • generics_explicit::inbound_instance_arg_missing_type_args_uses_context
  • generics_explicit::inbound_bare_map_against_generic_class_uses_context
  • existing wrong-type-argument and wrong-arity tests in generics_explicit.rs
  • Python test_generic_instance_carries_sparse_value_type
  • Python test_unbound_generic_instance_carries_nominal_sparse_value_type
  • TypeScript unbound generic instance carries only nominal class identity
  • TypeScript non-generic class instance carries nominal identity

9. Media uses an exact primitive annotation, not the Python wrapper class

Python media objects are class-shaped implementation shells containing _data, but their BAML type is the primitive media kind. The Python encoder now emits:

inbound_value.value_type.media.kind = _MEDIA_WIRE_KINDS[type(value)]

data_entry = inbound_value.class_value.fields.add()
data_entry.string_key = "_data"
_set_inbound_value(
    data_entry.value,
    value._to_pyhandle(),
    kwarg_name=kwarg_name,
    registered=registered,
)

The important base-to-head distinction is:

Python object representation: class-shaped shell { _data: <host handle> }
BAML node identity:          value_type: pdf   // or image/audio/video
contextual union:            pdf | image       // supplied by the signature

Previously the only special inbound type channel was InboundClassValue.class_ty, which cannot correctly describe a primitive pdf. Treating the shell as an ordinary class/map also loses which media arm the wrapper represents. The node-level primitive annotation lets the engine unwrap _data while retaining the actual BAML media kind.

The engine recognizes the class shell in a media context, requires exactly _data, and recursively materializes that handle as the annotated/contextual media type.

Inbound union matching is media-kind-specific: image matches image and generic media, but not audio. This exact-kind claim applies to the engine inbound matcher; the existing schema-free host-return validator still matches media by carrier rather than by kind.

The Rust media_pdf_roundtrips_through_engine integration test verifies the _data class-shell-to-canonical-media conversion under contextual pdf. The generated Python media round-trip suite exercises the actual annotated wrapper decode/re-encode path. The Rust integration test itself does not construct a sparse BexExternalValue::typed carrier, so it proves contextual shell conversion rather than Python's annotation emission.

10. Reflected type values remain payloads

value_type and ty_value are independent:

  • value_type describes the current inbound node;
  • ty_value is the payload when a BAML type value is passed as data.

This is regression scope, not a new reflected-type feature: ty_value predates this PR. The failure being guarded against is the new transient annotation/union carrier accidentally consuming a reflected type payload as metadata while coercing a union arm.

declared: type | string
payload:  ty_value(int)
expected selected arm: type, with reflected payload `int` intact

host_selected_metatype_arm_survives_argument_coercion covers that distinction.

Outbound unions: one emitted arm discriminator

The outbound union envelope is now:

message BamlValueUnionVariant {
  string name = 1;
  bool is_optional = 2;
  bool is_single_pattern = 3;
  BamlTy self_type = 4;
  string value_option_name = 5; // display only
  BamlOutboundValue value = 6;
  reserved 7;
  optional uint32 selected_option_index = 8;
}

The selected type is derived from the union plus the index:

selected_type = self_type.options[selected_option_index]

The optional scalar is important: presence distinguishes “the first arm, index 0” from “no discriminator was encoded.” value_option_name remains human-readable metadata and is not authoritative.

The encoder validates that UnionMetadata.selected_option is a member of union_type and emits the first structurally matching index:

let RuntimeTy::Union(members, _) = union_type else { /* error */ };
let Some(index) = members
    .iter()
    .position(|member| selected_arm_equal(member, selected_option))
else { /* selected type is not a member */ };

runtime_ty_structurally_equal ignores source-only attributes, treats union member order as semantically irrelevant, and preserves duplicate multiplicity when comparing whole union types. selected_arm_equal additionally tolerates the legacy root representation where a non-null arm is wrapped in T | null.

Duplicate-arm trade-off

The index preserves the position of distinguishable arms, including ambiguous payload shapes such as int[] versus string[]. It does not preserve which occurrence was selected among structurally identical duplicate members. Because runtime union metadata stores the selected type rather than an occurrence index, int | int | string canonicalizes an int selection to the first int index.

Current consumers

This PR emits selected_option_index and makes it authoritative in the static C++, Rust, Java, Swift, and C# union decoders. They first resolve the index through the raw self_type.options array and then match that selected descriptor to the generated host arm. They do not apply the wire index directly to a compact host union: self_type can contain duplicate options, optional shells, or null holes that are absent from the generated host wrapper. Legacy/bare envelopes with no index retain structural fallback. Go's dynamic bridge value now exposes the optional index alongside the display-only variant name; Python and legacy TypeScript continue to expose their dynamic value shapes.

The outbound encoder verifies selected-type membership. It does not independently revalidate that an arbitrarily forged nested payload inhabits the selected arm; normal engine materialization is expected to establish that invariant before encoding.

Coverage:

  • outbound_union_encodes_selected_index_for_ambiguous_numeric_arms
  • artifact_safe_union_encodes_selected_index
  • outbound_optional_null_preserves_declared_member_index
  • outbound_union_rejects_selected_type_absent_from_declared_union
  • outbound_union_matches_structurally_equivalent_selected_type
  • union_structural_equality_ignores_member_order
  • union_structural_equality_preserves_duplicate_multiplicity
  • unannotated_structurally_duplicate_members_select_first_canonical_arm

Boundary matching changes, by layer

This PR does not introduce one universal matcher. There are two relevant layers with different jobs:

  1. the engine's inbound/contextual matcher, which can use program aliases and class schemas and must select one union arm;
  2. the existing schema-free host-return validator in bex_external_types, shared by native and WASM callback return paths, with an existing engine schema-aware second pass.

The exact changes are:

Behavior Inbound/contextual engine matcher Existing host-return validator
Literal string/int/bool/bigint Exact value Exact value (pre-existing)
Literal float Exact parsed f64 bits in this PR Still carrier/tag-only
Enum variant Exact enum + exact variant in this PR Exact enum + exact variant in this PR
Media Exact kind; generic media accepts all kinds Still carrier-only, not kind-specific
Lists/maps Recursive; contextual types propagated Recursive payload validation (pre-existing)
Plain map to class Allowed inbound and checked against loaded schema Rejected as a class return (pre-existing semantics)
Function Callable host handle or function ref Callable host handle or function ref
baml.json.json Shared recursive JSON predicate Same shared recursive JSON predicate
void callback return null is the canonical top-level representation null now accepted for top-level void

Canonical JSON alias

baml.json.json now uses one recursive predicate in both inbound matching and host-return validation:

pub fn value_satisfies_json(value: &BexExternalValue) -> bool {
    fn recurse(value: &BexExternalValue, depth: usize) -> bool {
        if depth > 256 {
            return false;
        }
        match value {
            BexExternalValue::Null
            | BexExternalValue::Int(_)
            | BexExternalValue::Bool(_)
            | BexExternalValue::String(_) => true,
            BexExternalValue::Float(value) => value.is_finite(),
            BexExternalValue::Array { items, .. } => {
                items.iter().all(|item| recurse(item, depth + 1))
            }
            BexExternalValue::Map { entries, .. } => {
                entries.values().all(|item| recurse(item, depth + 1))
            }
            BexExternalValue::Union { .. } => false,
            _ => false,
        }
    }
    recurse(value, 0)
}

This admits the JSON algebra and rejects non-finite floats, bigint, bytes, classes, enums, media, handles, and forged union metadata.

Coverage:

  • host_return::canonical_json_alias_accepts_only_the_json_value_algebra
  • conversion::canonical_json_alias_matches_values_and_selected_union_arms

Host-return validation is strengthened, not introduced

validate_host_return, native/WASM use of it, class-name checks, recursive list/map checks, class-field schema validation, wrong-return HostContractViolation, declared throw enforcement, BAML catch routing, and the in-flight dispatch guard all predate this base-to-head diff.

This PR specifically adds or changes:

  • exact RuntimeTy::EnumVariant validation in both the schema-free and engine-side validators;
  • Null as the canonical completed value for a top-level void callback;
  • allowance for top-level void callback binding while nested/unresolved void remains rejected;
  • canonical JSON alias validation described above.

It is inaccurate to present the existing wrong-return, throw/catch, or cancellation-table test suites as newly implemented by this PR.

Major host-callable change: preserve callback argument types on the BAML-to-host path

There are real host-callable changes in this PR. The primary regression addressed is on the BAML-to-host argument path—not the already-existing host-return/catch system.

function round_trip(
  f: (int[] | string[]) -> int[] | string[],
  value: int[] | string[],
) -> int[] | string[] {
  f(value)
}

Before this change, ordinary sysop conversion was used for the callback's argument pack. That conversion is intentionally type-erased and could not preserve the selected arm of an empty int[] versus string[].

The engine now reads the realized parameter contract from the Object::HostClosure, validates the [required_args, optional_args] pack, and converts each supplied value with its declared parameter type:

let (required_types, optional_types) = host_call_parameter_types(
    params,
    positional_values.len(),
    optional_values.keys().map(BexStr::as_str),
)?;

let positional = positional_values
    .into_iter()
    .zip(required_types)
    .map(|(value, ty)| {
        self.convert_vm_value_to_external_with_type(value, &ty, permit)
    })
    .collect::<Result<Vec<_>, _>>()?;

for (name, value) in optional_values {
    let name = name.to_string();
    let ty = &optional_types[&name];
    optional.insert(
        name,
        self.convert_vm_value_to_external_with_type(value, ty, permit)?,
    );
}

The resulting callback wire value contains the declared union plus the selected index even when the nested payload is empty:

union_variant_value {
  self_type: int[] | string[]
  selected_option_index: 0
  value { list_value {} }
}

Optional parameters preserve omission: only entries actually present in the optional-argument map are serialized. A call to f() sends no value, while f(value = "supplied") sends the selected string arm.

New end-to-end tests in bex_engine/tests/host_value_callable.rs:

  • host_callable_arguments_preserve_closed_union_selected_arm_on_wire
  • host_callable_union_envelope_preserves_empty_container_arm_identity
  • host_callable_optional_union_is_omitted_or_sent_with_selected_arm

Supporting unit tests:

  • host_call_parameter_type_tests::resolves_required_and_exact_optional_wire_names
  • host_call_parameter_type_tests::rejects_malformed_required_arity_and_optional_name

Adjacent lifecycle changes

This PR moves the already-cancelled check before host-call argument traversal, avoiding a potentially large recursive conversion for an operation that will never dispatch. The cancellation behavior itself and in-flight eviction guard predate this diff; there is no new dedicated test that measures the O(1) pre-check.

At the native C-FFI completion boundary, a new drop guard drains queued host releases on every return. This covers a late throw/panic arriving after cancellation removed its call ID: decoding creates an opaque error handle, the unknown-ID path drops it, and the boundary now synchronously releases the host key. An accepted throw retains the key until the delivered payload is dropped.

Coverage: bridge_cffi::host_value::late_unknown_throw_releases_opaque_handle_but_accepted_throw_retains_it.

SDK-facing Self lowering

SDK generators should not need to reconstruct the class that owns a method's Self. baml_project::build_symbol_pool now creates the owning TIR class type once:

let class_self_ty = TirTy::Class(
    QualifiedTypeName::new(pkg.clone(), ns_path.clone(), class.name.clone()),
    class_generic_params
        .iter()
        .cloned()
        .map(|name| TirTy::TypeVar(name, TyAttr::default()))
        .collect(),
    TyAttr::default(),
);

It passes that type as self_ty while lowering class-method parameters and return types. The normal compiler type-expression lowering performs recursive substitution, so nested forms work without generator-specific string rewriting:

class Mirror {
  value string
  function clone(self, other: Self?) -> map<string, Self> { {"value": self} }
  function pick(self, value: Self | int) -> Self | string { self }
  function wrap(value: Self[]) -> Self { value[0] }
}

class GenericMirror<T> {
  value T
  function nested(self, other: Self?) -> map<string, Self[]> { {"value": [self]} }
  function identity(value: Self) -> Self { value }
}

SDK-facing results include:

Self?                         -> Mirror | null
map<string, Self>             -> map<string, Mirror>
Self | int                    -> Mirror | int
Self[]                        -> Mirror[]
GenericMirror<T>::Self        -> GenericMirror<T>
map<string, Self[]>           -> map<string, GenericMirror<T>[]>

This covers instance and static method parameters and return types. The receiver was already omitted from public instance-method arguments before this PR. This diff does not thread self_ty through resolve_throws, so it should not claim coverage for Self in a method's throws type.

Coverage: baml_project::client_codegen::test_class_method_self_lowers_to_owning_codegen_class.

Static bridge completion: exact empty-container arms

The cross-SDK regression fixture is intentionally payload-ambiguous:

function round_trip_str_or_int_list(x: string[] | int[]) -> string[] | int[] {
  x
}

Both selected values below have the same payload bytes:

list_value {}

Without a node annotation, a strict bridge cannot tell whether the caller selected string[] or int[], and an outbound decoder cannot reconstruct the correct generated union wrapper from the empty payload. The static encoders now preserve the selected concrete arm while still dropping the host union envelope itself.

Rust generates this shape for each union arm:

Self::IntList(value) => annotate_selected_type(
    __BamlValuePrivate::to_baml(value),
    <Vec<i64> as __BamlValuePrivate>::baml_ty(),
)

Vec<T>, IndexMap<K, V>, and HashMap<K, V> also expose/attach their exact container type. annotate_selected_type never writes a root union/optional and never overwrites an annotation already emitted by a nested selected union.

C++ follows the same rule in the generic codec:

std::visit([&value_msg](const auto& alt) {
  using T = std::decay_t<decltype(alt)>;
  codec<T>::encode(value_msg, alt);
  detail::annotate_selected_type(value_msg, codec<T>::baml_ty());
}, value);

The C++ codecs now provide baml_ty() for primitives, literals, lists, maps, optionals, boxes, variants, generated enums, generated classes, and aliases. This keeps recursive type construction cheap and local to the host type rather than running a separate value-tree inference pass.

Java has erasure, so generated call sites pair ambiguous arguments with their already-pooled BAML descriptor:

new baml_bridge.BamlTypedValue(value, $typeDescriptor)

BamlTypedValue is only an internal generated-call carrier; it adds no protobuf field and does not touch FunctionCallContext. ProtoWriter uses the descriptor to unwrap Union2.Arm0/Arm1 or a generated nominal union record, select the contextual arm by declaration index, and annotate the selected payload. Context is then threaded through list elements, map values, and class fields. Ordinary Java lists/maps are not blindly marked exact when the runtime descriptor has erased nullable/generic details; exact annotations are emitted for selected arms and nominal instances where the descriptor is trustworthy. Media identity is encoded as the exact BamlTy.media on value_type, while the class-shaped payload remains only the _data handle transport.

Swift's BamlUnion2 through BamlUnion8 use the same selected-node projection through BamlTypeDescriptor.

The outbound side deliberately resolves the index in two steps:

raw_selected_type = self_type.options[selected_option_index]
generated_arm = first host arm whose exact type matches raw_selected_type

An optional shell around a selected non-null value is transparent during host matching. This matters because an engine union such as null | string[] | int[] can report index 2 even though the generated host union has only two non-null arms. Applying 2 directly to the host union would be out of range; resolving self_type.options[2] == int[] and then matching int[] to the host arm is correct.

Regression coverage now asserts both non-empty and empty arms in Rust, C++, Java, and Swift. The empty int[] case specifically failed before this completion because it canonicalized/decoded as the first string[] arm.

Producer behavior by SDK

The intended generated-SDK rule is compositional: every generated host type converts itself to an inbound BAML value, and a generated union type delegates to its selected arm. That delegation occurs recursively in class fields, map values, and list entries. It is by design that inbound drops the host-language union wrapper; outbound retains a selected index because the receiving SDK must reconstruct one.

For typed hosts, projection must not discard the selected arm's concrete container/literal/class type when the remaining payload is ambiguous. For dynamic hosts, raw values stay sparse and the engine uses the registered language policy rather than requiring recursive host-side type inference.

Producer Base-to-head behavior
Legacy TypeScript Plain objects/arrays remain unannotated. Typemap-recognized generated class instances emit valueType.classTy; exact generic args are included only when every $types binding is present, otherwise only nominal class identity is sent. Typed host throws and built-in host-callable error classes use the same field.
Python Pydantic instances emit value_type.class_ty, including Pydantic generic arguments when available. Unparameterized generics emit the nominal class with zero args. Media wrappers emit exact primitive media kinds and keep _data in the class-shaped payload shell.
Rust SDK Generated classes move nominal identity to InboundValue.value_type.class_ty. Generated unions annotate the projected selected arm; container codecs provide exact recursive BamlTy; outbound union decoding resolves the canonical selected descriptor through self_type before choosing the generated enum variant. Current Rust sdkgen still skips generic classes, so generated class annotations have zero type args.
C++ SDK Generated classes, callback error values, and outbound-to-inbound class transcoding move nominal identity/type args to InboundValue.value_type. Generic codecs expose exact baml_ty() descriptors, selected union arms are annotated inbound, and indexed outbound envelopes select by descriptor rather than payload guessing.
Java SDK Generated calls use an internal BamlTypedValue carrier for erased descriptors; selected union arms, class identity/generic arguments, fields, and media encode through node-local value_type. Generated union records consume outbound selected descriptors before structural fallback.
C# SDK Generated lists/maps/classes/media emit exact node-local ValueType; generated unions project to their selected payload and reject union/optional shells as inbound annotations. Outbound union decoding resolves SelectedOptionIndex through raw SelfType.Union.Options before choosing a compact generated arm. C# registers as a strict static producer.
Go SDK surfaces Canonical protobufs and the duplicate baml_go copies are regenerated. baml_go.Class uses InboundValue.value_type.class_ty, and DynamicUnion exposes SelectedOptionIndex as canonical identity while retaining the display-only variant name.
Swift SDK Static BamlEncodable types expose a cheap BamlTypeDescriptor. BamlUnion2 through BamlUnion8 keep the inbound payload bare but annotate it with the selected arm's exact type, generated classes move FQN/type arguments to node-local value_type, and generated union decoders resolve outbound selected_option_index through the raw self_type before matching the compact host union arm.
TypeScript2 BamlSerializable.toBaml() can return any InboundValue, including an exact literal annotation; generated class serialization moves nominal identity to the node-level field. Ordinary scalar/list/map values explicitly leave valueType undefined.

Swift's static union projection is implemented directly on the generic union family:

case .t0(let value):
    return value._bamlEncode()._bamlAnnotatingSelectedType(T0._bamlType)
case .t1(let value):
    return value._bamlEncode()._bamlAnnotatingSelectedType(T1._bamlType)

That makes the two empty payloads below different on the wire without serializing a recursive type tree for every value:

let strings: BamlUnion2<[String], [Int]> = .t0([]) // value_type: string[]
let ints: BamlUnion2<[String], [Int]> = .t1([])    // value_type: int[]

Generated Swift classes use the same node-local channel for nominal identity and concrete generic arguments:

public static var _bamlType: BamlTypeDescriptor? {
    .classType("user.Box", typeArguments: [T._bamlType])
}

public func _bamlEncode() -> BamlInboundValue {
    .baml_class("user.Box", typeArguments: [T._bamlType], [("value", value)])
}

On the return path, Swift now consumes the canonical index before legacy metadata or structural fallback:

if let selectedType = try value.unionSelectedType() {
    if T0._bamlDecodeType == selectedType {
        return .t0(try T0._bamlDecode(value))
    }
    if T1._bamlDecodeType == selectedType {
        return .t1(try T1._bamlDecode(value))
    }
    throw BamlDecodeError.typeMismatch(/* selected type is not a host arm */)
}

The end-to-end type_shapes SDK test calls the generated round_trip_str_or_int_list client with both empty arms and asserts that the returned BamlUnion2 preserves .t0 versus .t1. This fails if inbound selected-node metadata is dropped or if outbound decoding guesses from the identical empty-list payload shape.

C# integration added by the final canary merge

C# is a static producer (BridgeLanguage::CSharp = 5), so it uses the same strict policy as Go/Rust/C++/Java/Swift. Its encoder moves type information to the current node instead of the container payload:

return new InboundValue {
    ValueType = new BamlTy { ClassTy = classType },
    ClassValue = @class,
};

Typed empty lists/maps similarly set ValueType.List / ValueType.Map; media sets the exact ValueType.Media kind while retaining the class-shaped _data transport shell. A generated C# union still drops unionness recursively, but adds the selected concrete descriptor to the projected payload when that payload has no more-specific annotation. A selected root union or optional is rejected.

Outbound C# treats the index as canonical and the option name as display-only:

BamlTy selectedType = union.SelfType.Union.Options[(int)selectedIndex].Clone();
while (selectedType.TyCase == BamlTy.TyOneofCase.Optional)
    selectedType = selectedType.Optional.Inner.Clone();

This fixes null | string[] | int[] selecting raw index 2: the decoder resolves int[] first, then maps it to compact generated C# arm 1, rather than applying raw index 2 directly.

The native phase-11 callback fixture exposed a shared-boundary regression for CallbackBox<int>: the wire had an exact node annotation plus an intentionally anonymous class payload. The schema-free host-return guard used to reject that payload before the engine could apply its annotation. It now validates the annotated type against the declared return type; the engine then coerces the payload with that exact type before recursive schema validation. A focused test accepts matching CallbackBox<int> and rejects an annotation for a different class.

Go's checked-in protobuf bindings and affected runtime surfaces are included here: class construction uses node-local value_type, and dynamic outbound unions retain selected_option_index. A future full generated-Go union implementation should follow the same existing protocol shape—each generated type converts recursively and each union projects to its selected value—without introducing an inbound union envelope.

Raw legacy TypeScript strings and Python strings are not automatically inferred as literal annotations. The explicit literal producer example in this diff is TypeScript2's toBaml() test:

const draft = {
  toBaml() {
    return {
      valueType: {
        ty: {
          $case: "literal",
          literal: {
            literal: { $case: "stringValue", stringValue: "draft" },
          },
        },
      },
      value: { $case: "stringValue", stringValue: "draft" },
    };
  },
};

value_type is a protocol capability; this PR does not add a new high-level “select this literal arm” wrapper to every legacy SDK.

Generated artifacts and scope

Updated checked-in protocol artifacts:

  • C++ protobuf .pb.h/.pb.cc;
  • Python protobuf .py/.pyi;
  • Rust SDK vendored prost wire types;
  • Swift protobuf .pb.swift sources and generation hashes;
  • legacy TypeScript protobuf JS/declarations/source and built dist files;
  • TypeScript2 encode/test source against the regenerated shape.

Other shared code updates include the C++ generator codec, C++ host-value transcoder, Python/Rust/C# bridge producers, C# host-callable and media protocols, Python Rust host-callable builder, C-FFI host completion path, process-global inbound ambiguity policy registration, the WASM dynamic-policy start hook, and WASM test fixtures that construct InboundValue directly.

Canonical Go protobuf files and the duplicate baml_go/internal/cffi inbound/outbound copies are regenerated from the final schema. The vendored CFFI header is synchronized as well. This removes the prior generated-proto sync failure and lets the focused baml_go runtime suite exercise the new class layout. The broader generated-Go union API remains outside this PR.

Proto backward compatibility is not a design constraint for this work, but tag 7 in the outbound union message remains reserved in the resulting schema.

Review map

Recommended review order:

  1. Wire contract
    • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_inbound.proto
    • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_outbound.proto
    • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_type.proto
  2. Wire decoding/encoding
    • baml_language/crates/bridge_ctypes/src/ty_decode.rs
    • baml_language/crates/bridge_ctypes/src/ty_encode.rs
    • baml_language/crates/bridge_ctypes/src/value_decode.rs
    • baml_language/crates/bridge_ctypes/src/value_encode.rs
    • baml_language/crates/bridge_ctypes/src/error.rs
  3. Shared external identity/validation
    • baml_language/crates/bex_external_types/src/bex_external_value.rs
    • baml_language/crates/bex_external_types/src/runtime_ty_identity.rs
    • baml_language/crates/bex_external_types/src/host_return.rs
  4. Engine coercion/materialization
    • baml_language/crates/bex_engine/src/conversion.rs
    • baml_language/crates/bex_engine/src/inbound_config.rs
    • baml_language/crates/bex_engine/src/lib.rs
    • baml_language/crates/bex_engine/tests/generics_explicit.rs
    • baml_language/crates/bex_engine/tests/host_value_callable.rs
    • baml_language/crates/bex_engine/tests/media_roundtrip.rs
  5. Compiler SDK IR
    • baml_language/crates/baml_project/src/client_codegen.rs
  6. Native completion/lifecycle
    • baml_language/crates/bridge_cffi/src/ffi/runtime.rs
    • baml_language/crates/bridge_cffi/src/ffi/host_value.rs
    • baml_language/crates/sys_native/src/host_dispatch.rs (documentation clarification only)
    • baml_language/crates/sys_wasm/src/host_value.rs and baml_language/crates/bridge_wasm/tests/host_callable.rs (wire fixture updates)
  7. SDK producers and generated bindings
    • baml_language/sdks/typescript/bridge_typescript/typescript_src/proto.ts
    • baml_language/sdks/typescript/bridge_typescript/tests/test_typemap.test.ts
    • baml_language/sdks/typescript/bridge_typescript/tests/call_function.test.ts
    • baml_language/sdks/python/src/baml_bridge/proto.py
    • baml_language/sdks/python/tests/test_proto_generics.py
    • baml_language/sdks/python/tests/test_engine.py
    • baml_language/sdks/rust/bridge_rust/src/{encode.rs,baml_value.rs}
    • baml_language/sdks/cpp/bridge_cpp/include/baml/detail/host_value.h
    • baml_language/sdks/cpp/sdkgen_cpp/src/lib.rs
    • baml_language/sdks/swift/Sources/BamlBridge/{Encode.swift,Decode.swift,BamlUnions.swift}
    • baml_language/sdks/swift/rust/sdkgen_swift/src/emit.rs
    • baml_language/sdks/swift/Tests/BamlBridgeTests/FFISmokeTests.swift
    • baml_language/sdk_tests/crates/swift/type_shapes/customizable/roundtrip_tests/TestUnions.swift
    • baml_language/sdks/csharp/bridge_csharp/src/Proto/{PrimitiveProtocol.cs,MediaProtocol.cs,HostCallableProtocol.cs}
    • baml_language/sdks/csharp/bridge_csharp/tests/{Baml.Bridge.Tests,Baml.Bridge.HostCallable.Tests}/Program.cs
    • typescript2/pkg-proto/src/{encode.ts,test/encode-decode.test.ts}
    • generated protobuf files beside those sources

Validation performed

The merged head was validated locally across the new C# surface and the shared bridge/runtime code:

Command/suite Result
C# bridge build + managed foundation, host-callable, and protocol probes passed on .NET SDK 10.0.301 with 0 warnings/errors
cargo test -p sdkgen_csharp --lib 51/51 passed
cargo nextest run -p sdk_test_csharp --no-fail-fast 15/15 passed, including callbacks, media, generics, unions/collections, nullable values, streams, resources, failures, and structural/dynamic values
broad workspace nextest with documented exclusions 4585/4585 passed; 40 skipped across 142 binaries
full manual precommit passed, including grammar/Markdown checks, fmt, C++ formatting, TOML/YAML, native + WASM clippy, generated-file checks, and cargo hawk

Static SDK coverage run during this PR also passed:

  • Rust/C++ generated type_shapes: 5/5, including both empty selected-list arms;
  • Java sdkgen: 92/92, bridge Gradle test jar, and generated type_shapes;
  • Swift bridge: 5 tests, generated end-to-end type_shapes, sdkgen tests, and clippy;
  • Go runtime: env -u GOROOT go test ./... in sdks/go/baml_go.

Shared engine and dynamic-language coverage included bex_engine, bridge_cffi, explicit generics, host callables, WASM compilation/clippy, Python engine calls, and the TypeScript bridge suite. Python and TypeScript compile the same discriminator and call it through their public SDK boundary with an ordinary untyped empty list:

function ClassifyAmbiguousEmptyList(value: int[] | string[]) -> string {
  match (value) {
    let ints: int[] => "ints",
    let strings: string[] => "strings",
  }
}
result = call_function_sync(runtime, "ClassifyAmbiguousEmptyList", {"value": []})
assert result.result() == "ints"
const result = callFunctionSync(runtime, 'ClassifyAmbiguousEmptyList', { value: [] })
expect(result.result()).toBe('ints')

These are end-to-end assertions: the untyped [] crosses the public encoder, registered dynamic bridge, wire decoder, engine coercion, and BAML match, and selects the first declared structural arm. Before the dynamic policy, the Python case failed with value of type array matches multiple union members; add an inbound value_type annotation to select one.

Focused coverage proves:

  • direct and aliased root-union/optional annotation rejection, while nested unions remain legal;
  • sparse literal, exact typed empty-container, enum-variant, media, callable, reflected-type, and JSON matching;
  • annotation/context/payload mismatch errors and recursive list/map/alias coercion;
  • strict static ambiguity versus dynamic first-match/literal-specific defaulting;
  • generic nominal refinement and concrete generic mismatch rejection;
  • outbound selected-index emission, optional/null raw-index mapping, structural matching, and invalid index/type rejection;
  • typed host-callback argument envelopes, sparse annotated host returns, optional omission, and late native release behavior;
  • SDK-facing Self substitution and process-global native/WASM policy registration.

GitHub Actions for merged head 341a54237 passed every triggered test, precommit, protobuf-sync, MSRV, size, and platform job, including the C# SDK matrix on Linux, macOS, and Windows.

Policy boundary and remaining work

This PR distinguishes static and dynamic ambiguity policy and completes selected-node propagation for the Rust, C++, Java, Swift, and C# static union surfaces covered by the shared regression fixtures:

  1. Static selected-node propagation is explicit. Generated union wrappers project to their selected payload and preserve the exact selected type recursively; empty typed containers are not inferred from their entries. The full generated-Go union surface is the remaining static SDK follow-up.
  2. Dynamic ambiguity uses an explicit defaulting convention. Python/TypeScript users have no normal high-level way to type an empty [] or distinguish every raw value's host-side union wrapper. Registered dynamic bridges therefore default after recursive matching: prefer an exact literal/enum-variant candidate, otherwise choose the first declared structural match.
  3. The policy is process-global and language-aware. Native bridge registration already records exactly one BridgeLanguage per process. Node.js/Python select dynamic defaulting; Go/Rust/C#/C++/Java/Swift select strict rejection; direct engine users with no registered bridge remain strict. WASM/TypeScript registers the dynamic policy at startup.
  4. A future explicit dynamic selector remains possible. The default is a deterministic convention, not inference from an empty payload. A future Python/TypeScript arm-selector API can provide intent explicitly where callers need a non-default ambiguous arm.

None of this requires an inbound union envelope. Union projection is intentional and recursive: static wrappers preserve the selected payload node's exact type, while genuinely untyped dynamic values use the registered defaulting policy described above.

Deliberately out of scope

  • Completing the generated-Go static union API beyond the regenerated protobufs and runtime surfaces included here.
  • A high-level literal/union-arm selector API in every host SDK.
  • Requiring every inbound node to serialize a complete recursive type tree.
  • Preserving occurrence identity among structurally identical duplicate union members; they canonicalize to the first match.
  • Redesigning dynamic Python/TypeScript public values to expose generated-style union wrappers.
  • Replacing all boundary matchers with one uniform algorithm; inbound contextual selection and host-return validation retain different responsibilities.
  • Self substitution in method throws types.
  • General outbound cycle detection; the existing typed VM-to-external walk still documents that cyclic values can recurse indefinitely.

Summary by CodeRabbit

  • New Features

    • Improved support for Self in class method types, including nested and generic usages.
    • Added precise type metadata for values exchanged through language bridges.
    • Union values now preserve their selected option reliably across host-language integrations.
  • Bug Fixes

    • Improved generic class coercion when contextual type information is available.
    • Strengthened validation for JSON, enum variants, literals, and host-call arguments.
    • Improved cancellation handling and timely release of host resources.
    • Added clearer errors for invalid bigint literals and union selections.
  • Documentation

    • Updated bridge regeneration and host callback integration guidance.

@cursor

cursor Bot commented Jul 18, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 18, 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, Comment Jul 23, 2026 7:46am
promptfiddle Ready Ready Preview, Comment Jul 23, 2026 7:46am
promptfiddle2 Ready Ready Preview, Comment Jul 23, 2026 7:46am

Request Review

@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.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds Self-aware class method codegen and revises typed inbound values, union-arm discrimination, literal preservation, protobuf bindings, SDK serializers, host-call conversion, cancellation, and host-release lifecycle handling.

Changes

Typed bridge and codegen flow

Layer / File(s) Summary
Self-aware class method lowering
baml_language/crates/baml_project/src/client_codegen.rs
Class method parameters and returns resolve Self to the owning class, including generic and nested cases.
Union identity and contextual conversion
baml_language/crates/bex_external_types/*, baml_language/crates/bex_engine/src/conversion.rs
Union matching uses selected metadata, structural equality, exact literals, enum variants, contextual containers, media kinds, and JSON validation.
Typed protobuf contract
baml_language/crates/bridge_ctypes/types/.../*.proto, baml_language/crates/bridge_ctypes/src/*
Inbound values gain sparse value_type metadata, outbound unions gain selected_option_index, and literal decoding preserves exact identity.
Generated bindings and SDK serializers
baml_language/sdks/{cpp,python,rust,typescript}/..., typescript2/pkg-proto/*
Generated bindings and SDK encoders use revised inbound class metadata and outbound union discriminator fields.
Host-call runtime lifecycle
baml_language/crates/bex_engine/src/lib.rs, baml_language/crates/bridge_cffi/src/ffi/host_value.rs, baml_language/crates/bex_engine/tests/*
Host-call parameters are resolved from closure contracts, cancellation is checked before sys-op conversion, releases drain at FFI return, and union-callable behavior is tested.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Host
  participant Bridge
  participant BexEngine
  participant VM
  Host->>Bridge: send value with sparse type metadata
  Bridge->>BexEngine: decode typed value and selected union arm
  BexEngine->>VM: coerce contextual payload and execute call
  VM->>BexEngine: return typed union or host result
  BexEngine->>Bridge: encode canonical selected option index
  Bridge->>Host: deliver converted value
Loading

Possibly related PRs

Suggested reviewers: rossirpaulo

Poem

A rabbit hops through unions bright,
With Self resolved just right.
Typed bridges carry clues,
Empty lists choose news,
And host calls land light.

🚥 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 is broad, but it accurately reflects the PR’s main focus on SDK codegen and runtime foundation changes.
✨ 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 codex/upstream-sdk-codegen-foundations

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.

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 26.5 MB 11.2 MB file 26.2 MB +263.3 KB (+1.0%) OK
packed-program Linux 🔒 17.3 MB 7.1 MB file 17.0 MB +283.6 KB (+1.7%) OK
baml-cli macOS 🔒 20.5 MB 9.8 MB file 20.3 MB +215.3 KB (+1.1%) OK
packed-program macOS 🔒 13.5 MB 6.3 MB file 13.2 MB +297.6 KB (+2.3%) OK
baml-cli Windows 🔒 22.1 MB 10.0 MB file 21.9 MB +221.7 KB (+1.0%) OK
packed-program Windows 🔒 14.4 MB 6.4 MB file 14.2 MB +244.2 KB (+1.7%) OK
bridge_wasm WASM 16.4 MB 🔒 4.5 MB gzip 4.4 MB +59.7 KB (+1.4%) 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

@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: 5

Caution

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

⚠️ Outside diff range comments (1)
baml_language/sdks/rust/bridge_rust/src/baml_value.rs (1)

219-223: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Populate collection type descriptors instead of always sending None.

These implementations know T, K, and V, but every Rust collection decodes with fallback metadata. For example, Vec<String> becomes an array typed with the scalar fallback, while non-string maps default to string keys.

Expose a wire-type descriptor through the value/key traits and emit Some(...) for all three implementations. Add unit coverage for empty collections.

As per coding guidelines, "Prefer writing Rust unit tests over integration tests where possible."

Also applies to: 260-271, 296-307

🤖 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/sdks/rust/bridge_rust/src/baml_value.rs` around lines 219 -
223, The Rust collection implementations of __BamlValuePrivate for Vec, HashMap,
and BTreeMap currently send item_type/key_type/value_type as None. Extend the
relevant value/key traits with wire-type descriptor support, use T/K/V to emit
Some(...) descriptors in all three implementations, and add Rust unit tests
covering empty collections and their descriptors.

Source: Coding guidelines

🤖 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 919-928: Update the BexExternalValue::Union branch in
convert_external_to_vm_value_with_ty to validate metadata.selected_option
against the declared expected_ty union and verify that value inhabits the
selected arm before materializing it. Reuse the existing membership and payload
validation logic from coerce_arg_to_declared_type, rejecting malformed arms or
callable signatures before the recursive conversion.
- Around line 2239-2304: The union coercion logic does not recognize function
arms or callable host values. Extend runtime_ty_structurally_equal to compare
RuntimeTy::Function signatures recursively, and update value_matches_type to
accept the HostValue/FunctionRef representations for function types. Add a
regression test covering a host-selected callback in a union and preserve
existing matching behavior for other arms.
- Around line 2033-2067: Update both shared and schema-aware host-return
validators to mirror the matching rules used by runtime_ty_compatible: compare
float literals with float_literal_matches, recursively validate list and map
element/key/value descriptors even when containers are empty, and require the
returned media kind to match the declared type. Preserve exact literal checks
for other scalar values so mismatched literals, empty string arrays, and audio
returned for image declarations are rejected.

In `@baml_language/crates/bridge_ctypes/src/ty_decode.rs`:
- Around line 256-261: Update the BigintValue branch in the literal decoding
logic to reject decimal strings exceeding the existing value-level bigint length
limit before calling parse_bytes or formatting the input. Return a length-only
CtypesError for oversized values, while preserving the current invalid-format
error for bounded inputs.

In `@baml_language/crates/bridge_ctypes/src/value_decode.rs`:
- Around line 95-129: Update convert_union_variant and the corresponding
list/map decoding paths to validate each decoded payload recursively against its
wire-declared RuntimeTy, not only union membership. Ensure selected_type,
item_type, key_type, and value_type reject mismatched values before constructing
BexExternalValue containers or unions, while preserving valid nested values. Add
Rust unit tests covering mismatched union, list, and map payloads.

---

Outside diff comments:
In `@baml_language/sdks/rust/bridge_rust/src/baml_value.rs`:
- Around line 219-223: The Rust collection implementations of __BamlValuePrivate
for Vec, HashMap, and BTreeMap currently send item_type/key_type/value_type as
None. Extend the relevant value/key traits with wire-type descriptor support,
use T/K/V to emit Some(...) descriptors in all three implementations, and add
Rust unit tests covering empty collections and their descriptors.
🪄 Autofix (Beta)

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

Run ID: e7abb70f-b98f-4744-aab8-627f95646797

📥 Commits

Reviewing files that changed from the base of the PR and between 972fdd2 and edf8aad.

⛔ Files ignored due to path filters (2)
  • baml_language/sdks/typescript/bridge_typescript/dist/proto/baml_cffi.d.ts is excluded by !**/dist/**
  • baml_language/sdks/typescript/bridge_typescript/dist/proto/baml_cffi.js is excluded by !**/dist/**
📒 Files selected for processing (31)
  • baml_language/crates/baml_project/src/client_codegen.rs
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_engine/tests/host_value_callable.rs
  • baml_language/crates/bex_external_types/src/host_return.rs
  • baml_language/crates/bex_external_types/src/lib.rs
  • baml_language/crates/bridge_cffi/src/ffi/host_value.rs
  • baml_language/crates/bridge_ctypes/README.md
  • baml_language/crates/bridge_ctypes/src/error.rs
  • baml_language/crates/bridge_ctypes/src/ty_decode.rs
  • baml_language/crates/bridge_ctypes/src/ty_encode.rs
  • baml_language/crates/bridge_ctypes/src/value_decode.rs
  • baml_language/crates/bridge_ctypes/src/value_encode.rs
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_inbound.proto
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_outbound.proto
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_type.proto
  • baml_language/crates/sys_native/src/host_dispatch.rs
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_inbound.pb.cc
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_inbound.pb.h
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.cc
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.h
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_inbound_pb2.py
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_inbound_pb2.pyi
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.py
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.pyi
  • baml_language/sdks/rust/bridge_rust/src/baml_value.rs
  • baml_language/sdks/rust/bridge_rust/src/wire/baml_bridge.cffi.v1.rs
  • baml_language/sdks/typescript/bridge_typescript/typescript_src/proto/baml_cffi.d.ts
  • baml_language/sdks/typescript/bridge_typescript/typescript_src/proto/baml_cffi.js
  • typescript2/pkg-proto/src/encode.ts
  • typescript2/pkg-proto/src/test/encode-decode.test.ts

Comment thread baml_language/crates/bex_engine/src/conversion.rs
Comment thread baml_language/crates/bex_engine/src/conversion.rs Outdated
Comment thread baml_language/crates/bex_engine/src/conversion.rs Outdated
Comment thread baml_language/crates/bridge_ctypes/src/ty_decode.rs Outdated
Comment thread baml_language/crates/bridge_ctypes/src/value_decode.rs Outdated

@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/bridge_ctypes/src/value_encode.rs`:
- Around line 366-387: The selected option lookup in selected_union_option_index
currently uses strict RuntimeTy equality; replace the members.iter().position
comparison with the existing structural union-member equality helper used by
selected_arm_equal/runtime_ty_structurally_equal. Preserve the current
not-member error and u32 index conversion behavior.
🪄 Autofix (Beta)

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

Run ID: cb915954-7a05-482f-a70c-affbdaf2b612

📥 Commits

Reviewing files that changed from the base of the PR and between edf8aad and de6eadb.

⛔ Files ignored due to path filters (2)
  • baml_language/sdks/typescript/bridge_typescript/dist/proto/baml_cffi.d.ts is excluded by !**/dist/**
  • baml_language/sdks/typescript/bridge_typescript/dist/proto/baml_cffi.js is excluded by !**/dist/**
📒 Files selected for processing (32)
  • baml_language/crates/baml_project/src/client_codegen.rs
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_engine/tests/host_value_callable.rs
  • baml_language/crates/bex_external_types/src/bex_external_value.rs
  • baml_language/crates/bex_external_types/src/host_return.rs
  • baml_language/crates/bex_external_types/src/lib.rs
  • baml_language/crates/bridge_cffi/src/ffi/host_value.rs
  • baml_language/crates/bridge_ctypes/README.md
  • baml_language/crates/bridge_ctypes/src/error.rs
  • baml_language/crates/bridge_ctypes/src/ty_decode.rs
  • baml_language/crates/bridge_ctypes/src/ty_encode.rs
  • baml_language/crates/bridge_ctypes/src/value_decode.rs
  • baml_language/crates/bridge_ctypes/src/value_encode.rs
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_inbound.proto
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_outbound.proto
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_type.proto
  • baml_language/crates/sys_native/src/host_dispatch.rs
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_inbound.pb.cc
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_inbound.pb.h
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.cc
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.h
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_inbound_pb2.py
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_inbound_pb2.pyi
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.py
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.pyi
  • baml_language/sdks/rust/bridge_rust/src/baml_value.rs
  • baml_language/sdks/rust/bridge_rust/src/wire/baml_bridge.cffi.v1.rs
  • baml_language/sdks/typescript/bridge_typescript/typescript_src/proto/baml_cffi.d.ts
  • baml_language/sdks/typescript/bridge_typescript/typescript_src/proto/baml_cffi.js
  • typescript2/pkg-proto/src/encode.ts
  • typescript2/pkg-proto/src/test/encode-decode.test.ts
🚧 Files skipped from review as they are similar to previous changes (20)
  • baml_language/crates/bridge_ctypes/src/error.rs
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_type.proto
  • baml_language/crates/bridge_ctypes/types/baml_bridge/cffi/v1/baml_outbound.proto
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.pyi
  • baml_language/crates/bex_external_types/src/lib.rs
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_inbound_pb2.py
  • baml_language/crates/bex_engine/tests/host_value_callable.rs
  • typescript2/pkg-proto/src/encode.ts
  • baml_language/crates/bridge_ctypes/src/ty_encode.rs
  • baml_language/crates/bridge_cffi/src/ffi/host_value.rs
  • baml_language/sdks/rust/bridge_rust/src/baml_value.rs
  • baml_language/crates/bridge_ctypes/src/ty_decode.rs
  • baml_language/crates/sys_native/src/host_dispatch.rs
  • baml_language/crates/bex_external_types/src/host_return.rs
  • baml_language/crates/baml_project/src/client_codegen.rs
  • baml_language/sdks/python/src/baml_bridge/cffi/v1/baml_outbound_pb2.py
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.h
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/sdks/cpp/bridge_cpp/pb/baml_bridge/cffi/v1/baml_outbound.pb.cc

Comment thread baml_language/crates/bridge_ctypes/src/value_encode.rs

@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_external_types/src/runtime_ty_identity.rs`:
- Around line 72-79: Update the T::Union branch of runtime_ty_structurally_equal
to require every member in both unions to have a structurally equal counterpart
in the other union, while retaining the length check. Ensure duplicate-member
cases produce the same result regardless of operand order.
🪄 Autofix (Beta)

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

Run ID: 9741d196-0a3c-4943-b752-a4bffa9d463e

📥 Commits

Reviewing files that changed from the base of the PR and between de6eadb and 35ded35.

📒 Files selected for processing (7)
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/crates/bex_external_types/src/lib.rs
  • baml_language/crates/bex_external_types/src/runtime_ty_identity.rs
  • baml_language/crates/bex_project/src/lib.rs
  • baml_language/crates/bridge_ctypes/src/error.rs
  • baml_language/crates/bridge_ctypes/src/ty_decode.rs
  • baml_language/crates/bridge_ctypes/src/value_encode.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • baml_language/crates/bridge_ctypes/src/ty_decode.rs
  • baml_language/crates/bridge_ctypes/src/value_encode.rs
  • baml_language/crates/bex_engine/src/conversion.rs

Comment thread baml_language/crates/bex_external_types/src/runtime_ty_identity.rs Outdated
@hellovai

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Reviews resumed.

…codegen-foundations

# Conflicts:
#	baml_language/crates/bex_engine/src/conversion.rs
#	baml_language/crates/bex_external_types/src/host_return.rs
@hellovai
hellovai enabled auto-merge July 23, 2026 07:40
@hellovai
hellovai added this pull request to the merge queue Jul 23, 2026
Merged via the queue into canary with commit ceae8ea Jul 23, 2026
71 of 72 checks passed
@hellovai
hellovai deleted the codex/upstream-sdk-codegen-foundations branch July 23, 2026 07:48
antoniosarosi added a commit that referenced this pull request Jul 23, 2026
…on (#4087)

#4087 migrated the Java bridge to the node-level InboundValue.value_type
sparse annotation (removing InboundClassValue.class_ty) and added the
outbound union selected_option_index, but left the Java bridge-ref docs
describing the pre-migration wire shape. Bring the two docs back to truth:

ref-java-inbound-encoding.md
- add value_type = 1 to the InboundValue proto block; document reserved
  field 1 on InboundClassValue
- new "The value_type annotation" section: how Java (a Reject-policy typed
  producer) threads the contextual declared union — BamlTypedValue for
  top-level args, fieldDescs for class fields, item/value types for
  containers — and emits value_type for the three canonical cases (empty
  container arm, overlapping arm, literal-vs-primitive)
- class identity / generic type_args now on value_type.class_ty (not the
  class_value payload); media kind on value_type.media.kind
- union-arm and empty-container rows/notes updated for arm fidelity;
  deviation flag records typed-producer vs Python's dynamic SelectDefault

ref-java-outbound-decoding.md
- union-variant decoding: honors canonical selected_option_index (field 8)
  first, structural armMatchesValue only as fallback; deviation flag updated

Docs-only; verified against ProtoWriter/ProtoReader/TypeRegistry/emit.rs and
the engine (bridge_ctypes value_decode, bex_engine conversion). Full battery
green: sdkgen_java 92 + clippy, baml_bridge gradle test+jar, sdk_test_java
nextest 10/10 (+1 skip).
pull Bot pushed a commit to justinlietz93/baml that referenced this pull request Jul 23, 2026
## Outcome

This makes the generated Go SDK usable across the broad static/runtime
surface we have completed so far, while keeping unsupported BAML shapes
omitted instead of emitting uncompilable Go.

Before, Go generation covered the initial free-function and basic-type
slice. After this PR, generated callers can use packages/namespaces,
classes, enums, optionals/defaults, closed and dynamic unions, aliases,
generic classes/functions, media, canonical JSON, reflected `type`
values, opaque `$rust_type` handles, host callbacks, methods/static
methods, parse/build-request companions, cancellation, and BAML time
values through one Go-native API.

```go
result, err := baml_sdk.UserExtract(ctx, input)
```

Generated classes, closed unions, and callbacks remain statically typed
where the configured union threshold permits it. Larger unions
intentionally project to `any`, retain candidate documentation, and rely
on BAML's runtime validation.

## User-visible behavior

- Generates one Go package per BAML package, with namespace-qualified
declarations inside it so BAML namespace cycles do not become Go import
cycles.
- Applies one canonical typed naming projection while retaining exact
wire names for FFI serialization.
- Generates compile-safe free functions, instance/static methods,
functional options for defaulted arguments, and callback option structs
for optional callback parameters.
- Supports recursive classes, enums, lists/maps, nullability, literals,
aliases, generic type arguments, media, JSON, reflected types, opaque
Rust handles, and canonical union arm selection.
- Preserves exact selected-node type information across the final shared
inbound wire contract from BoundaryML#4087, including ambiguous empty containers
and nullable bigint containers.
- Omits unsupported functions without breaking the rest of the generated
package.
- Wires Go generation into the normal CLI flow and mirrors the shared
SDK fixture harness.

## Audit map

Review in this order:

1. `baml_language/sdks/go/sdkgen_go/src/types.rs` — canonical BAML-to-Go
semantic projection and support filtering.
2. `baml_language/sdks/go/sdkgen_go/src/names.rs` — collision-safe name
allocation and exact wire identity.
3. `baml_language/sdks/go/sdkgen_go/src/lib.rs` — package layout,
declarations, codecs, functions, methods, callbacks, and descriptors.
4. `baml_language/sdks/go/baml_go/` — runtime encoding/decoding,
ownership, callbacks, media, JSON, reflected types, and handles.
5. `baml_language/sdk_tests/crates/go/` plus Go customizable fixtures —
generated compile/runtime coverage and Python-parity tests.
6. `baml_language/crates/baml_cli/` and
`sdk_tests/harness_setup/src/go.rs` — CLI and test-harness integration.

## Validation

Final post-BoundaryML#4087 sync validation:

- `cargo test -p sdkgen_go` — 84/84 passed
- `cargo test -p bex_external_types` — 21/21 passed; 3 doctests ignored
- `cargo test -p bex_engine --test host_value_callable` — 23 passed; 1
documented compiler-gap test ignored
- `cargo nextest run -p sdk_test_go --no-fail-fast` — 9/9 passed
- Go runtime unit suite — passed
- Repository pre-commit formatting, conflict checks, Clippy, and
cargo-hawk dead-public analysis — passed

## Deliberate remaining scope

- Streaming implementation is deferred.
- Large unions intentionally use `any` above the configured typed-union
threshold.
- Structured BAML error values are currently surfaced to Go primarily
through error text/trace behavior.
- Packaging, publication, and coordinated nightly/canary releases are
follow-up release work rather than generator semantics.


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

* **New Features**
* Added Go-only `max_typed_union_arity` setting (default **3**) with
`baml.toml` template support, validation, and generation behavior for
typed unions.
* Expanded the Go SDK with typed-union tuning support plus stronger
generics, JSON-algebra utilities, media/time/reflection helpers, and
improved host-callable integration.
* **Bug Fixes**
* Improved generated Go output installation to safely replace stale
files while preserving user-created ones.
* **Tests**
* Added/extended extensive Go generator and SDK test suites, including
additional gofmt-clean generation and broader runtime edge coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
pull Bot pushed a commit to justinlietz93/baml that referenced this pull request Aug 4, 2026
…on (BoundaryML#4087) (BoundaryML#4153)

## Summary

Parity investigation of the Java bridge against the new inbound
`value_type` ABI annotation introduced in ceae8ea (BoundaryML#4087). **Finding:
BoundaryML#4087 already implemented the Java encoder/decoder support and the
empty-list arm-fidelity test contract end-to-end** — the one gap it left
was the Java `bridge-ref` docs, which still describe the pre-migration
wire shape. This PR is that docs parity fix. No runtime/codegen change
was warranted.

### What BoundaryML#4087 already did (verified, no change needed)
- **Inbound annotation.** Java threads the declared union as a
*contextual type* and emits `value_type` for the selected arm exactly in
the ambiguity cases: `BamlTypedValue(value, descriptor)` for top-level
args (`emit.rs`), parallel `fieldDescs[]` for class fields, item/value
types for containers (`ProtoWriter.encodeInboundValue`). Covers
empty-container arms, overlapping arms, and literal-vs-primitive. Java
registers the `Reject` ambiguity policy (a typed producer), so it
annotates rather than leaning on Python's dynamic `SelectDefault`.
- **Parity with Python.** Python sets `value_type` only for class
identity and media kind; Java matches both (now on `value_type.class_ty`
/ `value_type.media.kind`) **and** additionally annotates selected union
arms — the extra information the sparse channel exists to carry for a
typed producer.
- **Outbound.** `ProtoReader` honors the new canonical
`selected_option_index` (field 8), falling back to structural
`armMatchesValue` only when absent.
- **Empty-list contract.** `TestUnions.test_round_trip_str_or_int_list`
already asserts full arm fidelity (empty via `Arm0` stays `Arm0`, via
`Arm1` stays `Arm1`).

### Docs brought back to truth
- `ref-java-inbound-encoding.md`: add `value_type = 1` to the
`InboundValue` proto; new "The `value_type` annotation" section (how
Java threads the contextual union); class identity / generic args now on
`value_type.class_ty`, media kind on `value_type.media.kind`; union-arm
+ empty-container rows/notes; typed-producer-vs-dynamic deviation flag.
- `ref-java-outbound-decoding.md`: union-variant decoding documents the
`selected_option_index`-first resolution.

## Test plan (full battery, cold builds)
- `bridge_ctypes` value_decode: 18 passed
- `bex_engine` conversion (union arm selection): 71 passed
- `sdkgen_java`: 92 passed; `cargo clippy -p sdkgen_java`: clean
- `baml_bridge` gradle `test jar`: BUILD SUCCESSFUL
- `cargo nextest run -p sdk_test_java`: **10 passed, 1 skipped** — all
four fixture suites (docstrings_etc, function_calls, llm_functions,
type_shapes), each javac + junit

Docs-only change; no fixture/test counts changed.

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

* **Documentation**
* Updated Java inbound encoding guidance to match the latest wire/proto
behavior, including node-level `value_type`, class identity and reified
generic type args, and improved union/empty-container annotation rules.
* Revised Java outbound union decoding guidance to use canonical
`selected_option_index` when available, with clear structural fallback,
null handling, and error behavior.
* Adjusted Rust inbound decode mapping descriptions and updated related
examples/test references to reflect the new contracts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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