From 67f668233ba197c8a0b4fe70e4f228a6bfb51dc7 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 9 Jul 2026 15:52:20 -0700 Subject: [PATCH] fix: compose extensions when any parent is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UCP intersection algorithm states: “Remove any capability where `extends` is set but none of its parent capabilities are in the intersection.” “For multi-parent extensions (`extends: ["a", "b"]`): at least one parent must be present.” — docs/specification/overview.md, “Intersection Algorithm” The composer instead required every declared parent to be active. Discount extending `[Cart, Checkout]` therefore failed to compose into a Checkout response when Cart was absent. Remove the all-parents-present check and retain root-reachability validation. Extensions with no path to the active root remain rejected. Add Cart and Checkout regressions for root-specific `$defs` selection, parent ordering, orphan rejection, and cycle termination. --- README.md | 2 +- src/compose.rs | 79 +++++++++++++------ tests/cli_test.rs | 50 +++++++++++- .../response_discount_cart_multi_parent.json | 19 +++++ ...sponse_discount_checkout_multi_parent.json | 19 +++++ .../compose/schemas/shopping/cart.json | 15 ++++ .../compose/schemas/shopping/discount.json | 14 +++- 7 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/compose/response_discount_cart_multi_parent.json create mode 100644 tests/fixtures/compose/response_discount_checkout_multi_parent.json create mode 100644 tests/fixtures/compose/schemas/shopping/cart.json diff --git a/README.md b/README.md index affb658..ce9c5c3 100644 --- a/README.md +++ b/README.md @@ -395,7 +395,7 @@ UCP payloads are self-describing — they embed `ucp.capabilities` metadata decl 2. **Extensions** — capabilities with `extends` add fields to the root 3. **Merge** — extensions define their additions in `$defs[root_capability_name]`; the tool composes them via `allOf` -**Graph rules:** exactly one root capability (no `extends`), all `extends` targets must exist in capabilities, all extensions must transitively reach the root. +**Graph rules:** composition receives an already-negotiated active capability set with exactly one root (no `extends`). Each extension must have at least one path through active declared parents that transitively reaches that root; absent alternative parents are ignored. **Schema authoring for extensions:** diff --git a/src/compose.rs b/src/compose.rs index a3ed7a2..335130c 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -77,7 +77,8 @@ pub struct Capability { pub version: String, /// URL to the JSON Schema for this capability. pub schema_url: String, - /// Parent capability names this extends. None for root capabilities. + /// Alternative parent capability paths. During composition, only parents in + /// the active capability set are traversed. None for root capabilities. pub extends: Option>, } @@ -395,12 +396,15 @@ pub fn check_version_constraints( violations } -/// Compose schema from capability declarations. +/// Compose a schema from an already-negotiated active capability set. /// -/// 1. Finds root capability (no extends) -/// 2. Validates graph connectivity +/// 1. Finds the single root capability (no extends) +/// 2. Validates that every extension has at least one active path to the root /// 3. Fetches schemas and extracts $defs[root] entries /// 4. Composes using allOf +/// +/// An extension's `extends` entries are alternative paths. Parents absent from +/// the active set are ignored as long as another active path reaches the root. pub fn compose_schema( capabilities: &[Capability], schema_base: &SchemaBaseConfig, @@ -445,21 +449,9 @@ pub fn compose_schema( } }; - // Validate graph: all extends references must exist in capabilities - for cap in capabilities { - if let Some(parents) = &cap.extends { - for parent in parents { - if !cap_map.contains_key(parent.as_str()) { - return Err(ComposeError::UnknownParent { - extension: cap.name.clone(), - parent: parent.clone(), - }); - } - } - } - } - - // Validate graph connectivity: all extensions must reach root + // Validate active-graph connectivity: every extension needs at least one + // declared parent path that transitively reaches the root. Absent alternative + // parents are ignored by reaches_root. for cap in capabilities { if cap.extends.is_some() && !reaches_root(cap, &cap_map, &root.name) { return Err(ComposeError::OrphanExtension { @@ -714,7 +706,9 @@ fn inline_internal_refs_inner(value: &mut Value, defs: &Value, visited: &mut Has } } -/// Check if a capability transitively reaches the root via extends chain. +/// Check whether any alternative parent path reaches the root through the active set. +/// +/// Parent names absent from `cap_map` are ignored, and cycles terminate via `visited`. fn reaches_root(cap: &Capability, cap_map: &HashMap<&str, &Capability>, root_name: &str) -> bool { let mut visited = HashSet::new(); let mut queue = vec![cap]; @@ -1155,7 +1149,7 @@ mod tests { } #[test] - fn compose_unknown_parent_error() { + fn compose_orphan_extension_error() { let checkout = Capability { name: "dev.ucp.shopping.checkout".to_string(), version: "2026-01-11".to_string(), @@ -1171,7 +1165,12 @@ mod tests { let config = SchemaBaseConfig::default(); let result = compose_schema(&[checkout, discount], &config); - assert!(matches!(result, Err(ComposeError::UnknownParent { .. }))); + assert!(matches!( + result, + Err(ComposeError::OrphanExtension { extension, root }) + if extension == "dev.ucp.shopping.discount" + && root == "dev.ucp.shopping.checkout" + )); } #[test] @@ -1293,6 +1292,42 @@ mod tests { )); } + #[test] + fn reaches_root_terminates_on_cycle() { + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + extends: Some(vec!["com.example.loyalty".to_string()]), + }; + let loyalty = Capability { + name: "com.example.loyalty".to_string(), + version: "2026-01-11".to_string(), + schema_url: "loyalty.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.discount".to_string()]), + }; + + let cap_map: HashMap<&str, &Capability> = vec![ + ("dev.ucp.shopping.checkout", &checkout), + ("dev.ucp.shopping.discount", &discount), + ("com.example.loyalty", &loyalty), + ] + .into_iter() + .collect(); + + assert!(!reaches_root( + &discount, + &cap_map, + "dev.ucp.shopping.checkout" + )); + } + #[test] fn capability_short_name_extracts_last_segment() { assert_eq!( diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 2ce76c4..cd377db 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -1355,9 +1355,9 @@ mod compose { } #[test] - fn unknown_parent_error() { + fn orphan_extension_error() { let dir = TempDir::new().unwrap(); - // Extension references parent not in capabilities (but has a root) + // Extension has no active path to the declared root. let payload = write_temp_file( &dir, "payload.json", @@ -1390,7 +1390,9 @@ mod compose { ]) .assert() .code(2) - .stderr(predicate::str::contains("unknown parent")); + .stderr(predicate::str::contains( + "extension 'dev.ucp.shopping.discount' does not connect to root 'dev.ucp.shopping.checkout'", + )); } #[test] @@ -1491,6 +1493,48 @@ mod compose_command { ); } + #[test] + fn compose_multi_parent_selects_checkout_def_with_absent_cart() { + let assert = cmd() + .args([ + "compose", + "tests/fixtures/compose/response_discount_checkout_multi_parent.json", + "--schema-local-base", + "tests/fixtures/compose", + ]) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let all_of = schema["allOf"].as_array().unwrap(); + + assert_eq!(all_of.len(), 1); + assert_eq!(all_of[0]["title"], "Checkout with Discount"); + assert!(all_of[0]["properties"]["line_items"].is_object()); + } + + #[test] + fn compose_multi_parent_selects_cart_def_with_absent_checkout() { + let assert = cmd() + .args([ + "compose", + "tests/fixtures/compose/response_discount_cart_multi_parent.json", + "--schema-local-base", + "tests/fixtures/compose", + ]) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let all_of = schema["allOf"].as_array().unwrap(); + + assert_eq!(all_of.len(), 1); + assert_eq!(all_of[0]["title"], "Cart with Discount"); + assert!(all_of[0]["properties"]["items"].is_object()); + } + #[test] fn compose_needs_no_direction_or_op() { // compose is pure composition — no --op, no --request/--response needed diff --git a/tests/fixtures/compose/response_discount_cart_multi_parent.json b/tests/fixtures/compose/response_discount_cart_multi_parent.json new file mode 100644 index 0000000..d1d9988 --- /dev/null +++ b/tests/fixtures/compose/response_discount_cart_multi_parent.json @@ -0,0 +1,19 @@ +{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": ["dev.ucp.shopping.cart", "dev.ucp.shopping.checkout"] + } + ] + } + } +} diff --git a/tests/fixtures/compose/response_discount_checkout_multi_parent.json b/tests/fixtures/compose/response_discount_checkout_multi_parent.json new file mode 100644 index 0000000..0dd4c71 --- /dev/null +++ b/tests/fixtures/compose/response_discount_checkout_multi_parent.json @@ -0,0 +1,19 @@ +{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": ["dev.ucp.shopping.cart", "dev.ucp.shopping.checkout"] + } + ] + } + } +} diff --git a/tests/fixtures/compose/schemas/shopping/cart.json b/tests/fixtures/compose/schemas/shopping/cart.json new file mode 100644 index 0000000..f6b66c7 --- /dev/null +++ b/tests/fixtures/compose/schemas/shopping/cart.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/cart.json", + "name": "dev.ucp.shopping.cart", + "version": "2026-01-11", + "title": "Cart", + "description": "Minimal cart schema for testing composition.", + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string" }, + "items": { "type": "array" }, + "ucp": { "type": "object", "additionalProperties": true } + } +} diff --git a/tests/fixtures/compose/schemas/shopping/discount.json b/tests/fixtures/compose/schemas/shopping/discount.json index 3c53478..3f15ccf 100644 --- a/tests/fixtures/compose/schemas/shopping/discount.json +++ b/tests/fixtures/compose/schemas/shopping/discount.json @@ -4,7 +4,7 @@ "name": "dev.ucp.shopping.discount", "version": "2026-01-11", "title": "Discount Extension", - "description": "Extends Checkout with discount code support.", + "description": "Extends Cart and Checkout with discount code support.", "$defs": { "dev.ucp.shopping.checkout": { "title": "Checkout with Discount", @@ -44,6 +44,18 @@ } } } + }, + "dev.ucp.shopping.cart": { + "title": "Cart with Discount", + "description": "Cart extended with discount capability.", + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string" }, + "items": { "type": "array" }, + "ucp": { "type": "object", "additionalProperties": true }, + "discounts": { "type": "object" } + } } } }