A reserved word in a generated SDK is not a naming problem

I filed a bug against BAML in July and then wrote the fix for it. The bug is easy to state: if a BAML enum member or class field happens to be spelled with a Python keyword, the Python client that BAML generates is not valid Python. The fix is not easy to state, which is the interesting part, and it is the reason this post exists.

The PR is BoundaryML/baml#4070. Before anything else, the status, because I would rather lead with it than bury it: the PR is open. It has not been merged and it has not been approved. More on where it actually stands at the end.

1. What a reserved-word collision actually breaks

Start with what the generator emitted before the fix:

class Tier(str, enum.Enum):
    None = "None"          # SyntaxError: cannot assign to None

class GateReport(pydantic.BaseModel):
    pass: bool             # SyntaxError: invalid syntax

baml generate exits 0. It reports success. The damage shows up later, and it is worse than "one broken type."

A SyntaxError is a module source-parse failure. Python does not get far enough to skip the bad line. So a single keyword-named enum member takes down the entire generated baml_sdk/__init__.py, and every symbol in the generated SDK becomes unimportable. A static, always-reproducible codegen defect gets converted into a runtime import failure a long way from its cause, and the tool that caused it told you everything was fine.

There was no workaround. Not in Python, because the file will not parse. Not in config either: the pydantic2 generator accepts naming_convention = "preserve-case", which emits the invalid line, and "language", which is unimplemented and panics.

And then there is the single best detail in the whole issue. A field named lambda emits:

lambda: int

That is not a syntax error. In a class body it parses as a lambda expression. There is no error, no warning, no import failure. The field simply is not in the model anymore. Every other keyword screams. That one deletes your field and says nothing.

The shape of the bug in one sentence: the set of Python keywords BAML lets through is strictly broader than the set BAML itself reserves. class is a BAML reserved word, so the parser rejects it early with an exit code of 4 and never emits a file. None, True, False, def, pass, lambda and friends are not BAML reserved words, so they fall straight through into invalid Python.

This was also not a fresh discovery. Issue #759, "Detect reserved keywords in specific generator languages," described exactly this years ago, including the phrase "enum or class field." PR #1955 fixed it and shipped in v0.89.0, adding a check that rejected Python reserved words as enum values. But it fixed it in the classic generator line. That validation was never ported into the newer rust-bridge sdkgen pydantic2 generator, so the bug came back on the new code path, and this time it also spanned class field names. That is the general lesson worth taking: a codegen fix lives inside one specific emitter, not inside the project. A new emitter starts at zero.

2. Why escaping at every identifier site is the hard part

The escaping rule itself is trivial. Append a trailing underscore. BAML already used that convention internally: its stdlib assert module ships as baml_sdk/vendor/assert_/. The convention existed. It just was never applied to user-declared identifiers.

The work is in enumerating the sites. An identifier does not appear once. Miss one and you have a module that half-renames itself, which is a new bug rather than a fixed one. The fix had to reach: enum members, class fields, class methods (emitted as Callable-typed property annotations, which bypassed the method-binding escape that already existed), class and enum and type-alias names, cross-references through render_name_ref, __init__ re-exports, .pyi stubs, routing and import lines, and TypeVars.

Two failures inside that surface are worth naming.

Escaping statelessly collides. Escaping is a pure function of the raw name, so raw None and raw None_ both map to None_. A declaration containing both emits duplicate generic parameters, and references cannot tell them apart. The resolution was to stop re-escaping at each reference and instead allocate TypeVars once per leaf, resolving references through a per-scope raw-to-emitted map. Distinct raw spellings never collapse. Identical spellings deliberately share one module-level TypeVar, which is sound because each scope is independently generic on it.

The reference site and the import site are different sites. For a root class named None, the annotation rendered correctly as None_, but the routing code still emitted from .. import None. Invalid Python, and None_ left unbound. The fix escapes the bare name once and reuses it for both the root names and the import anchor.

A fix can also introduce its own collisions. The provenance marker described below is a class attribute named __baml_wire_names__, which is a new name a user identifier can collide with. So when a class emits a marker, the generator reserves that marker name across the class's combined field and method members. A colliding method gets bumped one trailing underscore. A colliding field cannot take that route, because pydantic refuses a model field whose name starts with _ and would raise at class creation, so the field is projected to a spelling with no leading underscore and keeps its raw name through the marker.

