Generated models

No pub.layers.* model in lairs is written by hand. Every record type, every nested object, and the one formal union are generated from the vendored Layers lexicons and committed to the repository. This constraint keeps the path from lexicon to dx.Model explicit and supports a byte-exact drift check of the committed models.

Why generated, never authored

The Layers lexicons are the single source of truth for the schema. There is no second description of it anywhere in lairs. A hand-written model would be a second description that could drift from the lexicon it claims to mirror. Generation avoids maintaining these competing schema descriptions: the committed models are a pure function of the vendored lexicons, and updating to a new Layers version is a mechanical sequence rather than a model-by-model edit. Re-vendor the lexicons, regenerate, and run the drift check.

This is a hard rule, not a preference. Behavior over the generated models (builders, view helpers, anchor dispatch) is ordinary code and lives outside the generated tree. Anything that mirrors the schema is generated. Anything that is behavior over the schema is not. The anchor_kind helper and the explode_layer helper in lairs.records are behavior. The Anchor and AnnotationLayer classes are generated.

The path from lexicon to model

A lexicon document becomes committed Python in four stages.

lexicon JSON
  -> panproto.parse_atproto_lexicon(doc)   parse to a Schema
  -> Schema + document  ->  spec models     lairs._codegen.schema_to_spec
  -> emitted module text                    lairs._codegen.emit
  -> lairs/records/_generated/<ns>.py       committed, ruff-canonicalized

panproto parses each lexicon into a Schema under its built-in atproto protocol. The Schema is the parsed, structured form: it retains the union discriminators, the refined value types, the reference-versus-containment edge distinction, and the integer ranges.

lairs then walks the lexicon into a sequence of spec models: the FieldSpec, VariantSpec, and ModelSpec value types, which are themselves dx.Models, because the codegen intermediate representation is data like everything else in lairs. One spec is produced per record, per nested object, and per formal union. The spec carries the description, the optionality (whether a property is in the lexicon's required set), the refined type, the integer range, the knownValues of an open string enum, and, for a union, its discriminator and members.

The spec mapping reads its structure from the lexicon document, not from the parsed Schema. The document retains the required sets and the field descriptions that the Schema graph does not surface, and it preserves definition order. The Schema is parsed and accepted (which asserts that the document parses cleanly under the atproto protocol) but the field-by-field walk is driven by the JSON. The two sources are complementary: the parse is the correctness check, the document is the data.

The emitter renders each spec to module text, the pipeline injects the cross-namespace imports a module needs (for instance annotation embedding defs#anchor), and a ruff format then ruff check --fix then ruff format pass converges the output to a stable, lint-clean form. A fresh generation can thus be compared byte-for-byte against the committed modules.

Why not the lossy theory path

panproto can also induce a categorical theory from a Schema, and didactic can synthesize models from a theory. That route is shorter, and it is not used. The induced theory is lossy by design: it cannot express refined value types, per-field defaults and descriptions, or the reference-versus-containment distinction, and it drops union discriminators, so a model rebuilt from a theory cannot reconstruct a tagged union. didactic's own spec-dict synthesizer is closer but still discards descriptions, defaults, optionality, refined types, and the embed-versus-ref distinction.

Because lairs needs every one of those properties in the committed output, it does not route through either. Instead, it walks the lexicon into spec models, which the emitter renders directly. The theory path remains useful for a quick structural check but is not the generation path.

The distinction is most visible in the union. The Layers lexicons contain a single formal union: the selector of defs#externalTarget, over the three W3C selector types. It generates a dx.TaggedUnion (ExternalTargetSelector) with a kind discriminator and one member class per reference. Had codegen gone through the theory, the discriminator would have been lost and the union could not have been rebuilt. A focused codegen test asserts that the lexicon union round-trips to a tagged union with its discriminator intact.

Note what is not a tagged union. The polymorphic anchor and the universal objectRef are lexicon objects with several optional fields, where a consumer dispatches on which fields are populated, not formal unions over refs. They generate as ordinary dx.Models with optional fields, faithfully to the lexicon. The anchors-and-modality page explains why the lexicons model anchors this way and how lairs dispatches on them.

The drift gate

The generated modules are committed rather than generated at install time. Committing them provides import speed, IDE and type-checker support, and reviewable diffs when a Layers version is bumped. The committed modules must thus stay faithful to the vendored lexicons.

lairs gen --check enforces this requirement. It regenerates the modules into a temporary directory from the vendored lexicons and compares them byte-for-byte against the committed modules. Any difference fails. Each generated module carries a header recording the lexicon-tree hash it was produced from, and the same hash lives in the manifest. The canonicalization pass makes this comparison byte-exact rather than merely semantically equivalent.

For the operational steps (vendoring a lexicon tree, regenerating, and running the check) see the codegen guide.