Skip to content

feat(codegen): add self-contained .well-known/ucp profile scoping for generate-types - #85

Merged
gsmith85 merged 2 commits into
mainfrom
feat/codegen-profile-mode
Oct 21, 2026
Merged

gsmith85 merged 2 commits into
mainfrom
feat/codegen-profile-mode

Conversation

@gsmith85

@gsmith85 gsmith85 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements Task 5 (PR 2) of the two-layer ucp-schema code-generation pipeline: self-contained .well-known/ucp profile scoping (--profile <PATH_OR_URL>) for ucp-schema generate-types, stacked on #84.

Changes

  • Self-Contained Profile Module (src/codegen/profile.rs):
    • Adds RestServiceBinding, ParsedProfile, parse_profile_source, and load_and_compute_profile_closure to load .well-known/ucp profiles from a local file path or HTTP(S) URL, validate namespace authority bindings via compose::verify_bindings, and extract transport == "rest" service bindings alongside ucp.capabilities.
    • Sets a default User-Agent header (ucp-schema/<version>) on reqwest::blocking::Client in src/loader.rs so Cloudflare-fronted merchant .well-known/ucp endpoints resolve directly over HTTPS.
  • Iterative Orphaned Extension Pruning:
    • Prunes any extension whose declared extends parents are absent from the profile's active capability set (or were themselves pruned), emitting warning: pruning orphaned extension '<name>' ... to stderr per the UCP Intersection Algorithm.
  • Multi-Origin Schema Crawling & Ambient Root Seeding:
    • Resolves each active capability and extension entrypoint directly from its declared "schema" URL or path (supporting multi-origin vendor extensions such as dev.shopify.catalog alongside dev.ucp.* schemas), crawls relative and absolute child $refs against each parent schema's base location, and seeds ambient protocol roots (common/types/error_response.json and profile.json) relative to ucp.json so --profile mode produces the exact same $defs closure as --schema-dir mode.
  • Profile extends Target Scoping & Multi-Tier Mixin Composition:
    • Scopes extension composition in profile mode to the parent capabilities listed in the profile's extends array (declared_extends on LoadedSchema) and recurses into nested allOf branches in merge_extension_into_schema for multi-tier operation mixins.
  • Strict CLI & Library Mode Exclusivity:
    • Enforces mutual exclusivity between --profile and --schema-dir / --capability / --extension on generate-types and removes the unused --schema-remote-base and --schema-local-base flags.

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 (this PR): 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 (feat(codegen): lower open string discriminator unions into ordered anyOf with <Union>Base #87): 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

@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from ba01543 to 4e46cd3 Compare October 2, 2026 21:05
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Oct 2, 2026
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 4e46cd3 to 3ce648d Compare October 2, 2026 22:46
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch from 095e38d to eae1f7e Compare October 7, 2026 06:00
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 3ce648d to 7ac4684 Compare October 7, 2026 06:00
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch from eae1f7e to 5a86208 Compare October 7, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch 2 times, most recently from 61e89d2 to f5391fa Compare October 8, 2026 00:32
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch from 5a86208 to f7cdac2 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
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from f5391fa to 1cfab4d Compare October 14, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch 2 times, most recently from 2033502 to f7a1e9d Compare October 14, 2026 23:23
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 1cfab4d to 18f89c0 Compare October 14, 2026 23:23
@gsmith85
gsmith85 requested a review from jingyli October 15, 2026 01:08
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch from f7a1e9d to 013e1cf Compare October 15, 2026 01:18
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 18f89c0 to 280bc37 Compare October 15, 2026 01:18
@gsmith85
gsmith85 force-pushed the feat/codegen-cli-generate-types branch from 013e1cf to bc1c420 Compare October 15, 2026 20:22
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 280bc37 to 20358f7 Compare October 15, 2026 20:22
Base automatically changed from feat/codegen-cli-generate-types to feat/codegen-compile-types October 15, 2026 22:38
Base automatically changed from feat/codegen-compile-types to main October 15, 2026 22:41
@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 20358f7 to 802c9a3 Compare October 15, 2026 22:49
@vishkaty

Copy link
Copy Markdown
Contributor

Reviewed this as a downstream consumer: our conformance suite drives ucp-schema as a schema oracle, so a change to generate-types matters to us even though we call resolve and validate. I built the head, ran the full suite locally, exercised --profile against local profiles including $ref values that point at URLs, and mutation tested the new behaviour. Checked at head 802c9a38, base main at 2155380a, with the sibling ucp tree at d9c18e0d.

The feature works. cargo test --all on the head is 407 passed and 0 failed, which is 406 plus the doc test; the cargo test --all-targets that ci.yml runs reports the 406. Profile mode and directory mode agree, output is byte identical across repeated runs, and the emitted $defs bundle has no dangling local refs. Multi parent pruning matches step 3 of the Intersection Algorithm in docs/specification/overview/index.md. Four notes; only the first has a red check behind it.

1. CI is red on the current head. Run 38819650568 on 802c9a38 concluded failure. Test (windows-latest) fails while Format, Clippy, MSRV (1.85), Test (ubuntu-latest) and Test (macos-latest) all pass. The same job is green on main at 2155380a in run 38818861559.

codegen::profile::tests::resolve_schema_target_handles_relative_urls_and_local_paths
panicked at src\codegen\profile.rs:491:9
  left:  "\\work\\schemas\\shopping\\types\\line_item.json"
 right: "/work/schemas/shopping/types/line_item.json"
test result: FAILED. 193 passed; 1 failed

The value on the left arrives through normalize_lexical_path, which rebuilds the path with PathBuf::push and therefore the platform separator, so on Windows it carries backslashes while the expected literal at src/codegen/profile.rs:491 is POSIX. Passing the expected literal through normalize_lexical_path as well, or comparing the two as Path values rather than as String, would make the assertion platform independent without touching production code. I have no Windows machine here, so that is read from the code rather than run.

2. Removing the extends scoping leaves the suite green. I took it out two ways and the full suite passed both times, 407 of 407: dropping && item.extends_capability(k) from the active seed condition at src/codegen/reachability.rs:249, and never populating declared_extends at src/codegen/profile.rs:219.

The behaviour itself is correct, and a profile pair off the real ucp tree pins it. Both profiles declare dev.ucp.shopping.checkout and dev.ucp.shopping.catalog.search as active capabilities plus dev.ucp.shopping.fulfillment as an extension.

fulfillment extends defs CatalogSearchRequest.filters
dev.ucp.shopping.checkout 127 #/$defs/SearchFilters
[checkout, catalog.search] 133 #/$defs/FulfillmentSearchFilters

One trap for anyone copying the pair: the FQDN must be dev.ucp.shopping.catalog.search with dots, matching the $defs key in shopping/fulfillment.json. With catalog_search both profiles emit 127 defs and the pair proves nothing.

The discriminating assertion is the narrow row: with fulfillment extending checkout only, the bundle must not contain FulfillmentSearchFilters, FulfillmentSearchRequest, FulfillmentSearchResponse, FulfillmentDestinationFilter, FulfillmentProduct, FulfillmentVariant, CatalogFulfillment or CatalogFulfillmentMethod. I kill tested it: the first mutant above leaks all eight into the narrow bundle and takes it to 135 defs, the second leaks six and takes it to 133, so the assertion goes red for both. A third variant, dropping cap.extends.is_some() at profile.rs:225, survives both the pair and the suite, and I could not construct a profile that distinguishes it, so I have not counted it.

3. The two tests that carry the PR claims do not run in CI. ucp_schemas_dir() resolves ../ucp/source/schemas, a sibling checkout that actions/checkout does not provide and that is not mentioned in the README or the workflows, so six of the thirteen tests in tests/generate_types_test.rs return early. Two of those six are profile_mode_matches_directory_mode_closure_and_prunes_orphaned_extensions and profile_mode_allbirds_multi_origin_shopify_catalog_extension, which are the ones asserting the equivalence. The pattern predates this PR, four tests on main already use it. Running that file with no sibling present reports 13 passed; 0 failed; 0 ignored, the same count as the real run, so nothing in the output distinguishes a skip from a pass. A vendored fixture subset, or an env var plus #[ignore] with a reason, would make the skip visible.

4. URL to local relocation discards the authority. relocate_url_to_local_schemas at src/codegen/profile.rs:344 does url_str.split_once("/schemas/") and throws the prefix away, so a $ref to a foreign host is satisfied from a local directory once any local root has been recorded. record_local_schema_root records one two ways: at profile.rs:154 it splits a locally loaded $id at /schemas/, counts the segments after it and walks that many ancestors up from the file, and at profile.rs:174 it records any ancestor directory literally named schemas.

Observed. A capability at <dir>/vendor/vmoney.json with $id https://vendor.example.invalid/schemas/vendor/vmoney.json, and a $ref to https://vendor.example.invalid/schemas/common/types/price.json, where that host does not resolve: exit 0, empty stderr, and Price and Amount emitted from <dir>. No ancestor is named schemas in that run, so the first mechanism alone is enough. The same $ref with /notschemas/ gives exit 3 and a fetch error. What gates it is whether the path after /schemas/ exists under a recorded root, not where the capability sits: with no local copy at that depth it falls through to the fetch and exits 3, which is how my first attempt behaved before I put the file there.

Local mode and relocation are deliberate, but one authority is substituted for another with no notice, which matters for the multi origin vendor extension case the PR body names. The prefix before /schemas/ is already in hand at that split and currently discarded; keeping it alongside the recorded root and relocating only URLs that share it looks like a small change.

For what it is worth on the consumer side: across 60 resolve invocations, 30 schemas each from the ucp trees at d9c18e0d and a25a4a24, the main and PR binaries gave byte identical stdout, stderr and exit codes, and --help for validate, resolve, compose and lint is unchanged. Nothing in our repository needs to move for this PR.

@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 802c9a3 to 6e678c1 Compare October 16, 2026 18:26
@gsmith85

Copy link
Copy Markdown
Contributor Author

Thanks for the sharp review, @vishkaty — all four items are now fixed in 6e678c1 (with stacked PRs #86–#89 rebased on top):

  1. Windows CI path assertion (src/codegen/profile.rs): Updated resolve_schema_target_handles_relative_urls_and_local_paths to compare Path::new(...) components rather than POSIX slash string literals.
  2. Discriminating extends scoping test (tests/generate_types_test.rs): Added profile_mode_fixture_closure_and_declared_extends_scoping, which activates two root capabilities (checkout and cart) alongside a multi-target extension whose profile entry declares "extends": "dev.ucp.shopping.checkout". It verifies that Checkout receives the overlay, Cart does not, and inactive local $defs / external $refs under the unextended cart branch are pruned without being crawled.
  3. Self-contained CI profile integration tests (tests/generate_types_test.rs): Both new profile integration tests (profile_mode_fixture_closure_and_declared_extends_scoping and profile_mode_multi_origin_relocates_only_matching_authority) run unconditionally in CI against tests/fixtures/compose/schemas and temporary directories without depending on a sibling ../ucp checkout.
  4. Origin-scoped URL-to-local relocation (src/codegen/profile.rs): LocalSchemaRoot now records the URL origin (scheme://host[:port]) from each local schema's $id, and relocate_url_to_local_schemas only relocates a URL when its origin matches the recorded local root's origin. Added both unit and end-to-end multi-origin tests verifying that foreign vendor /schemas/ URLs never silently resolve to a local https://ucp.dev tree.

@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 6e678c1 to ca2b208 Compare October 16, 2026 22:04
Comment thread src/codegen/hoist.rs
.schema
.get("$id")
.and_then(Value::as_str)
.is_some_and(|id| id.starts_with("https://ucp.dev/"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One edge case to consider here is if the schema file "maliciously" declare the UCP URL in $id to allow their own vendor type to be considered as the core UCP type and be preserved.

Maybe a stronger assertion here should factor in where the LoadedSchema came from - e.g., it can either be via schema-dir in normal local mode OR in the profile mode, the associated URL's domain defined in schema in the profile.

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.

Fixed in 5aa23b8. LoadedSchema now tracks owner_namespace: Option<String> propagated from the authority-validated capability name (or target URL host on external $refs), and is_ucp_owned, derive_vendor_prefix, and record_local_schema_root validate against owner_namespace instead of trusting self-declared $id values.

Comment thread src/codegen/hoist.rs Outdated
let Some(prefix) = derive_vendor_prefix(&loaded[idx]) else {
continue;
};
if stem.starts_with(&prefix) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: This introduces a similar same issue as #83 (comment) where this if statement will get triggered if let's say the prefix resolves to Payment and triggering the exception on name collisions done the line.

Can we also remove this special condition to force a rename?

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.

Fixed in 5aa23b8 — removed the if stem.starts_with(&prefix) { continue; } skip so colliding standalone stems that happen to start with the vendor prefix still get disambiguated.

Comment thread src/codegen/profile.rs Outdated
local_schema_roots: &[LocalSchemaRoot],
) -> Option<String> {
let target_origin = url_origin(url_str)?;
let (_, rel_after_schemas) = url_str.split_once("/schemas/")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This line here purposely drops the version prefix in the UCP URL (e.g., 2026-04-08 will be dropped from https://ucp.dev/2026-04-08/schemas/shopping/types/line_item.json in rel_after_schemas).

When we reconstruct it using the local root below, you would essentially map the schema under the same local file. So in the extension declaration, if let's say a vendor extension declared in the profile references/composes using an older version of the UCP type, they would still get whatever version the local file is on.

Should we include at least the version prefix immediately before the/schemas on top of the origin check before applying relocation (otherwise we should always be forcing a fetch from the URL)?

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.

Fixed in 5aa23b8. LocalSchemaRoot now records version: Option<String> (from the schema $id or capability entry) and relocate_url_to_local_schemas extracts the version segment before /schemas/ via split_url_marker and requires version compatibility before relocating.

@gsmith85
gsmith85 force-pushed the feat/codegen-profile-mode branch from 5aa23b8 to 9140b28 Compare October 21, 2026 01:42
@gsmith85
gsmith85 merged commit 56a3138 into main Oct 21, 2026
19 checks passed
@gsmith85
gsmith85 deleted the feat/codegen-profile-mode branch October 21, 2026 02:15
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.

4 participants