diff --git a/crates/ghostlight-dungeon/src/model_connector.rs b/crates/ghostlight-dungeon/src/model_connector.rs index 3c64c49..90fd39e 100644 --- a/crates/ghostlight-dungeon/src/model_connector.rs +++ b/crates/ghostlight-dungeon/src/model_connector.rs @@ -308,6 +308,11 @@ fn responses_schema_is_strict(schema: &serde_json::Value) -> bool { { return false; } + if map.get("type").and_then(serde_json::Value::as_str) == Some("array") + && !map.contains_key("items") + { + return false; + } if schema_map_describes_object(map) { if map.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) { return false; @@ -896,6 +901,24 @@ mod tests { assert!(error.contains("root must have type object")); } + #[test] + fn strict_schema_projection_rejects_arrays_without_an_item_contract() { + let mut schema = serde_json::json!({ + "type":"object", + "required":["findings"], + "properties":{ + "findings":{"type":"array", "maxItems":0} + }, + "additionalProperties":false + }); + + let error = project_strict_responses_schema(&mut schema) + .unwrap_err() + .to_string(); + + assert!(error.contains("not strict")); + } + #[test] fn attempt_deadline_tracks_the_logical_model_class_not_the_stage_name() -> Result<()> { let port = CodexConnectorModelPort::new( diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 82e32e0..9101e17 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -2143,6 +2143,28 @@ fn cell_effect_verifier_schema(action_count: usize) -> Result let verdict = schema .pointer_mut("/$defs/CellActionEffectVerdict") .ok_or_else(|| anyhow!("cell effect verifier schema has no verdict definition"))?; + let finding_schema = serde_json::json!({ + "type":"object", + "additionalProperties":false, + "required":["mismatch_kind", "repair_guidance"], + "properties":{ + "mismatch_kind":{ + "enum":[ + "subject_swap", + "effect_omission", + "effect_reversal", + "target_substitution", + "invented_outcome", + "wrong_effect_kind" + ] + }, + "repair_guidance":{ + "type":"string", + "minLength":1, + "maxLength":240 + } + } + }); *verdict = serde_json::json!({ "oneOf":[ { @@ -2156,7 +2178,11 @@ fn cell_effect_verifier_schema(action_count: usize) -> Result "maximum":action_count - 1 }, "result":{"const":"match"}, - "findings":{"type":"array", "maxItems":0} + "findings":{ + "type":"array", + "maxItems":0, + "items":finding_schema.clone() + } } }, { @@ -2174,28 +2200,7 @@ fn cell_effect_verifier_schema(action_count: usize) -> Result "type":"array", "minItems":1, "maxItems":6, - "items":{ - "type":"object", - "additionalProperties":false, - "required":["mismatch_kind", "repair_guidance"], - "properties":{ - "mismatch_kind":{ - "enum":[ - "subject_swap", - "effect_omission", - "effect_reversal", - "target_substitution", - "invented_outcome", - "wrong_effect_kind" - ] - }, - "repair_guidance":{ - "type":"string", - "minLength":1, - "maxLength":240 - } - } - } + "items":finding_schema } } } @@ -5047,6 +5052,12 @@ mod tests { schema.pointer("/properties/verdicts/maxItems"), Some(&serde_json::json!(4)) ); + assert!( + schema + .pointer("/$defs/CellActionEffectVerdict/oneOf/0/properties/findings/items") + .is_some(), + "even a zero-length findings branch needs an items schema at the provider boundary" + ); let validator = jsonschema::validator_for(&schema).unwrap(); let faithful = serde_json::json!({ "verdicts":[