From d3e28b38c5b755aebed5a884ff0504edf49fc135 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 1 Sep 2026 09:04:27 +1000 Subject: [PATCH] fix(ai): rebind absence-proven provider sessions --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 28 ++++++++++++- crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 21 +++++++++- crates/graphql-orm-ai/README.md | 10 +++-- .../provider-sessions-and-hosted-activity.md | 18 ++++++--- .../src/orm_provider_session.rs | 10 ++++- crates/graphql-orm-ai/src/provider_calls.rs | 16 ++++++-- crates/graphql-orm-ai/src/provider_session.rs | 11 ++++-- .../graphql-orm-ai/tests/provider_sessions.rs | 39 +++++++------------ docs/reference/workspace-packages.md | 2 +- 11 files changed, 111 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 304f4c8..0c3f0be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.10" +version = "0.95.11" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 7959241..6e74360 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-28 +last_reviewed: 2026-09-01 review_by: 2027-02-01 supersedes: [] --- @@ -18,6 +18,32 @@ 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.11] - 2026-09-01 + +Persistent schema module: **0.64.0** (unchanged from 0.95.10). + +### Fixed + +- An absence-proven deleted provider-session generation can now rebind under + the current server-authored provider, registration, policy, model, protocol, + and capability descriptor. A reviewed runtime change no longer strands the + durable application session behind its historical descriptor. +- Losing the replacement compare-and-swap discards the fresh empty provider + session and safely defers the run instead of reporting an uncertain provider + turn. A residual deleted tombstone is likewise treated as a safe deferral. + +### Security + +- Live cursor resume still requires the complete descriptor to match exactly. + Replacement remains available only after exact provider absence is persisted + and the protected cursor is cleared. Rebind atomically revalidates the owner, + session, scope, run, attempt, lease, row, claim, cleanup generation, absence + evidence, canonical transcript, and current descriptor before any business + input or tool request crosses the provider boundary. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.95.10] - 2026-08-28 Persistent schema module: **0.64.0** (unchanged from 0.95.9). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 48e1c53..f4a3d19 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.10" +version = "0.95.11" 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 fc2d818..97ef6dd 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-28 +last_reviewed: 2026-09-01 review_by: 2027-02-01 supersedes: [] --- @@ -19,6 +19,25 @@ 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.10 to 0.95.11: absence-proven descriptor replacement + +Adopt `graphql-orm-ai` 0.95.11 from one reviewed full monorepo revision. No +host API or configuration change is required. After cleanup has persisted +exact provider absence and cleared the protected cursor, a later run may +replace the deleted generation under its current server-authored provider, +registration, policy, model, protocol, and capability descriptor. The +historical descriptor does not authorize or fence the replacement generation. + +Live cursor resume still requires an exact complete descriptor match. Rebind +continues to revalidate every owner, session, scope, run, attempt, lease, row, +claim, cleanup-generation, absence, transcript, and current-descriptor fence. +If concurrent replacement wins first, the losing fresh empty provider session +is discarded and the run is safely deferred before business input or a tool +request is sent. + +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.9 to 0.95.10: stable Codex cleanup namespace Adopt `graphql-orm-ai` 0.95.10 from one reviewed full monorepo revision. No host diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 50ff184..ee7c74f 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-ai" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-28 +last_reviewed: 2026-09-01 review_by: 2027-02-01 supersedes: [] --- @@ -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.10", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.11", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -154,7 +154,11 @@ message under current policy where re-execution is provably safe; `acknowledgeAiRunFailure` dismisses a failure without deleting audit history. Invalidating a retained provider thread emits `provider_session_reset` or `provider_session_rebound` so a host can tell the user the model's context was -reset even though the durable transcript reads as continuous. +reset even though the durable transcript reads as continuous. Once exact +provider absence is persisted, a later run may replace the deleted generation +under its current server-authored descriptor; the historical descriptor grants +no replacement authority. A lost rebind fence discards the fresh empty +provider session and defers safely before any business input or tool request. Codex cleanup treats a successful empty `thread/delete` result as authoritative. The provider profile ID and versioned cursor kind form its stable provider-state 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 61cf141..43fc541 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 @@ -3,7 +3,7 @@ title: "Provider Sessions, Hosted Search, and Visible Activity" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-15 +last_reviewed: 2026-09-01 review_by: 2027-02-11 supersedes: [] --- @@ -538,10 +538,15 @@ eligibility from raw state fields. `RebindAllowed` is a crate-issued, short-lived authorization available only after cleanup persisted exact provider absence in a `Deleted` tombstone. `rebind_for_run` rehydrates current authority and atomically compares the exact owner/session/scope, run/attempt/ -lease fence, deleted row and cleanup generations, provider descriptor, and -host-authored canonical transcript fingerprint. It protects a fresh cursor in -the same row, so the unique session binding remains intact and concurrent -replacement has one winner. Cleared cursor material is never reopened. +lease fence, deleted row and cleanup generations, current server-authored +replacement descriptor, and host-authored canonical transcript fingerprint. +Exact provider absence severs the deleted generation from its historical +descriptor, so an approved provider, registration, model, protocol, policy, or +capability change cannot strand the durable application session. The current +descriptor is bound into the short-lived authorization and persisted with a +fresh cursor in the same row, so the unique session binding remains intact and +concurrent replacement has one winner. Cleared cursor material is never +reopened. A managed cleanup loop performs: @@ -556,7 +561,8 @@ An ordinary later run may replace only that exact absence-proven tombstone. Expiry, process death, transport failure, cleanup backoff, and restore quarantine remain unavailable. If creation of the replacement empty thread succeeds but its rebind CAS loses, `AiProviderCallExecutor` invokes the exact -registered provider discard boundary. +registered provider discard boundary and safely defers the run. No business +input or tool request crosses the provider boundary before the rebind commits. ### Parking across approval and subscription waits diff --git a/crates/graphql-orm-ai/src/orm_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index 810d427..3e1da00 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session.rs @@ -572,9 +572,15 @@ mod service { } if state == AiProviderSessionState::Deleted && binding.last_run_id != Some(lease.run_id().0) - && descriptor_from_record(binding).map_err(ai_error_to_orm)? - == *planned.descriptor() { + // Exact provider absence severs the old cursor from + // the next generation. The tombstone's historical + // descriptor must therefore not strand the durable + // application session after a reviewed provider, + // registration, policy, or capability change. The + // current server-authored descriptor is instead + // bound into this short-lived run authorization and + // atomically persisted with the fresh empty cursor. let provider_absence_observed_at = binding .provider_absence_observed_at .and_then(|value| OffsetDateTime::from_unix_timestamp(value).ok()) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 39d2e08..af85515 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -2940,7 +2940,14 @@ impl AiProviderCallExecutor { &cursor, ) .await; - return Err(error); + return Err(match error { + // No business input is sent before the empty + // provider session is durably rebound. Losing that + // persistence fence is therefore a safe deferral, + // not an uncertain provider turn. + AiError::Conflict => AiError::ProviderSessionDeferred, + other => other, + }); } } } @@ -2961,7 +2968,8 @@ impl AiProviderCallExecutor { crate::AiProviderSessionRunDisposition::Unavailable( crate::AiProviderSessionState::CleanupRequired | crate::AiProviderSessionState::CleanupInProgress - | crate::AiProviderSessionState::CleanupBackoff, + | crate::AiProviderSessionState::CleanupBackoff + | crate::AiProviderSessionState::Deleted, ) => { return Err(AiError::ProviderSessionDeferred); } @@ -9702,7 +9710,7 @@ mod tests { } #[tokio::test] - async fn rebind_fence_loss_discards_the_exact_new_empty_provider_session() { + async fn rebind_fence_loss_discards_the_empty_session_and_defers_safely() { let cursor = AiProviderSessionCursor::new("mock.thread", "losing-rebind-thread") .expect("test cursor should validate"); let fixture = fixture_with_provider( @@ -9760,7 +9768,7 @@ mod tests { None, ) .await, - Err(AiError::Conflict) + Err(AiError::ProviderSessionDeferred) )); assert_eq!(fixture.mock.discarded_provider_session_count(), 1); assert_eq!(fixture.mock.request_count(), 0); diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index 38d83eb..7769b05 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -492,7 +492,9 @@ pub struct AiProviderSessionBindingView { /// cursor and grants no provider egress, tool, GraphQL, or application /// authority. [`AiProviderSessionService::rebind_for_run`] rehydrates the /// current principal and atomically revalidates every field before replacing -/// the tombstone. +/// the tombstone under the current server-authored descriptor. Exact provider +/// absence severs the deleted generation from its historical descriptor; that +/// descriptor grants no authority over the replacement generation. #[derive(Clone, PartialEq, Eq)] pub struct AiProviderSessionRebindAuthorization { pub(crate) binding_id: Uuid, @@ -553,7 +555,7 @@ pub enum AiProviderSessionRunDisposition { /// A confirmed wait was already reclaimed for this exact run fence. Reclaimed(Box), /// Exact provider absence was proven and this run may replace the - /// tombstone once. + /// tombstone once under its current server-authored descriptor. RebindAllowed(Box), /// A binding exists but cannot safely serve the planned run. Unavailable(AiProviderSessionState), @@ -1398,8 +1400,9 @@ pub trait AiProviderSessionService: Send + Sync { /// # Errors /// /// Returns an error when current authority, the run fence, the - /// crate-issued authorization, deleted generation, provider descriptor, - /// transcript prefix, or protected replacement cursor is not exact. + /// crate-issued authorization, deleted generation, current replacement + /// descriptor, transcript prefix, or protected replacement cursor is not + /// exact. async fn rebind_for_run( &self, _lease: &AiRunLease, diff --git a/crates/graphql-orm-ai/tests/provider_sessions.rs b/crates/graphql-orm-ai/tests/provider_sessions.rs index 82eecdc..7302c6e 100644 --- a/crates/graphql-orm-ai/tests/provider_sessions.rs +++ b/crates/graphql-orm-ai/tests/provider_sessions.rs @@ -839,30 +839,20 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() { .await .expect("uncertain run should finish recovery-required"); let second_run = next_active_run(&fixture, &owner, first_run.session_id()).await; - let second_plan = AiProviderSessionTurnPlan::new(descriptor.clone(), "d".repeat(64)) - .expect("rebind plan should validate"); let changed_descriptor = AiProviderSessionDescriptor::new( - ProviderKind::LocalHarness, - "reviewed-local-profile", - "changed-model", - "a".repeat(64), - "codex-app-server/v2", - "b".repeat(64), + ProviderKind::OpenAiCompatible, + "replacement-profile", + "replacement-model", + "e".repeat(64), + "replacement-protocol/v3", + "f".repeat(64), ) .expect("changed descriptor should validate"); - let changed_plan = AiProviderSessionTurnPlan::new(changed_descriptor, "d".repeat(64)) + let changed_plan = AiProviderSessionTurnPlan::new(changed_descriptor.clone(), "d".repeat(64)) .expect("changed plan should validate"); - assert!(matches!( - fixture - .provider_sessions - .disposition_for_run(&second_run, &changed_plan) - .await - .expect("changed descriptor disposition should resolve"), - AiProviderSessionRunDisposition::Unavailable(AiProviderSessionState::Deleted) - )); let authorization = match fixture .provider_sessions - .disposition_for_run(&second_run, &second_plan) + .disposition_for_run(&second_run, &changed_plan) .await .expect("deleted binding should be classified") { @@ -878,8 +868,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() { &other_run, authorization.clone(), AiProviderSessionBindRequest::new( - descriptor.clone(), - AiProviderSessionCursor::new("codex.thread", "swapped-owner-thread") + changed_descriptor.clone(), + AiProviderSessionCursor::new("replacement.thread", "swapped-owner-thread") .expect("swapped cursor should validate"), "d".repeat(64), None, @@ -896,8 +886,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() { &second_run, authorization.clone(), AiProviderSessionBindRequest::new( - descriptor.clone(), - AiProviderSessionCursor::new("codex.thread", "swapped-transcript-thread") + changed_descriptor.clone(), + AiProviderSessionCursor::new("replacement.thread", "swapped-transcript-thread") .expect("swapped cursor should validate"), "e".repeat(64), None, @@ -910,8 +900,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() { let stale_authorization = authorization.clone(); let request = || { AiProviderSessionBindRequest::new( - descriptor.clone(), - AiProviderSessionCursor::new("codex.thread", "fresh-thread") + changed_descriptor.clone(), + AiProviderSessionCursor::new("replacement.thread", "fresh-thread") .expect("fresh cursor should validate"), "d".repeat(64), None, @@ -930,6 +920,7 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() { (Ok(claim), Err(AiError::Conflict)) | (Err(AiError::Conflict), Ok(claim)) => claim, outcomes => panic!("exactly one rebind should win: {outcomes:?}"), }; + assert_eq!(rebound.descriptor(), &changed_descriptor); let opened = fixture .provider_sessions .open_for_run(&second_run, &rebound) diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 7f7c7a8..198ce94 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.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.95.11` | `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.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none |