Two corner cases I confirmed with executable repros and then deliberately did not fix in scope: a class carrying both a field pass and a method pass_, where the later method assignment clobbers the field's FieldInfo and silently replaces it while the module still imports, and keyword-named classes on nested module paths emitting an unbound annotation in the parent .pyi, which is type-checker-only. Both are written into the PR body rather than papered over.

3. Why the wire identity has to survive the rename

Here is the load-bearing idea. Every identifier lives in two namespaces at once:

  1. The wire identity, the BAML name, which the engine, the prompt, and the protobuf payload use. pass, None, lambda.
  2. The host-language spelling, the Python name, which must be a legal Python identifier. pass_, None_, lambda_.

Before this fix the generator treated those as one object, because for every legal identifier they had always been spelled identically. They were accidentally aliased. Escaping is precisely the operation that forces the distinction to become real, so the rename has to carry the wire name through a side channel.

For enums: escape the member name, keep the value verbatim. None_ = "None". Decode already constructed enums by value, so preserving the value is exactly what keeps decode working unchanged.

For class fields: escape the attribute name and pin the wire name to a pydantic alias, pass_: T = pydantic.Field(alias="pass"), plus populate_by_name=True on any class with at least one escaped field.

The byte-identity guarantee matters as much as the mechanism. A class whose fields are all ordinary gains no alias, no populate_by_name, and no marker. Only a schema that actually contains a keyword renders differently. Every new behavior is keyword-gated, and I backed that with digest comparison over four generated fixture trees plus a pinned keyword-free negative control.

4. What the encode-path risk was

This is the part that makes it more than a codegen patch.

The generated SDK crosses the Python/engine boundary in two directions, and those two directions disagreed about which namespace was authoritative. Decode was already wire-name-driven: fields matched by BAML name, enums constructed by value. Encode was Python-identifier-driven: it sent dict(value) field names, and for enums it sent value.name.

While the two namespaces were accidentally aliased, that asymmetry was invisible. Escaping breaks the accident. Which means renaming pass to pass_ in the generator alone would fix imports and outputs while silently breaking inputs. You would get a module that imports cleanly, whose responses decode correctly, and which sends pass_ to an engine expecting pass, forever. The half-fix is worse than the bug, because the bug is loud and the half-fix is not.

So the generator change forced a bridge change: field encode routes through the alias when present, and enum encode sends value.value to match what decode already did.

The first shape of that bridge change inferred wire identity from runtime shape: consume any alias found, consume any enum member's .value. Review showed that shape is ambiguous, because user code is also allowed to have aliases for its own unrelated reasons, and custom enums whose values are not their names exist, including integer enums where forwarding .value into a protobuf string field raises TypeError.

The replacement is an explicit, generator-stamped provenance marker: __baml_wire_names__ on classes, __baml_wire_values__ on enums, listing only the escaped names mapped to their raw BAML names. The bridge reads it via getattr, honors it only if it is dict-shaped, and otherwise falls back to the attribute or member name, which is exactly the pre-fix behavior. Hand-written models and custom enums carry no marker and take the fallback. The runtime shape inference was deleted. The general rule I would keep: do not infer provenance from a property that user code is also allowed to have.

One disclosed limitation: an older generated client carries no marker, so a newer runtime falls back to the escaped Python name and can mismatch the engine's raw name. BAML ships generator and runtime from one release and expects lockstep regeneration, so I added no cross-version shim and wrote the limitation down instead.

The proportions are the reassuring part. The diff is +3592/-140 across 13 files, but 877 of those added lines are new test files, and the largest Rust diff is almost entirely inline #[test] modules. The live encode path change is 50 added lines in one file, proto.py, and that is the file I flagged in the PR body as deserving the closest review. Test gates: 146 generator crate tests (117 existing, 11 from the initial commit, 18 from the response commit), 31 bridge tests, and a fixture covering the 24 keywords BAML admits at source with a 10-test engine round-trip suite. Soft keywords (match, case, type) are deliberately left alone.

Where it actually stands

Open. Not merged, not approved. Three commits, 6 inline review comments, 4 conversation comments, 7 review submissions, mergeable with no conflicts but mergeable_state = blocked, untouched since 2026-07-18. The review activity on it came from the repository's automated reviewer bot and from my replies to it. No BoundaryML maintainer has reviewed it, commented on it, or signed off on it, and an automated approval is not a human one. Issue #4059 is still open, because the fix is not in.

That is the honest ending. Whether it lands is somebody else's call, and it has not been made.