diff --git a/gts/src/schema_modifiers.rs b/gts/src/schema_modifiers.rs index a777b70..e1f2ece 100644 --- a/gts/src/schema_modifiers.rs +++ b/gts/src/schema_modifiers.rs @@ -4,6 +4,14 @@ use crate::schema_traits::{X_GTS_TRAITS, X_GTS_TRAITS_SCHEMA}; pub const X_GTS_FINAL: &str = "x-gts-final"; pub const X_GTS_ABSTRACT: &str = "x-gts-abstract"; +/// Marks a type whose derived schemas must resolve to a closed content model +/// at their top level (effective `additionalProperties: false`). Designed for +/// open abstract envelope bases (e.g. extensible metadata): the base stays +/// open so derived schemas can declare payload properties, while this +/// modifier guarantees each derived schema rejects undeclared properties — +/// keeping server-side validation meaningful (a typo'd property name fails +/// instead of being silently accepted). +pub const X_GTS_CLOSED_DERIVATIONS: &str = "x-gts-closed-derivations"; fn contains_key_recursive(value: &Value, key: &str) -> bool { match value { @@ -39,15 +47,32 @@ pub fn validate_schema_modifiers(content: &Value) -> Result<(), String> { None => false, }; + let closed_derivations = match content.get(X_GTS_CLOSED_DERIVATIONS) { + Some(Value::Bool(b)) => *b, + Some(other) => { + return Err(format!( + "{X_GTS_CLOSED_DERIVATIONS} must be a boolean, got {other}" + )); + } + None => false, + }; + if is_final && is_abstract { return Err(format!( "schema cannot declare both {X_GTS_FINAL} and {X_GTS_ABSTRACT} as true" )); } + if is_final && closed_derivations { + return Err(format!( + "schema cannot declare both {X_GTS_FINAL} and {X_GTS_CLOSED_DERIVATIONS} as true: \ + a final type has no derivations to constrain" + )); + } + if let Value::Object(map) = content { for (k, v) in map { - if k == X_GTS_FINAL || k == X_GTS_ABSTRACT { + if k == X_GTS_FINAL || k == X_GTS_ABSTRACT || k == X_GTS_CLOSED_DERIVATIONS { continue; } if contains_key_recursive(v, X_GTS_FINAL) { @@ -56,6 +81,11 @@ pub fn validate_schema_modifiers(content: &Value) -> Result<(), String> { if contains_key_recursive(v, X_GTS_ABSTRACT) { return Err(format!("{X_GTS_ABSTRACT} must be at the schema top level")); } + if contains_key_recursive(v, X_GTS_CLOSED_DERIVATIONS) { + return Err(format!( + "{X_GTS_CLOSED_DERIVATIONS} must be at the schema top level" + )); + } } } diff --git a/gts/src/store.rs b/gts/src/store.rs index 9c95352..b8f3afc 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -456,6 +456,23 @@ impl GtsStore { errors.join("; ") ))); } + + // Check x-gts-closed-derivations: an (open) base may require every + // derived schema to resolve to a closed content model at its own + // top level, so undeclared properties are rejected rather than + // silently accepted (e.g. extensible metadata envelopes). + if base_content.get(crate::schema_modifiers::X_GTS_CLOSED_DERIVATIONS) + == Some(&Value::Bool(true)) + { + let derived_eff = crate::schema_compat::extract_effective_schema(&derived_resolved); + if derived_eff.additional_properties != Some(Value::Bool(false)) { + return Err(StoreError::ValidationError(format!( + "base type '{base_id}' declares x-gts-closed-derivations: derived \ + schema '{derived_id}' must resolve to a closed content model \ + (effective additionalProperties: false) at its top level" + ))); + } + } } Ok(()) diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index d7b6489..cde55a3 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -5910,3 +5910,145 @@ fn test_validate_instance_reports_unresolvable_ref() { .contains("Unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") ); } + +/* ── x-gts-closed-derivations (OP#12) ── */ + +fn closed_derivations_base() -> serde_json::Value { + // Open abstract envelope that requires closed derived schemas — the + // extensible-metadata pattern (e.g. AM tenant metadata). + json!({ + "$id": "gts://gts.x.tcd.meta.env.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": true, + "x-gts-closed-derivations": true, + "additionalProperties": true + }) +} + +#[test] +fn test_closed_derivations_accepts_closed_derived() { + let mut store = GtsStore::new(); + store + .register_schema("gts.x.tcd.meta.env.v1~", &closed_derivations_base()) + .expect("register base"); + + let derived = json!({ + "$id": "gts://gts.x.tcd.meta.env.v1~x.app._.settings.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "automation_level": {"type": "string", "enum": ["manual", "autonomous"]} + }, + "additionalProperties": false + }); + store + .register_schema("gts.x.tcd.meta.env.v1~x.app._.settings.v1~", &derived) + .expect("register derived"); + + store + .validate_schema_chain("gts.x.tcd.meta.env.v1~x.app._.settings.v1~") + .expect("closed derived schema under closed-derivations base must pass"); +} + +#[test] +fn test_closed_derivations_rejects_open_derived() { + let mut store = GtsStore::new(); + store + .register_schema("gts.x.tcd.meta.env.v1~", &closed_derivations_base()) + .expect("register base"); + + // additionalProperties: true — a typo'd property would be accepted. + let derived = json!({ + "$id": "gts://gts.x.tcd.meta.env.v1~x.app._.settings.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "automation_level": {"type": "string"} + }, + "additionalProperties": true + }); + store + .register_schema("gts.x.tcd.meta.env.v1~x.app._.settings.v1~", &derived) + .expect("register derived"); + + let result = store.validate_schema_chain("gts.x.tcd.meta.env.v1~x.app._.settings.v1~"); + assert!(result.is_err(), "open derived schema must be rejected"); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("x-gts-closed-derivations"), + "error should name the modifier: {msg}" + ); +} + +#[test] +fn test_closed_derivations_rejects_unspecified_additional_properties() { + let mut store = GtsStore::new(); + store + .register_schema("gts.x.tcd.meta.env.v1~", &closed_derivations_base()) + .expect("register base"); + + // additionalProperties omitted — open by JSON Schema default, so it must + // be rejected just like an explicit `true`. + let derived = json!({ + "$id": "gts://gts.x.tcd.meta.env.v1~x.app._.settings.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "automation_level": {"type": "string"} + } + }); + store + .register_schema("gts.x.tcd.meta.env.v1~x.app._.settings.v1~", &derived) + .expect("register derived"); + + let result = store.validate_schema_chain("gts.x.tcd.meta.env.v1~x.app._.settings.v1~"); + assert!( + result.is_err(), + "derived schema with default-open content model must be rejected" + ); +} + +#[test] +fn test_closed_derivations_closed_via_allof_conjunct() { + let mut store = GtsStore::new(); + store + .register_schema("gts.x.tcd.meta.env.v1~", &closed_derivations_base()) + .expect("register base"); + + // Closedness contributed by an allOf conjunct (the lattice keeps `false`). + let derived = json!({ + "$id": "gts://gts.x.tcd.meta.env.v1~x.app._.settings.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$ref": "gts://gts.x.tcd.meta.env.v1~"}, + { + "type": "object", + "properties": {"automation_level": {"type": "string"}}, + "additionalProperties": false + } + ] + }); + store + .register_schema("gts.x.tcd.meta.env.v1~x.app._.settings.v1~", &derived) + .expect("register derived"); + + store + .validate_schema_chain("gts.x.tcd.meta.env.v1~x.app._.settings.v1~") + .expect("allOf-closed derived schema must pass"); +} + +#[test] +fn test_closed_derivations_final_combination_rejected() { + use crate::schema_modifiers::validate_schema_modifiers; + let bad = json!({ + "type": "object", + "x-gts-final": true, + "x-gts-closed-derivations": true + }); + assert!( + validate_schema_modifiers(&bad).is_err(), + "final + closed-derivations is meaningless and must be rejected" + ); +}