Skip to content

feat(codegen): lower open string discriminator unions into ordered anyOf with <Union>Base - #87

Merged
gsmith85 merged 2 commits into
mainfrom
feat/codegen-open-unions
Oct 21, 2026
Merged

gsmith85 merged 2 commits into
mainfrom
feat/codegen-open-unions

Conversation

@gsmith85

@gsmith85 gsmith85 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Working...

Summary

Implements Task 7 (PR 3b) of the two-layer ucp-schema code-generation pipeline: open string discriminator union lowering (lower_conditional_unions in src/codegen/reify.rs), stacked on #86.

Changes

  • Conditional allOf if/then.$ref Union Lowering (src/codegen/reify.rs):
    • Detects schemas whose allOf branches consist of if: { properties: { <disc>: { const | enum([single]) } } } paired with then: { "$ref": "#/$defs/<Variant>" }.
    • Merges parent properties and required into each referenced variant definition, removes any back-reference {"$ref": "#/$defs/<ParentName>"} from the variant's allOf, sets the variant's discriminator property to {"type": "string", "const": "<tag>"}, and strips "default" from variant discriminator properties (invariant OU-4).
  • Open vs. Closed Discriminator Handling (OU-1 through OU-4):
    • When the parent discriminator property is an open string (type == "string" without const or enum, or a $ref to such a string schema like ReverseDomainName), synthesizes <ParentName>Base in $defs with all base properties, additionalProperties: true, and <disc>: { "type": "string", ..., "not": { "enum": [<sorted_known_tags>] } } (invariant OU-2), and strips "default" from the base discriminator property (invariant OU-4).
    • Rewrites <ParentName> to an ordered anyOf containing sorted $refs to the known variants followed by {"$ref": "#/$defs/<ParentName>Base"} as the final fallback branch (invariant OU-1), without emitting the OpenAPI "discriminator" keyword (invariant OU-3).
    • When the parent discriminator is closed (enum or const), emits anyOf with only the sorted variant $refs and no <ParentName>Base definition.

