Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
79 changes: 57 additions & 22 deletions src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
}

Expand Down Expand Up @@ -397,12 +398,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,
Expand Down Expand Up @@ -447,21 +451,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 {

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.

consider cleaning up this error variant from src/error.rs if, after this PR, it's not referenced elsewhere

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 {
Expand Down Expand Up @@ -716,7 +708,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];
Expand Down Expand Up @@ -1283,7 +1277,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(),
Expand All @@ -1299,7 +1293,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]
Expand Down Expand Up @@ -1421,6 +1420,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!(
Expand Down
50 changes: 47 additions & 3 deletions tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/compose/response_discount_cart_multi_parent.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
}
}
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
}
}
15 changes: 15 additions & 0 deletions tests/fixtures/compose/schemas/shopping/cart.json
Original file line number Diff line number Diff line change
@@ -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 }
}
}
14 changes: 13 additions & 1 deletion tests/fixtures/compose/schemas/shopping/discount.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
}
}
}
}
Loading