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
32 changes: 31 additions & 1 deletion gts/src/schema_modifiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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"
));
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions gts/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
142 changes: 142 additions & 0 deletions gts/src/store_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}