Stacked PR Series Roadmap

  1. PR 1a (feat(codegen): add identifier naming, UCP keyword stripping, and $defs ref rewriting #81): Identifier qualification, UCP keyword stripping, $ref rewriting (src/codegen/normalizer.rs)
  2. PR 1b (feat(codegen): add directional schema slicing and $ref alignment #82): Directional schema slicing & $ref alignment (src/codegen/normalizer.rs)
  3. PR 1c (feat(codegen): add capability reachability, composition, and generate_types() API #83): Capability/extension reachability, composition & generate_types() library API (src/codegen/{mod,reachability,hoist,compose}.rs)
  4. PR 1d (feat(cli): add generate-types subcommand #84): CLI generate-types subcommand (src/bin/ucp-schema.rs, tests/generate_types_test.rs)
  5. PR 2 (feat(codegen): add self-contained .well-known/ucp profile scoping for generate-types #85): Self-contained .well-known/ucp --profile scoping (src/codegen/profile.rs)
  6. PR 3a (feat(codegen): add AST shape normalizers and inline conditional variant hoisting #86): Codegen AST shape normalizers & inline conditional variant hoisting (src/codegen/normalizer.rs, src/codegen/reify.rs)
  7. PR 3b (this PR): Open string discriminator union lowering (<Union>Base) (src/codegen/reify.rs)
  8. PR 3c (feat(codegen): discover and register decentralized extended subtypes into open unions #88): Cross-file extended subtype discovery & registration (src/codegen/reify.rs)
  9. PR 4a (python-sdk#101): Python SDK direct JSON Schema codegen (generate_models.sh, postprocess_models.py, src/ucp_sdk/models/)
  10. PR 4b (js-sdk#87): JS/TS SDK direct JSON Schema codegen (generate_models.sh, scripts/generate-zod-from-types.mjs, src/spec_generated.ts)
  11. PR 5 (feat(openapi): add profile-driven OpenAPI 3.1 service and type binding (generate-openapi) #89): OpenAPI 3.1 REST service & type binding (ucp-schema generate-openapi, src/openapi/mod.rs)

Test Plan

  • cargo fmt --check
  • cargo test --all-targets
  • cargo clippy --all-targets -- -D warnings

@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Oct 3, 2026
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 63a971e to 8ebe945 Compare October 3, 2026 02:03
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 33e031a to 233fbe2 Compare October 3, 2026 02:07
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 233fbe2 to 9971a9c Compare October 5, 2026 23:57
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from a9d9753 to 57d290f Compare October 6, 2026 00:12
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch 2 times, most recently from 7940a92 to cd8c9b3 Compare October 7, 2026 06:00
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 57d290f to 63aa281 Compare October 7, 2026 06:00
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 63aa281 to 831c2df Compare October 7, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch 2 times, most recently from ff6d9c1 to 792c4ed Compare October 8, 2026 00:32
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 831c2df to 2d102e4 Compare October 8, 2026 00:32
@damaz91 damaz91 added status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Oct 14, 2026

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

Clean implementation of Stage 7 conditional union lowering (OU-1 through OU-4), $ref discriminator inlining on <Union>Base, and request-slice *Base pruning! Left a few inline notes on edge cases and clippy lints.

Comment thread src/codegen/mod.rs Resolved
Comment thread src/codegen/mod.rs Resolved
Comment thread src/codegen/reify.rs Resolved
Comment thread src/codegen/reify.rs Resolved
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 2d102e4 to b338761 Compare October 14, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 792c4ed to 61c5803 Compare October 14, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from b338761 to 6ff6704 Compare October 14, 2026 23:23
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 61c5803 to 5df86d0 Compare October 14, 2026 23:23
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 6ff6704 to 6ccf31b Compare October 15, 2026 01:18
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 6ccf31b to aca7679 Compare October 15, 2026 01:43
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch 2 times, most recently from 84e4665 to 68e158b Compare October 15, 2026 01:56
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from aca7679 to e2d223a Compare October 15, 2026 20:22
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 68e158b to 7f79b07 Compare October 15, 2026 20:22
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from e2d223a to 816d836 Compare October 15, 2026 22:49
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 7f79b07 to 592e88a Compare October 15, 2026 22:49
@gsmith85
gsmith85 requested a review from jingyli October 16, 2026 01:10
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from 592e88a to f0bdaea Compare October 16, 2026 18:26
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch 2 times, most recently from 5767e71 to f53e59e Compare October 16, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from f0bdaea to a8b8d6d Compare October 16, 2026 22:04

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

Approving. The OU-1 to OU-4 rules hold on every union in the corpus, and inlining the $ref string constraints on <Union>Base is a nice touch.

I used an AI assistant (Antigravity) to help with this review. It compared this PR against its own base branch (feat/codegen-ast-reify), not main, and I ran the full test suite and clippy on the branch: all green. The comments below are about union shapes that do not exist today but could show up as new verticals are added.

Two inline comments, each a small fix plus a test. One smaller note here so it does not get lost:

  • If a union's allOf has even one branch that is not an if/then.$ref variant (say one extra if/then value rule), extract_conditional_union_branches returns None and the whole union quietly keeps its old allOf form, with no Base and none of the OU-* handling. A safe default, but invisible. Either lower the variant branches and copy the other branches into each variant and into Base, or add a corpus test that fails if any $defs entry still has an allOf branch with then.$ref after Stage 7.

All fine to defer if you want to land the stack first; say so and I will open issues to track them.

Comment thread src/codegen/reify.rs
"required",
"type",
"additionalProperties",
"dependentRequired",

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.

dependentRequired is removed from the parent here, but inherit_parent_into_union_variant only copies properties and required into each variant. build_union_base_schema does keep it on <Union>Base. So after lowering, a rule like "if hours is set, timezone is required" still applies to unknown tags but no longer to the known variants, which is the opposite of what the author meant.

It does not show today because the only unions with dependentRequired (FulfillmentMethod*) have variants that were hoisted in Stage 4 and already copied it, and the test at generate_types_test.rs:1589 only checks the Base. Any union whose variants are separate $defs or files (ShippingDestination, MediaImage, CancellationItem, or a future one) would lose it.

Suggestion: in inherit_parent_into_union_variant, also copy the parent's dependentRequired (dropping entries already covered by the variant's required, as build_hoisted_variant_schema does). minProperties / maxProperties / propertyNames could travel the same way. A unit test with a $ref variant and a parent dependentRequired would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Extracted prune_satisfied_dependent_required in ba88937 so both Stage 4 and Stage 7's inherit_parent_into_union_variant merge dependentRequired (pruning entries already satisfied by the variant's required list) along with minProperties, maxProperties, and propertyNames, covered by lower_conditional_unions_handles_partial_enums_dependent_required_and_non_variant_allof.

Comment thread src/codegen/reify.rs Outdated
);
}

let open_disc_target = is_open_string_discriminator(&parent_schema, &disc_prop, defs);

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.

When the discriminator is a closed enum, the parent becomes anyOf: [<variants>] with no Base. That is only correct if every enum value has a then.$ref branch. With status: ["a","b","c"] and variants for a and b only, { "status": "c" } was valid before (no if matched, parent properties applied) and is invalid after (no anyOf branch matches).

No union in today's corpus is closed, so nothing changes now, but "a few special shapes out of a longer status enum" is a very common pattern for new verticals.

Suggestion: when the enum has values with no variant, emit a <Union>Base whose discriminator is { "type": "string", "enum": [<the remaining values>] } and append it last, like the open case. If every value has a variant, keep the current behaviour. Adding a third enum value to the existing ClosedUnion unit test would cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ba88937. classify_union_discriminator now returns UnionBaseKind::PartialEnum when a closed enum has uncovered tags and emits <Union>Base with enum: [<remaining_tags>] appended last in anyOf (and omits <Union>Base when every enum tag is covered).

@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from f53e59e to e9bad62 Compare October 21, 2026 01:17
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch 2 times, most recently from 0211640 to b35f537 Compare October 21, 2026 01:42
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from e9bad62 to b7b8c96 Compare October 21, 2026 01:42
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch 2 times, most recently from a211ebb to a741904 Compare October 21, 2026 03:01
@gsmith85
gsmith85 force-pushed the feat/codegen-ast-reify branch from 11d30b1 to 0941a3f Compare October 21, 2026 03:01
Base automatically changed from feat/codegen-ast-reify to main October 21, 2026 03:25
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from a741904 to eb1bdf1 Compare October 21, 2026 03:40
@gsmith85
gsmith85 force-pushed the feat/codegen-open-unions branch from eb1bdf1 to ba88937 Compare October 21, 2026 04:09
@gsmith85

Copy link
Copy Markdown
Contributor Author

Thanks @ptiper! Addressed all three points in ba88937:

  1. dependentRequired & object-level constraints on $ref variants: inherit_parent_into_union_variant now merges dependentRequired (pruning satisfied dependencies via prune_satisfied_dependent_required) and propagates minProperties, maxProperties, and propertyNames.
  2. Partially-covered closed enum discriminators: classify_union_discriminator now returns UnionBaseKind::PartialEnum when an enum has uncovered tags and emits <Union>Base with enum: [<remaining_tags>] last in anyOf.
  3. Mixed allOf branches + corpus invariant: extract_conditional_union_branches now partitions allOf into variant and non-variant branches (propagating non-variant constraints into each variant and <Union>Base), and assert_bundle_invariants verifies that no $defs entry retains an un-lowered then.$ref branch in allOf.

@gsmith85
gsmith85 merged commit 27e360c into main Oct 21, 2026
21 checks passed
@gsmith85
gsmith85 deleted the feat/codegen-open-unions branch October 21, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants