From bf562303fd56c1419f243f8932e66825ecec06bc Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 28 Aug 2026 16:56:45 +1000 Subject: [PATCH] fix(ai): keep retained cleanup upgrade-stable --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 24 +++ crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 20 +++ crates/graphql-orm-ai/README.md | 15 +- .../provider-sessions-and-hosted-activity.md | 12 +- .../src/providers/codex_app_server.rs | 150 +++++++++++++----- docs/reference/workspace-packages.md | 2 +- 8 files changed, 178 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16fc402..0f6d35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.9" +version = "0.95.10" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 3147150..7959241 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,30 @@ 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.10] - 2026-08-28 + +Persistent schema module: **0.64.0** (unchanged from 0.95.9). + +### Fixed + +- Retained Codex cleanup now uses the stable provider profile and versioned + cursor kind as its provider-state namespace. Older protected threads can be + deleted after model, executable, sandbox, bootstrap, tool, reasoning, + registration-fingerprint, or wire-profile changes instead of remaining in + cleanup backoff. + +### Security + +- Mutable registration fields remain mandatory for create, resume, and every + turn. Cleanup requests still originate only after the protected cursor's + authenticated binding to the complete durable descriptor and cursor + fingerprint is verified. The deletion adapter rejects a different provider + family, provider profile, or cursor kind before launching a process, and the + exact deletion/absence actor remains unchanged and fail-closed. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.95.9] - 2026-08-28 Persistent schema module: **0.64.0** (unchanged from 0.95.8). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 6f40e53..48e1c53 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.9" +version = "0.95.10" 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 fc973f3..fc2d818 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,26 @@ 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.9 to 0.95.10: stable Codex cleanup namespace + +Adopt `graphql-orm-ai` 0.95.10 from one reviewed full monorepo revision. No host +API or configuration change is required when the existing provider profile ID +continues to identify the same Codex state store. Cleanup now admits an older +durable descriptor by the stable local-provider profile and versioned cursor +kind after the protected cursor has been authenticated against that complete +descriptor. Mutable model, executable, sandbox, bootstrap, tool, reasoning, +registration-fingerprint, and app-server protocol values no longer strand +provider resources after an upgrade. + +Those mutable values remain exact admission fences for empty-thread creation, +resume, and every turn. Cross-provider, cross-profile, and cross-cursor-kind +cleanup remains rejected before process launch. Hosts must not reuse one +provider profile ID for different state stores; drain retained sessions before +renaming a profile or moving its state namespace. + +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.8 to 0.95.9: provider-native retained-thread absence proof Adopt `graphql-orm-ai` 0.95.9 from one reviewed full monorepo revision. Existing diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 88696e2..50ff184 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.9", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.10", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -65,7 +65,9 @@ the compiled test-backed recipe and the missing reusable bootstrap API. discarding the host's durable conversation history. If deletion returns an unstructured provider error, a second fresh process may instead prove absence by exhaustively scanning the bounded active and archived state index. Raw - thread metadata is discarded inside the protocol actor. + thread metadata is discarded inside the protocol actor. Cleanup remains + available across mutable registration, model, and wire-profile upgrades when + the host preserves the same provider-state profile and versioned cursor kind. - Watermark-bounded, contiguous durable session and owner-inbox replay whose `HasMore` contract remains correct at the configured ORM page maximum. - Default-deny application tools with server-authored documents and static @@ -155,6 +157,15 @@ Invalidating a retained provider thread emits `provider_session_reset` or reset even though the durable transcript reads as continuous. Codex cleanup treats a successful empty `thread/delete` result as authoritative. +The provider profile ID and versioned cursor kind form its stable provider-state +namespace. A protected cursor is opened only after its authenticated binding to +the durable descriptor is verified, so mutable model, executable, tool, +registration-fingerprint, and protocol changes cannot strand an old resource. +Those mutable fields still fence every create, resume, and turn. A different +provider family, profile ID, or cursor kind remains rejected before launch. +Hosts must preserve one profile ID only for one provider state store; moving or +renaming that namespace requires draining its retained sessions first. + An ambiguous delete failure is never interpreted by error message. The reviewed fallback launches a separate initialized process and scans every source kind in both active and archived `thread/list` state-DB partitions with stable creation diff --git a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md index 580eac1..61cf141 100644 --- a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md +++ b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md @@ -106,9 +106,15 @@ Use `fingerprint`, not `registration.identity()`, in `ModelRequest` and matching `AiBudgetReservationRequest` carries `selected_effort`. A changed setting cannot resume the cursor: clean up and confirm exact absence, issue the ordinary rebind, create a new empty thread, -and bind the new effort fingerprint. Pre-0.80 v3 registration fingerprints -are accepted only by the deletion adapter for draining; they are never valid -for a turn or resume. +and bind the new effort fingerprint. Cleanup deliberately does not reuse this +mutable execution fingerprint as its namespace. After the durable service +authenticates the protected cursor against the complete stored descriptor, the +deletion adapter admits only the same local provider family, provider profile +ID, and versioned cursor kind. Model, executable, sandbox, bootstrap, tool, +reasoning, registration-fingerprint, and wire-profile changes therefore cannot +strand an older resource, while every create, resume, and turn remains fenced +by their exact current values. A host must preserve one provider profile ID for +one provider state store and drain sessions before moving or renaming it. A retained capability-delivery descriptor persists the complete `AiProviderCapabilitySessionBinding::fingerprint()` instead of that raw value. 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 941eda2..80f675a 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -685,9 +685,9 @@ impl AiCodexAppServerRegistration { .is_some_and(|binding| binding.reasoning_effort() == effort) } - fn admits_cleanup_fingerprint(&self, fingerprint: &str) -> bool { - self.admits_provider_session_fingerprint(fingerprint) - || legacy_registration_identity_v3(self) == fingerprint + fn owns_cleanup_namespace(&self, descriptor: &crate::AiProviderSessionDescriptor) -> bool { + descriptor.provider_kind() == &ProviderKind::LocalHarness + && descriptor.provider_profile_id() == self.provider_profile_id } fn reasoning_effort_capabilities(&self) -> ProviderCapabilities { @@ -1282,13 +1282,7 @@ impl crate::AiProviderSessionDeletionService for AiCodexAppServerProviderSession request: &crate::AiProviderSessionDeletionRequest, ) -> Result { let descriptor = request.claim().descriptor(); - if descriptor.provider_kind() != &ProviderKind::LocalHarness - || descriptor.provider_profile_id() != self.registration.provider_profile_id() - || descriptor.provider_model() != self.registration.logical_model() - || !self - .registration - .admits_cleanup_fingerprint(descriptor.registration_fingerprint()) - || descriptor.protocol_version() != self.registration.protocol_version() + if !self.registration.owns_cleanup_namespace(descriptor) || request.cursor().kind() != "codex.app_server.thread.v2" { return Err(ProviderError::Rejected); @@ -6393,28 +6387,6 @@ fn registration_identity(registration: &AiCodexAppServerRegistration) -> String hex::encode(hasher.finalize()) } -fn legacy_registration_identity_v3(registration: &AiCodexAppServerRegistration) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"graphql-orm-ai/codex-app-server-registration/v3\0"); - for value in [ - registration.provider_profile_id.as_str(), - registration.logical_model.as_str(), - registration.executable_sha256.as_str(), - registration.executable_version.as_str(), - registration.sandbox_profile.as_str(), - registration.protocol_version.as_str(), - ] { - hasher.update((value.len() as u64).to_be_bytes()); - hasher.update(value.as_bytes()); - } - let profile = registration.launch_profile.identity_label(); - hasher.update((profile.len() as u64).to_be_bytes()); - hasher.update(profile.as_bytes()); - hasher.update((registration.bootstrap_instructions.fingerprint().len() as u64).to_be_bytes()); - hasher.update(registration.bootstrap_instructions.fingerprint().as_bytes()); - hex::encode(hasher.finalize()) -} - #[cfg(test)] pub(crate) mod tests { use std::io::{BufRead, BufReader, Write}; @@ -6426,7 +6398,8 @@ pub(crate) mod tests { use std::thread::{self, JoinHandle}; use agql_auth::{ - AccessTokenMetadata, AuthPrincipal, AuthUser, PrincipalReference, SessionContext, + AccessTokenMetadata, AuthPrincipal, AuthUser, FixedClock, PrincipalReference, + SessionContext, }; use futures::stream; use graphql_orm::graphql::orm::{ @@ -6445,8 +6418,9 @@ pub(crate) mod tests { use crate::{ AiGraphqlMutationCapabilityCatalog, AiGraphqlQueryCapabilityCatalog, AiGraphqlQueryCapabilityLimits, AiGraphqlSubscriptionCapabilityCatalog, - AiGraphqlSubscriptionCapabilityLimits, AiRunId, AiSessionId, AiToolCatalog, AiToolId, - GraphqlExecutionTargetId, ProviderDynamicToolResult, ProviderEvent, + AiGraphqlSubscriptionCapabilityLimits, AiProviderSessionDeletionService, AiRunId, AiScope, + AiSessionId, AiToolCatalog, AiToolId, GraphqlExecutionTargetId, ProviderDynamicToolResult, + ProviderEvent, }; use uuid::Uuid; @@ -7411,10 +7385,6 @@ pub(crate) mod tests { }) .collect::>(); assert_eq!(fingerprints.len(), efforts.len()); - let legacy = legacy_registration_identity_v3(&reviewed); - assert!(!reviewed.admits_provider_session_fingerprint(&legacy)); - assert!(reviewed.admits_cleanup_fingerprint(&legacy)); - let subset = ModelReasoningEffortProfile::new( "model-1", [ModelReasoningEffort::Low, ModelReasoningEffort::Medium], @@ -7463,7 +7433,6 @@ pub(crate) mod tests { binding.fingerprint(), ModelReasoningEffort::Unspecified, )); - assert!(admitted.admits_cleanup_fingerprint(binding.fingerprint())); let changed = admitted.with_bootstrap_instructions(trusted_bootstrap()); assert!(!changed.admits_provider_session_fingerprint(binding.fingerprint())); @@ -9483,6 +9452,106 @@ pub(crate) mod tests { ); } + fn cleanup_request( + descriptor: crate::AiProviderSessionDescriptor, + cursor: crate::AiProviderSessionCursor, + ) -> crate::AiProviderSessionDeletionRequest { + let now = time::OffsetDateTime::now_utc(); + crate::AiProviderSessionDeletionRequest::new( + crate::AiProviderSessionCleanupClaim { + binding_id: Uuid::new_v4(), + session_id: AiSessionId(Uuid::new_v4()), + scope: AiScope::new("project", "cleanup-test"), + descriptor, + cleanup_worker_id: "cleanup-worker".to_owned(), + cleanup_generation: 1, + cleanup_expires_at: now + time::Duration::minutes(1), + row_version: 1, + }, + cursor, + ) + } + + #[tokio::test] + async fn detached_cleanup_uses_stable_profile_and_cursor_namespace() { + let registration = registration("2.0.0"); + let counters = Arc::new(Counters::new()); + let provider = + AiCodexAppServerProvider::new(registration.clone(), pool(counters.clone(), 2, 4)); + let now = time::OffsetDateTime::now_utc(); + let deletion = provider.provider_session_deletion_service(Arc::new(FixedClock::new(now))); + let cursor = crate::AiProviderSessionCursor::new( + "codex.app_server.thread.v2", + "thread-retained-test", + ) + .expect("retired cursor should validate"); + let retired = crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + "retired-model", + "f".repeat(64), + "retired-app-server-protocol", + "e".repeat(64), + ) + .expect("retired descriptor should validate"); + + deletion + .delete_or_confirm_absent(&cleanup_request(retired, cursor.clone())) + .await + .expect("the stable provider-state namespace should admit cleanup"); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + assert_eq!(counters.deleted_threads.load(Ordering::SeqCst), 1); + + for rejected in [ + crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + "another-profile", + registration.logical_model(), + "a".repeat(64), + registration.protocol_version(), + "e".repeat(64), + ) + .expect("cross-profile descriptor should validate"), + crate::AiProviderSessionDescriptor::new( + ProviderKind::OpenAi, + registration.provider_profile_id(), + registration.logical_model(), + "a".repeat(64), + registration.protocol_version(), + "e".repeat(64), + ) + .expect("cross-provider descriptor should validate"), + ] { + assert!(matches!( + deletion + .delete_or_confirm_absent(&cleanup_request(rejected, cursor.clone())) + .await, + Err(ProviderError::Rejected) + )); + } + let wrong_cursor = + crate::AiProviderSessionCursor::new("another.thread.kind", "thread-retained-test") + .expect("cross-kind cursor should validate"); + let current = crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.logical_model(), + registration + .provider_session_fingerprint(ModelReasoningEffort::Unspecified) + .expect("current fingerprint should validate"), + registration.protocol_version(), + "e".repeat(64), + ) + .expect("current descriptor should validate"); + assert!(matches!( + deletion + .delete_or_confirm_absent(&cleanup_request(current, wrong_cursor)) + .await, + Err(ProviderError::Rejected) + )); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn empty_retained_thread_binds_exact_dynamic_definitions_before_content() { let counters = Arc::new(Counters::new()); @@ -9687,7 +9756,6 @@ pub(crate) mod tests { assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 1); assert_eq!(counters.deleted_threads.load(Ordering::SeqCst), 1); - assert!(registration.admits_cleanup_fingerprint(descriptor.registration_fingerprint())); } #[tokio::test] diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index bc1f688..b95893b 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.27.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.95.9` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.95.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.2` | `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.27.0` | `crates/graphql-orm-macros` | `sqlite` | none |