diff --git a/Cargo.lock b/Cargo.lock index a947ad1..c6fd827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.88.0" +version = "0.88.1" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 0d39b98..c9e9e61 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,27 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.88.1] - 2026-08-22 + +Persistent schema module: **0.63.0** (unchanged from 0.88.0). + +### Fixed + +- The Codex app-server schema projector now preserves bounded nullable scalar + `type` arrays used by the crate-authored FixedBroker definitions. Codex + 0.148.0 accepts these schemas directly; previously the adapter rejected the + discovery and execute definitions before starting a readiness turn. + +### Security + +- Type-array projection is limited to unique combinations of `string`, + `integer`, `number`, `boolean`, and `null`, requires `null` plus at least one + non-null scalar, validates compatible enums and constraints, and still + rejects object, array, unbounded, malformed, or unknown union shapes. + +There is no schema, data, protected-payload, GraphQL SDL, backup or restore +migration in this release. + ## [0.88.0] - 2026-08-22 Persistent schema module: **0.63.0** (unchanged from 0.87.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 4fb2018..6093034 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.88.0" +version = "0.88.1" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index b89476a..649b5fb 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,24 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.88.0 to 0.88.1: Codex FixedBroker schema projection + +Adopt `graphql-orm-ai` 0.88.1 from one reviewed full monorepo revision. The AI +schema module remains **0.63.0**. There is no database, data, table, column, +index, constraint, backfill, GraphQL SDL, protected-payload, backup or restore +migration. + +Hosts using the Codex app-server adapter should rerun readiness against their +complete capability surface. The adapter now projects the crate-authored +FixedBroker discovery, describe, and execute definitions without rewriting +their bounded nullable scalar `type` arrays. Codex 0.148.0 was measured to +accept the preserved form and deliver the offered dynamic tool directly. + +No host API changes are required. Unknown, duplicate, non-nullable, structural, +or otherwise malformed type unions continue to fail closed before a provider +turn. Keep the 0.88.0 closed launch profile, full-surface readiness guard, and +negative native-item checks unchanged. + ## 0.87.0 to 0.88.0: direct GPT-5.6 dynamic tools on Codex 0.148.0 Adopt `graphql-orm-ai` 0.88.0 from one reviewed full monorepo revision. The AI diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 419d215..e4b8849 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.87.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.88.1", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -159,6 +159,13 @@ false. The protocol actor rejects any native item that is nevertheless emitted. Reverify both direct delivery and the negative native-item matrix before upgrading Codex. +The Codex schema projector preserves bounded nullable scalar `type` arrays in +the crate-authored FixedBroker definitions. It does not pass through arbitrary +JSON Schema unions: only unique combinations of supported scalar types plus +`null`, with compatible enums and constraints, are admitted. Full-surface +readiness must project all three FixedBroker definitions before the host is +ready. + See the [session reliability adoption contract](docs/session-reliability-adoption.md). ## Features and capability boundary diff --git a/crates/graphql-orm-ai/src/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 1aa0625..17cf6f1 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -4795,6 +4795,9 @@ fn project_codex_schema_node(schema: &Value, depth: usize) -> Result Result, + schema_types: &[Value], +) -> Result { + if !(2..=5).contains(&schema_types.len()) + || object.keys().any(|key| { + matches!( + key.as_str(), + "properties" + | "required" + | "additionalProperties" + | "items" + | "minItems" + | "maxItems" + | "uniqueItems" + ) + }) + { + return Err(ProviderError::Rejected); + } + let mut unique_types = BTreeSet::new(); + for schema_type in schema_types { + let schema_type = schema_type.as_str().ok_or(ProviderError::Rejected)?; + if !matches!( + schema_type, + "string" | "integer" | "number" | "boolean" | "null" + ) || !unique_types.insert(schema_type) + { + return Err(ProviderError::Rejected); + } + } + if !unique_types.contains("null") || unique_types.len() < 2 { + return Err(ProviderError::Rejected); + } + + let description = object + .get("description") + .map(|value| value.as_str().ok_or(ProviderError::Rejected)) + .transpose()? + .unwrap_or_default(); + validate_codex_schema_description(description)?; + + let mut constraint_notes = Vec::new(); + let minimum_length = optional_u64(object, "minLength")?; + let maximum_length = optional_u64(object, "maxLength")?; + if (minimum_length.is_some() || maximum_length.is_some()) && !unique_types.contains("string") { + return Err(ProviderError::Rejected); + } + if minimum_length + .zip(maximum_length) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + return Err(ProviderError::Rejected); + } + if let Some(minimum) = minimum_length { + constraint_notes.push(format!("minimum length {minimum}")); + } + if let Some(maximum) = maximum_length { + constraint_notes.push(format!("maximum length {maximum}")); + } + + let minimum = optional_number(object, "minimum")?; + let maximum = optional_number(object, "maximum")?; + if (minimum.is_some() || maximum.is_some()) + && !unique_types.contains("integer") + && !unique_types.contains("number") + { + return Err(ProviderError::Rejected); + } + if minimum + .zip(maximum) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + return Err(ProviderError::Rejected); + } + if let Some(minimum) = minimum { + constraint_notes.push(format!("minimum {minimum}")); + } + if let Some(maximum) = maximum { + constraint_notes.push(format!("maximum {maximum}")); + } + + let mut projected = + serde_json::Map::from_iter([("type".to_owned(), Value::Array(schema_types.to_vec()))]); + if let Some(values) = object.get("enum") { + let values = values.as_array().ok_or(ProviderError::Rejected)?; + if values.is_empty() + || values.len() > 100 + || values.iter().any(|value| { + !codex_scalar_union_accepts(value, &unique_types) + || value + .as_str() + .is_some_and(|value| value.is_empty() || value.len() > 200) + }) + { + return Err(ProviderError::Rejected); + } + projected.insert("enum".to_owned(), Value::Array(values.clone())); + } + let projected_description = projected_codex_description(description, &constraint_notes)?; + if !projected_description.is_empty() { + projected.insert( + "description".to_owned(), + Value::String(projected_description), + ); + } + Ok(Value::Object(projected)) +} + +fn codex_scalar_union_accepts(value: &Value, schema_types: &BTreeSet<&str>) -> bool { + match value { + Value::Null => schema_types.contains("null"), + Value::Bool(_) => schema_types.contains("boolean"), + Value::String(_) => schema_types.contains("string"), + Value::Number(number) => { + schema_types.contains("number") + || (schema_types.contains("integer") + && (number.as_i64().is_some() || number.as_u64().is_some())) + } + Value::Array(_) | Value::Object(_) => false, + } +} + fn validate_codex_schema_description(description: &str) -> Result<(), ProviderError> { if description.len() > 2_000 || description @@ -6518,6 +6644,79 @@ pub(crate) mod tests { assert_ne!(projected.fingerprints, substituted_projection.fingerprints); } + #[test] + fn fixed_broker_definitions_project_their_nullable_scalar_unions_for_codex() { + let definitions = crate::capability_broker_definitions(&"a".repeat(64)) + .expect("fixed broker definitions should compile"); + assert_eq!(definitions.len(), 3); + let projected = project_codex_dynamic_tools(&definitions) + .expect("every crate-authored fixed broker schema should project"); + assert_eq!(projected.protocol_values.len(), 3); + + let discover = projected + .protocol_values + .iter() + .find(|tool| tool.get("name") == Some(&json!("graphql_capabilities_discover"))) + .expect("discover projection should exist"); + assert_eq!( + discover.pointer("/inputSchema/properties/namespace/type"), + Some(&json!(["string", "null"])) + ); + assert_eq!( + discover.pointer("/inputSchema/properties/kind/enum"), + Some(&json!(["generated_query", null])) + ); + + let execute = projected + .protocol_values + .iter() + .find(|tool| tool.get("name") == Some(&json!("graphql_capabilities_execute"))) + .expect("execute projection should exist"); + assert_eq!( + execute.pointer("/inputSchema/properties/arguments/items/properties/value/type"), + Some(&json!(["string", "integer", "number", "boolean", "null"])) + ); + assert_eq!( + execute.pointer("/inputSchema/properties/maximumItems/type"), + Some(&json!(["integer", "null"])) + ); + assert!( + execute + .pointer("/inputSchema/properties/maximumItems/description") + .and_then(Value::as_str) + .is_some_and(|description| { + description.contains("minimum 1") && description.contains("maximum 10000") + }) + ); + for tool in &projected.protocol_values { + jsonschema::validator_for(&tool["inputSchema"]) + .expect("projected fixed broker schema should remain valid JSON Schema"); + } + } + + #[test] + fn codex_nullable_scalar_union_projection_rejects_widened_or_malformed_shapes() { + for malformed in [ + json!({"type": ["null"]}), + json!({"type": ["string", "integer"]}), + json!({"type": ["string", "string", "null"]}), + json!({"type": ["object", "null"]}), + json!({ + "type": ["string", "null"], + "properties": {}, + "additionalProperties": false + }), + json!({"type": ["boolean", "null"], "minimum": 1}), + json!({"type": ["integer", "null"], "enum": ["not-an-integer", null]}), + json!({"type": ["string", "null"], "enum": [false, null]}), + ] { + assert!(matches!( + project_codex_schema_node(&malformed, 0), + Err(ProviderError::Rejected) + )); + } + } + #[test] fn finite_relational_query_plan_projects_without_generic_schema_passthrough() { let tool = ModelToolDefinition { @@ -6601,7 +6800,7 @@ pub(crate) mod tests { .and_then(Value::as_str) .is_some_and(|description| description.contains("maximum 25")) ); - assert!(schema.to_string().find("anyOf").is_none()); + assert!(!schema.to_string().contains("anyOf")); } fn named_semantic_type(name: &str, nullable: bool) -> GraphqlSemanticTypeRef { diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 9e4f1be..0ab7a29 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.23.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.88.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.88.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.9.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.23.0` | `crates/graphql-orm-macros` | `sqlite` | none |