From 9a813c9d23b1c856c1d3ead970f780aad50cc9fb Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 1 Sep 2026 14:07:40 +1000 Subject: [PATCH] fix(ai): admit bounded warnings while resuming --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 22 +++ crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 17 ++ crates/graphql-orm-ai/README.md | 9 +- .../docs/implementation-status.md | 6 +- .../src/providers/codex_app_server.rs | 179 ++++++++++++++++-- docs/reference/workspace-packages.md | 2 +- 8 files changed, 218 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ab4fec..2921f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.13" +version = "0.95.14" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 21b0798..f52733c 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,28 @@ 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.95.14] - 2026-09-01 + +Persistent schema module: **0.64.0** (unchanged from 0.95.13). + +### Fixed + +- The strict Codex actor now admits the documented generic `warning` + notification during an exact retained `thread/resume` handshake as well as + during a correlated turn. Warning content is discarded, count and bytes are + bounded independently for each resume or turn window, an optional thread ID + must match, and the notification never advances resume readiness. + +### Security + +- Warnings remain rejected during new-thread creation, deletion, state-index + scans, and idle periods. A retained turn still requires the correlated + resume response plus matching started notification, or the reviewed + content-free usage fallback. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.95.13] - 2026-09-01 Persistent schema module: **0.64.0** (unchanged from 0.95.12). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index f870f6f..08eb9fc 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.95.13" +version = "0.95.14" 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 bde4c3d..344e678 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,23 @@ 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.95.13 to 0.95.14: retained-resume warning interleaving + +Adopt `graphql-orm-ai` 0.95.14 from one reviewed full monorepo revision. A host +waiting for `retained_resume_ready` may receive +`AiCodexAppServerInbound::RuntimeWarning` while the exact `thread/resume` +handshake is pending. Treat it as a content-free diagnostic and continue +reading; it is not resume evidence and does not replace the response, matching +started notification, or reviewed usage fallback. + +The actor discards warning text, applies independent count and byte ceilings +to each resume or turn window, and rejects mismatched-thread, malformed, +new-thread, deletion, state-index, and idle warnings. No host configuration or +public data model changes. + +The AI schema module remains **0.64.0**. There is no database, data, GraphQL +SDL, protected-payload, backup, restore, or data backfill migration. + ## 0.95.12 to 0.95.13: retained web-search adoption on a fresh process Adopt `graphql-orm-ai` 0.95.13 from one reviewed full monorepo revision. No diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index c20ba44..da1cbb7 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.95.13", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.14", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -224,6 +224,13 @@ thread state, supplied as a trusted model instruction, and independently enforced by the actor. Reverify both direct delivery and the negative native- item matrix before upgrading Codex. +Codex may emit its documented generic `warning` notification while a retained +thread is resuming. The actor accepts it only inside the exact protected +resume window or a correlated turn, discards its message, and enforces strict +per-window count and byte limits. It does not satisfy resume readiness; hosts +must continue reading until the correlated response and started notification, +or the reviewed content-free usage fallback, complete the lifecycle. + 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 diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 382418c..6dd22a2 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,7 +10,7 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.95.13` with AI schema module +`graphql-orm-ai` is at crate version `0.95.14` with AI schema module `0.64.0`. It uses workspace `graphql-orm` `0.30.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.10.3`, and external `agql-auth` `0.19.0` at `1d2e9fe2e1576105212a7b340a11abf8cad0382d`. @@ -60,7 +60,9 @@ verification evidence belongs in the focused guides. notification in either order, or the reviewed content-free usage-snapshot fallback. A fresh process actor adopts the exact retained web-search policy while encoding `thread/resume`; an actor with existing retained state still - requires exact policy equality. A typed failure proven before business + requires exact policy equality. Content-discarded, flood-bounded generic + warnings may interleave only within that exact resume window or a correlated + turn and never become readiness evidence. A typed failure proven before business `turn/start` releases its reservation, fences cleanup, and remains safely retryable; later failures retain uncertain-effect recovery. Retained developer instructions are compile-time static, registration-fingerprinted, 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 598e173..181e82a 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -43,8 +43,8 @@ const MAXIMUM_BOOTSTRAP_INSTRUCTION_BYTES: usize = 64 * 1024; const MAXIMUM_IDENTIFIER_BYTES: usize = 200; const MAXIMUM_VERSION_BYTES: usize = 200; const MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES: usize = 4 * 1024; -const MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN: usize = 16 * 1024; -const MAXIMUM_RUNTIME_WARNINGS_PER_TURN: usize = 8; +const MAXIMUM_RUNTIME_WARNING_BYTES_PER_WINDOW: usize = 16 * 1024; +const MAXIMUM_RUNTIME_WARNINGS_PER_WINDOW: usize = 8; const MAXIMUM_CAPABILITY_SESSION_BINDINGS: usize = 256; const MAXIMUM_WEB_SEARCH_RESULTS_PER_CALL: usize = 100; const MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL: usize = 1024 * 1024; @@ -3530,7 +3530,8 @@ enum ThreadLifecycleOperation { /// failure may be followed in a separate fresh actor by the exact bounded, /// content-discarding active-and-archived state-index absence scan. A /// documented generic `warning` is admitted only as a content-free, -/// turn-correlated, flood-bounded control event. Empty reasoning-item +/// exact-resume- or turn-correlated, flood-bounded control event. A warning +/// never advances resume readiness. Empty reasoning-item /// lifecycles are admitted only as content-free signals while turn-level /// reasoning summaries remain explicitly disabled. All other server-initiated /// requests and non-allowlisted notifications fail closed. @@ -3738,6 +3739,8 @@ impl AiCodexAppServerProtocolActor { self.thread_lifecycle_phase = ThreadLifecyclePhase::AwaitingResponseAndStarted; self.thread_lifecycle_operation = Some(ThreadLifecycleOperation::Start); self.retained_usage_snapshot_observed = false; + self.runtime_warning_count = 0; + self.runtime_warning_bytes = 0; } fn begin_resume_lifecycle(&mut self, thread_id: &str) { @@ -3745,6 +3748,8 @@ impl AiCodexAppServerProtocolActor { self.thread_lifecycle_phase = ThreadLifecyclePhase::AwaitingResponseAndStarted; self.thread_lifecycle_operation = Some(ThreadLifecycleOperation::Resume); self.retained_usage_snapshot_observed = false; + self.runtime_warning_count = 0; + self.runtime_warning_bytes = 0; } /// Encodes an ephemeral thread start with trusted instructions kept in the @@ -5099,17 +5104,38 @@ impl AiCodexAppServerProtocolActor { .runtime_warning_bytes .checked_add(message_bytes) .ok_or(ProviderError::Rejected)?; - if notification.method != RUNTIME_WARNING - || !self.initialization_complete - || self.thread_lifecycle_phase != ThreadLifecyclePhase::Complete - || self.pending_turn_thread_id.as_deref() != Some(active_thread_id) - || self.deleting_thread_id.is_some() - || (!self + let pending_resume_response = self + .pending + .values() + .any(|method| *method == ClientMethod::ThreadResume); + let resume_correlated = self.thread_lifecycle_operation + == Some(ThreadLifecycleOperation::Resume) + && self.pending_turn_thread_id.is_none() + && self.active_turn_id.is_none() + && match self.thread_lifecycle_phase { + ThreadLifecyclePhase::AwaitingResponseAndStarted + | ThreadLifecyclePhase::AwaitingResponse => { + pending_resume_response && self.pending.len() == 1 + } + ThreadLifecyclePhase::AwaitingStarted => self.pending.is_empty(), + ThreadLifecyclePhase::Complete => { + self.retained_usage_snapshot_observed && self.pending.is_empty() + } + ThreadLifecyclePhase::Ready | ThreadLifecyclePhase::Deleted => false, + }; + let turn_correlated = self.thread_lifecycle_phase == ThreadLifecyclePhase::Complete + && self.pending_turn_thread_id.as_deref() == Some(active_thread_id) + && (self .pending .values() .any(|method| *method == ClientMethod::TurnStart) - && !self.turn_response_observed - && !self.turn_started_observed) + || self.turn_response_observed + || self.turn_started_observed); + if notification.method != RUNTIME_WARNING + || !self.initialization_complete + || self.deleting_thread_id.is_some() + || self.thread_absence_scan.is_some() + || (!resume_correlated && !turn_correlated) || params .thread_id .as_deref() @@ -5117,8 +5143,8 @@ impl AiCodexAppServerProtocolActor { || params.message.trim().is_empty() || message_bytes > MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES || params.message.chars().any(char::is_control) - || self.runtime_warning_count >= MAXIMUM_RUNTIME_WARNINGS_PER_TURN - || next_bytes > MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN + || self.runtime_warning_count >= MAXIMUM_RUNTIME_WARNINGS_PER_WINDOW + || next_bytes > MAXIMUM_RUNTIME_WARNING_BYTES_PER_WINDOW { return Err(ProviderError::Rejected); } @@ -11613,7 +11639,7 @@ pub(crate) mod tests { )); let mut count_limited = active_protocol_actor(); - for _ in 0..MAXIMUM_RUNTIME_WARNINGS_PER_TURN { + for _ in 0..MAXIMUM_RUNTIME_WARNINGS_PER_WINDOW { assert!(matches!( count_limited.accept(&runtime_warning_notification(None, "bounded")), Ok(AiCodexAppServerInbound::RuntimeWarning) @@ -11626,7 +11652,8 @@ pub(crate) mod tests { let mut byte_limited = active_protocol_actor(); let maximum_message = "x".repeat(MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES); - for _ in 0..(MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN / MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES) + for _ in + 0..(MAXIMUM_RUNTIME_WARNING_BYTES_PER_WINDOW / MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES) { assert!(matches!( byte_limited.accept(&runtime_warning_notification(None, &maximum_message)), @@ -12573,6 +12600,128 @@ pub(crate) mod tests { )); } + #[test] + fn protocol_admits_bounded_runtime_warning_in_every_exact_resume_interleaving() { + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("cursor should validate"); + let warning = runtime_warning_notification( + Some("thread-retained-1"), + "A bounded runtime feature remains unavailable.", + ); + + let mut warning_first = initialized_protocol_actor(); + warning_first + .resume_thread(&cursor, &turn()) + .expect("warning-first resume should begin"); + assert!(matches!( + warning_first.accept(&warning), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + assert!(!warning_first.retained_resume_ready(&cursor)); + warning_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("response should remain required after warning"); + assert!(!warning_first.retained_resume_ready(&cursor)); + warning_first + .accept(&thread_started_notification("thread-retained-1")) + .expect("started notification should complete warning-first resume"); + assert!(warning_first.retained_resume_ready(&cursor)); + + let mut response_first = initialized_protocol_actor(); + response_first + .resume_thread(&cursor, &turn()) + .expect("response-first resume should begin"); + response_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("response should bind"); + assert!(matches!( + response_first.accept(&runtime_warning_without_timestamp( + None, + "The schema permits this warning timestamp to be absent.", + )), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + assert!(!response_first.retained_resume_ready(&cursor)); + response_first + .accept(&thread_started_notification("thread-retained-1")) + .expect("started notification should remain required"); + assert!(response_first.retained_resume_ready(&cursor)); + + let mut started_first = initialized_protocol_actor(); + started_first + .resume_thread(&cursor, &turn()) + .expect("started-first resume should begin"); + started_first + .accept(&thread_started_notification("thread-retained-1")) + .expect("started notification should bind first"); + assert!(matches!( + started_first.accept(&warning), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + assert!(!started_first.retained_resume_ready(&cursor)); + started_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("response should remain required after warning"); + assert!(started_first.retained_resume_ready(&cursor)); + } + + #[test] + fn protocol_keeps_resume_warnings_closed_outside_the_exact_resume_window() { + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("cursor should validate"); + let warning = runtime_warning_notification( + Some("thread-retained-1"), + "A bounded runtime feature remains unavailable.", + ); + + let mut mismatched = initialized_protocol_actor(); + mismatched + .resume_thread(&cursor, &turn()) + .expect("mismatch fixture should begin resume"); + assert!(matches!( + mismatched.accept(&runtime_warning_notification( + Some("thread-other"), + "A warning for another thread must not cross this window.", + )), + Err(ProviderError::Rejected) + )); + assert!(!mismatched.retained_resume_ready(&cursor)); + + let mut new_thread = initialized_protocol_actor(); + new_thread + .start_persistent_empty_thread( + "model-1", + ModelReasoningEffort::Unspecified, + &trusted_bootstrap(), + &[], + ) + .expect("new-thread fixture should begin"); + assert!(matches!( + new_thread.accept(&runtime_warning_notification(None, "Not a resume window.")), + Err(ProviderError::Rejected) + )); + + let mut deleting = initialized_protocol_actor(); + deleting + .resume_thread(&cursor, &turn()) + .expect("delete fixture resume should begin"); + deleting + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("delete fixture response should bind"); + deleting + .accept(&thread_started_notification("thread-retained-1")) + .expect("delete fixture started should bind"); + deleting + .delete_thread(&cursor) + .expect("delete fixture should enter deletion window"); + assert!(matches!( + deleting.accept(&warning), + Err(ProviderError::Rejected) + )); + } + #[test] fn protocol_accepts_timestamped_thread_started_in_either_correlated_order() { let mut response_first = initialized_protocol_actor(); diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index e4da89a..5967426 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.30.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.95.13` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.95.14` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.3` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none |