diff --git a/Cargo.lock b/Cargo.lock index 0c3f0be..3bc54f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.11" +version = "0.95.12" dependencies = [ "agql-auth", "async-graphql", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.10.2" +version = "0.10.3" dependencies = [ "async-graphql", "async-graphql-parser", diff --git a/Cargo.toml b/Cargo.toml index d0062c6..5582326 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" graphql-orm = { path = "crates/graphql-orm", version = "0.30.0", default-features = false } -graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.2" } +graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.3" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } graphql-orm-router-protocol = { path = "crates/graphql-orm-router-protocol", version = "0.2.1" } diff --git a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index 1c69345..5b2c6a9 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -3,13 +3,27 @@ title: "graphql-orm-ai-tool-profiles changelog" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-22 +last_reviewed: 2026-09-01 review_by: 2027-02-11 supersedes: [] --- # Changelog +## [0.10.3] - 2026-09-01 + +### Added + +- `AiError::PreTransportProviderFailed` represents a provider failure whose + adapter and call executor proved occurred before dispatch. It retains the + stable `AI_PROVIDER_FAILED` public code. + +### Security + +- The variant is proof-bearing and must not classify a generic provider error. + Failures after possible dispatch remain `ProviderFailed` and preserve the + uncertain-effect recovery boundary. + ## [0.10.2] - 2026-08-26 ### Added diff --git a/crates/graphql-orm-ai-tool-profiles/Cargo.toml b/crates/graphql-orm-ai-tool-profiles/Cargo.toml index 7f05003..ea55b37 100644 --- a/crates/graphql-orm-ai-tool-profiles/Cargo.toml +++ b/crates/graphql-orm-ai-tool-profiles/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai-tool-profiles" -version = "0.10.2" +version = "0.10.3" edition = "2024" authors = ["Toby Martin "] description = "Backend-neutral GraphQL AI tool profile compiler and manifest contracts" diff --git a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md index 10c7ef6..1b71fa2 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -3,13 +3,26 @@ title: "graphql-orm-ai-tool-profiles migration guide" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-22 +last_reviewed: 2026-09-01 review_by: 2027-02-11 supersedes: [] --- # Migration Guide +## 0.10.2 to 0.10.3: proof-bearing pre-dispatch provider failure + +Adopt `graphql-orm-ai-tool-profiles` 0.10.3 with `graphql-orm-ai` 0.95.12 from +one reviewed full monorepo revision. Deliberately exhaustive internal matches +must handle `AiError::PreTransportProviderFailed` separately from generic +`ProviderFailed`. Only the provider-call executor may return it after the +adapter reports `RejectedBeforeDispatch` and the unstarted budget reservation +is durably released. + +The new variant keeps the stable `AI_PROVIDER_FAILED` public code. There is no +schema, database, data, GraphQL SDL, manifest, capability, fingerprint, +protected-content, credential, or persistent AI schema-module migration. + ## 0.10.1 to 0.10.2: bounded relationship-argument projection Adopt `graphql-orm-ai-tool-profiles` 0.10.2 from one reviewed full monorepo diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 0ea6f7b..7cc90cd 100644 --- a/crates/graphql-orm-ai-tool-profiles/README.md +++ b/crates/graphql-orm-ai-tool-profiles/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-ai-tool-profiles" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-09-01 review_by: 2027-02-11 supersedes: [] --- @@ -24,7 +24,7 @@ are separate runtime decisions and must remain default-deny. ```toml [dependencies] -graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.10.2" } +graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.10.3" } serde_json = "1" ``` diff --git a/crates/graphql-orm-ai-tool-profiles/src/error.rs b/crates/graphql-orm-ai-tool-profiles/src/error.rs index a30c325..deb30eb 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/error.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/error.rs @@ -39,6 +39,15 @@ pub enum AiError { /// provider/tool loop must use [`Self::BudgetDenied`] instead. #[error("AI budget denied")] PreTransportBudgetDenied, + /// Provider execution failed before the request crossed its dispatch boundary. + /// + /// This is a proof-bearing execution classification, not a generic provider + /// error. It may be returned only after the adapter reports + /// `RejectedBeforeDispatch` and the executor durably releases the unstarted + /// budget reservation. A failure after possible dispatch must use + /// [`Self::ProviderFailed`] and remain recovery-required. + #[error("AI provider operation failed before dispatch")] + PreTransportProviderFailed, /// Input failed a public schema contract. #[error("invalid AI input: {0}")] InvalidInput(String), @@ -86,6 +95,7 @@ impl AiError { Self::EgressDenied => "AI_EGRESS_DENIED", Self::BudgetDenied => "AI_BUDGET_DENIED", Self::PreTransportBudgetDenied => "AI_BUDGET_DENIED", + Self::PreTransportProviderFailed => "AI_PROVIDER_FAILED", Self::InvalidInput(_) => "AI_INVALID_INPUT", Self::ReauthorizationFailed => "AI_REAUTHORIZATION_FAILED", Self::ToolExecutionFailed => "AI_TOOL_EXECUTION_FAILED", @@ -117,4 +127,12 @@ mod tests { "AI_PROVIDER_FAILED" ); } + + #[test] + fn pre_transport_provider_failure_keeps_the_provider_failure_public_code() { + assert_eq!( + AiError::PreTransportProviderFailed.public_code(), + "AI_PROVIDER_FAILED" + ); + } } diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 6e74360..78bdbf7 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -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.12] - 2026-09-01 + +Persistent schema module: **0.64.0** (unchanged from 0.95.11). + +### Fixed + +- The strict Codex actor now exposes an exact retained-resume readiness proof. + A host can wait for both the correlated `thread/resume` response and matching + `thread/started` notification, in either order, before preparing a turn. The + existing content-free retained-usage snapshot remains the only reviewed + fallback. +- Typed Codex failures proven to occur before `turn/start` are now classified + before dispatch. Their budget reservation is released, a retained cursor is + fenced for cleanup, and the run closes as retryable `Failed` instead of + incorrectly requiring uncertain-effect recovery. + +### Security + +- Only the typed newly-bound and retained pre-turn rejection variants can cross + the proof-bearing pre-dispatch path. Ordinary adapter failures and every + failure during or after `turn/start` remain possibly dispatched and continue + to require recovery. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.95.11] - 2026-09-01 Persistent schema module: **0.64.0** (unchanged from 0.95.10). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index f4a3d19..652b063 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.11" +version = "0.95.12" 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 97ef6dd..6aeabfa 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,27 @@ 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.11 to 0.95.12: retained-resume readiness and pre-dispatch proof + +Adopt `graphql-orm-ai` 0.95.12 and `graphql-orm-ai-tool-profiles` 0.10.3 from +one reviewed full monorepo revision. A Codex process host must keep accepting +strict actor frames after `thread/resume` until +`AiCodexAppServerProtocolActor::retained_resume_ready` returns true. A +correlated response alone is not readiness: the matching `thread/started` +notification must also arrive in either order, unless the reviewed +content-free retained-usage snapshot completes the lifecycle. + +Hosts may return `ProviderError::RetainedTurnRejected` only before writing the +business `turn/start` request. The Codex adapter converts that typed proof, and +the existing typed newly-bound rejection, into `RejectedBeforeDispatch`. +Reservations are released and retained cursors are fenced for cleanup. The run +closes as retryable `Failed/provider_pre_transport_failed`. Do not wrap a +write, timeout, protocol error, or other failure during or after `turn/start`; +those outcomes remain uncertain and recovery-required. + +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.10 to 0.95.11: absence-proven descriptor replacement Adopt `graphql-orm-ai` 0.95.11 from one reviewed full monorepo revision. No diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index ee7c74f..43cf2ab 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.11", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.12", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -160,6 +160,16 @@ 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. +A retained Codex host must not treat the `thread/resume` response as sufficient +readiness. Continue feeding the strict protocol actor until +`retained_resume_ready` proves both that response and the matching +`thread/started` notification in either order, or the reviewed content-free +usage-snapshot fallback. Only a typed rejection raised before writing +`turn/start` is safely pre-dispatch: it releases the unstarted reservation, +requires retained-cursor cleanup, and closes with the retryable +`provider_pre_transport_failed` code. Every failure during or after +`turn/start` remains uncertain. + 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 diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 01c01d2..b68e8ba 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -3,16 +3,16 @@ title: "Implementation Status" 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: [] --- # Implementation Status -`graphql-orm-ai` is at crate version `0.95.9` with AI schema module -`0.64.0`. It uses workspace `graphql-orm` `0.27.0`, backend-neutral -`graphql-orm-ai-tool-profiles` `0.10.2`, and external `agql-auth` +`graphql-orm-ai` is at crate version `0.95.12` 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`. Completed stateless local-provider turns can carry a proof-bearing contained @@ -56,6 +56,11 @@ verification evidence belongs in the focused guides. response-authoritative deletion, empty reasoning lifecycle, and retained cumulative-usage replay are admitted only through typed content-free controls that cannot become model output or current-run usage. Retained + resume readiness requires its correlated response and matching started + notification in either order, or the reviewed content-free usage-snapshot + fallback. 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, and distinct from request input. Reasoning effort is a separately typed, model-profile-validated turn field frozen into each retained-session diff --git a/crates/graphql-orm-ai/src/orm_background.rs b/crates/graphql-orm-ai/src/orm_background.rs index f8ef8ee..133c8c1 100644 --- a/crates/graphql-orm-ai/src/orm_background.rs +++ b/crates/graphql-orm-ai/src/orm_background.rs @@ -4039,6 +4039,7 @@ fn classify_background_retrieval_error( | ProviderError::Rejected | ProviderError::StatelessNativeItemRejected | ProviderError::NewlyBoundTurnRejected(_) + | ProviderError::RetainedTurnRejected(_) | ProviderError::Cancelled | ProviderError::Classified(_) => { AiOpenAiBackgroundRetrievalAttempt::RecoveryRequired(background_retrieval_failure( diff --git a/crates/graphql-orm-ai/src/orm_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index 5b7e74a..f123b4f 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -1722,6 +1722,11 @@ impl AiReadOnlyAgentCoordinator { .finish_failed(&lease, &guard, "provider_budget_denied") .await; } + Err(ProviderTurnFailure::PreTransportProvider) => { + return self + .finish_failed(&lease, &guard, "provider_pre_transport_failed") + .await; + } Err(ProviderTurnFailure::StatelessNativeItemRejected) => { return self .finish_failed(&lease, &guard, "provider_native_item_rejected") @@ -2514,6 +2519,7 @@ impl AiReadOnlyAgentCoordinator { enum ProviderTurnFailure { Provider, BudgetDenied, + PreTransportProvider, StatelessNativeItemRejected, Deferred, LeaseLost(AiError), @@ -2525,9 +2531,10 @@ enum ProviderTurnFailure { /// Separates proof-bearing refusals from an uncertain provider turn. /// /// The budget reservation is taken before the transport boundary and inside -/// the same call that later dispatches. A denial therefore proves that no -/// bytes crossed the provider boundary, that no provider turn was consumed, -/// and that the atomic reservation transaction left nothing held. A stateless +/// the same call that later dispatches. A budget denial or a typed adapter +/// pre-dispatch rejection therefore proves that no bytes crossed the provider +/// boundary, that no provider turn was consumed, and that the atomic +/// reservation transaction left nothing held. A stateless /// native-item refusal is separately proof-bearing only after the /// executor has committed authoritative usage and proven that no answer or /// admitted host tool effect exists. Every other executor error keeps the @@ -2535,6 +2542,7 @@ enum ProviderTurnFailure { const fn classify_provider_turn_failure(error: &AiError) -> ProviderTurnFailure { match error { AiError::PreTransportBudgetDenied => ProviderTurnFailure::BudgetDenied, + AiError::PreTransportProviderFailed => ProviderTurnFailure::PreTransportProvider, AiError::StatelessNativeItemRejected => ProviderTurnFailure::StatelessNativeItemRejected, _ => ProviderTurnFailure::Provider, } @@ -3913,6 +3921,64 @@ mod tests { ); } + #[tokio::test] + async fn pre_transport_provider_rejection_fails_cleanly_and_is_retryable() { + let lease = AiRunLease::test_running(principal_reference()); + let run = Arc::new(TestRunControl::new()); + let provider = Arc::new(TestProviderExecutor { + responses: Mutex::new(VecDeque::from([Err(AiError::PreTransportProviderFailed)])), + delay: None, + }); + let planner = Arc::new(TestChatPlanner { + scope: test_scope(), + continuation_count: AtomicUsize::new(0), + }); + let forbidden = Arc::new(ChatForbiddenBoundaries::default()); + let coordinator = AiReadOnlyAgentCoordinator::new( + run.clone(), + provider.clone(), + forbidden.clone(), + Arc::new(TestOutputWriter), + forbidden.clone(), + Arc::new(TestCheckpointWriter), + Arc::new(TestRuleResolver), + planner, + limits(50), + ); + + let outcome = coordinator + .execute_claimed(&lease) + .await + .expect("a proven pre-transport rejection is a clean terminal failure"); + + assert_eq!( + outcome, + Failed { + provider_turns: 0, + total_tool_calls: 0, + } + ); + assert_eq!(run.final_states(), vec![AiRunState::Failed]); + assert_eq!( + run.final_codes(), + vec!["provider_pre_transport_failed".to_owned()] + ); + assert!(run.scheduled_retry_codes().is_empty()); + assert_eq!(provider.remaining_responses(), 0); + assert_eq!(forbidden.tool_calls.load(Ordering::SeqCst), 0); + assert_eq!(forbidden.provider_checkpoints.load(Ordering::SeqCst), 0); + assert_eq!( + crate::classify_run_retry( + crate::AiRunRetryEvidence { + terminal: crate::AiRunTerminalEvent::Failed, + produced_assistant_output: false, + }, + Some("provider_pre_transport_failed"), + ), + crate::AiRunRetryAdmission::Allowed + ); + } + #[tokio::test] async fn metered_stateless_native_item_refusal_is_failed_and_retryable() { let lease = AiRunLease::test_running(principal_reference()); diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index a50e4bf..d9562e5 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -1099,6 +1099,20 @@ impl AiSupervisedAgentCoordinator { .finish_failed(&lease, &guard, "provider_budget_denied") .await; } + Err(SupervisedProviderTurnFailure::PreTransportProvider) => { + if let Some((service, claim)) = &reclaimed { + let _ = service + .require_cleanup(claim, "provider_session_reclaimed_handoff_failed") + .await; + } + // The adapter proved that no business turn crossed the + // provider boundary. The reservation has already been + // released and retained-session cleanup, when applicable, + // has been durably fenced by the call executor. + return self + .finish_failed(&lease, &guard, "provider_pre_transport_failed") + .await; + } Err(SupervisedProviderTurnFailure::StatelessNativeItemRejected) => { if let Some((service, claim)) = &reclaimed { let _ = service @@ -1723,18 +1737,22 @@ impl AiSupervisedAgentCoordinator { enum SupervisedProviderTurnFailure { Provider, BudgetDenied, + PreTransportProvider, StatelessNativeItemRejected, LeaseLost(AiError), } /// Separates proof-bearing refusals from an uncertain turn. /// -/// See the read-only coordinator for the full argument: the atomic budget -/// reservation happens before the transport boundary, so a denial proves no -/// bytes crossed it and no reservation was left held. +/// See the read-only coordinator for the full argument. Budget denial, typed +/// pre-dispatch rejection, and a provider-session cleanup deferral all prove +/// that no business turn crossed the provider boundary. const fn classify_supervised_turn_failure(error: &AiError) -> SupervisedProviderTurnFailure { match error { AiError::PreTransportBudgetDenied => SupervisedProviderTurnFailure::BudgetDenied, + AiError::PreTransportProviderFailed | AiError::ProviderSessionDeferred => { + SupervisedProviderTurnFailure::PreTransportProvider + } AiError::StatelessNativeItemRejected => { SupervisedProviderTurnFailure::StatelessNativeItemRejected } @@ -3042,6 +3060,77 @@ mod tests { ); } + #[tokio::test] + async fn pre_transport_provider_rejection_is_a_certain_supervised_failure() { + let lease = AiRunLease::test_running(principal_reference()); + let run = Arc::new(TestRunControl::new()); + let provider = Arc::new(TestProviderExecutor { + responses: Mutex::new(VecDeque::from([Err(AiError::PreTransportProviderFailed)])), + require_checkpoint_cleared: false, + calls: AtomicUsize::new(0), + }); + let coordinator = AiSupervisedAgentCoordinator::new( + run.clone(), + provider.clone(), + Arc::new(TestOutputWriter), + Arc::new(TestCheckpointWriter { + provider_checkpoints: AtomicUsize::new(0), + }), + Arc::new(TestCheckpointControl { + adopted: Mutex::new(None), + consumed: AtomicBool::new(false), + }), + Arc::new(TestApprovalStager { + calls: AtomicUsize::new(0), + saw_checkpoint: AtomicBool::new(false), + }), + unused_automatic(), + unused_resume(), + Arc::new(TestRuleResolver), + Arc::new(TestPlanner { + scope: test_scope(), + route: test_route(), + continuation_count: AtomicUsize::new(0), + }), + Arc::new(FixedClock::new(time::OffsetDateTime::now_utc())), + limits(), + ); + + let outcome = coordinator + .execute_claimed(&lease) + .await + .expect("a proven supervised pre-transport rejection should fail cleanly"); + + assert_eq!( + outcome, + AiSupervisedAgentRunOutcome::Failed { + provider_turns: 0, + total_tool_calls: 0, + } + ); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); + assert_eq!(provider.remaining_responses(), 0); + assert_eq!(run.final_states(), vec![AiRunState::Failed]); + assert_eq!( + crate::classify_run_retry( + crate::AiRunRetryEvidence { + terminal: crate::AiRunTerminalEvent::Failed, + produced_assistant_output: false, + }, + Some("provider_pre_transport_failed"), + ), + crate::AiRunRetryAdmission::Allowed + ); + } + + #[test] + fn retained_cleanup_deferral_keeps_the_supervised_turn_out_of_recovery() { + assert!(matches!( + classify_supervised_turn_failure(&AiError::ProviderSessionDeferred), + SupervisedProviderTurnFailure::PreTransportProvider + )); + } + #[tokio::test] async fn provider_turn_is_checkpointed_before_one_approval_is_staged() { let lease = AiRunLease::test_running(principal_reference()); diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 851975a..32ac57b 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1866,6 +1866,9 @@ pub enum ProviderError { /// A newly bound empty retained thread failed a closed activation check. #[error("provider newly-bound turn rejected: {0}")] NewlyBoundTurnRejected(AiCodexBoundTurnRejection), + /// A retained thread failed before business input crossed the turn boundary. + #[error("provider retained turn rejected before dispatch: {0}")] + RetainedTurnRejected(AiCodexRetainedTurnRejection), /// Stream was cancelled. #[error("provider stream cancelled")] Cancelled, @@ -1891,6 +1894,7 @@ impl ProviderError { | Self::Rejected | Self::StatelessNativeItemRejected | Self::NewlyBoundTurnRejected(_) => AiProviderFailureCategory::ProviderRejection, + Self::RetainedTurnRejected(_) => AiProviderFailureCategory::RetainedResumeRejection, Self::RateLimited => AiProviderFailureCategory::RateLimit, Self::Unavailable => AiProviderFailureCategory::TransportUnavailable, Self::Cancelled => AiProviderFailureCategory::Cancellation, @@ -2009,6 +2013,44 @@ impl fmt::Display for AiCodexBoundTurnRejection { } } +/// Content-free phase that rejected a retained Codex turn before business input. +/// +/// A process adapter may emit this proof only before it writes the `turn/start` +/// request containing user input. Loading a protected thread with +/// `thread/resume` is included because that request contains no user input or +/// application-tool request. Failures during or after `turn/start` must remain +/// ordinary uncertain provider errors. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum AiCodexRetainedTurnRejection { + /// The opened session, model, or frozen registration did not match locally. + RequestValidation, + /// A bounded local process or concurrency admission failed before resume. + ProcessAdmission, + /// The exact protected cursor did not complete its correlated resume lifecycle. + ResumeLifecycle, + /// The actor could not prepare the exact turn frame before it was written. + TurnPreparation, +} + +impl AiCodexRetainedTurnRejection { + /// Stable bounded machine code. + pub const fn as_str(self) -> &'static str { + match self { + Self::RequestValidation => "codex_retained_turn_request_validation", + Self::ProcessAdmission => "codex_retained_turn_process_admission", + Self::ResumeLifecycle => "codex_retained_turn_resume_lifecycle", + Self::TurnPreparation => "codex_retained_turn_preparation", + } + } +} + +impl fmt::Display for AiCodexRetainedTurnRejection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + impl fmt::Display for AiProviderFailureCategory { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) @@ -2035,6 +2077,17 @@ pub trait AiProviderFailureDiagnosticSink: Send + Sync { let _ = reason; self.record(AiProviderFailureCategory::ProviderRejection); } + + /// Records the closed pre-dispatch phase that rejected a retained Codex turn. + /// + /// The default records only + /// [`AiProviderFailureCategory::RetainedResumeRejection`]. Hosts may + /// override this method to retain the closed phase without observing + /// cursors, prompts, tools, arguments, results, or provider payloads. + fn record_retained_turn_rejection(&self, reason: AiCodexRetainedTurnRejection) { + let _ = reason; + self.record(AiProviderFailureCategory::RetainedResumeRejection); + } } /// Provider event stream. @@ -3247,6 +3300,15 @@ mod safe_failure_tests { let bound_text = format!("{bound:?} {bound}"); assert!(bound_text.contains("codex_bound_turn_frozen_definition_mismatch")); assert!(!bound_text.contains(sensitive)); + let retained = + ProviderError::RetainedTurnRejected(AiCodexRetainedTurnRejection::ResumeLifecycle); + assert_eq!( + retained.safe_category(), + AiProviderFailureCategory::RetainedResumeRejection + ); + let retained_text = format!("{retained:?} {retained}"); + assert!(retained_text.contains("codex_retained_turn_resume_lifecycle")); + assert!(!retained_text.contains(sensitive)); struct DefaultBoundTurnSink(std::sync::Mutex>); impl AiProviderFailureDiagnosticSink for DefaultBoundTurnSink { @@ -3261,12 +3323,16 @@ mod safe_failure_tests { sink.record_newly_bound_turn_rejection( AiCodexBoundTurnRejection::BootstrapFingerprintMismatch, ); + sink.record_retained_turn_rejection(AiCodexRetainedTurnRejection::TurnPreparation); assert_eq!( sink.0 .lock() .expect("bound-turn diagnostic lock should remain available") .as_slice(), - &[AiProviderFailureCategory::ProviderRejection] + &[ + AiProviderFailureCategory::ProviderRejection, + AiProviderFailureCategory::RetainedResumeRejection, + ] ); let denied = classify_safe_application_tool_error(&AiError::Forbidden) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index af85515..463450e 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -2686,6 +2686,12 @@ impl AiProviderCallExecutor { sink.record_newly_bound_turn_rejection(*reason); return; } + if let ProviderError::RetainedTurnRejected(reason) = error + && let Some(sink) = &self.failure_diagnostic_sink + { + sink.record_retained_turn_rejection(*reason); + return; + } self.record_provider_failure(error.safe_category()); } @@ -3059,6 +3065,12 @@ impl AiProviderCallExecutor { Ok(result) } Err(error @ AiError::PreTransportBudgetDenied) => Err(error), + Err(AiError::PreTransportProviderFailed) => { + session_service + .require_cleanup(¤t_claim, "provider_session_pre_transport_failure") + .await?; + Err(AiError::ProviderSessionDeferred) + } Err(error) => { let _ = session_service .require_cleanup(¤t_claim, "provider_session_turn_ambiguous") @@ -3380,7 +3392,7 @@ impl AiProviderCallExecutor { crate::AiProviderDispatchOutcome::RejectedBeforeDispatch(error) => { self.record_provider_error(&error); self.release_unstarted(&lease, &reservation).await?; - return Err(AiError::ProviderFailed); + return Err(AiError::PreTransportProviderFailed); } crate::AiProviderDispatchOutcome::FailedAfterPossibleDispatch(error) => { self.record_provider_error(&error); @@ -9455,7 +9467,7 @@ mod tests { assert!(matches!( executor.execute(&fixture.lease, plan(&fixture)).await, - Err(AiError::ProviderFailed) + Err(AiError::PreTransportProviderFailed) )); assert_eq!(fixture.mock.request_count(), 0); assert_eq!(reservation_state(&fixture.database).await, "released"); @@ -9709,6 +9721,128 @@ mod tests { assert!(bindings[0].cleanup_reason_code.is_none()); } + #[tokio::test] + async fn retained_pre_transport_provider_failure_requires_cleanup_and_defers_safely() { + let cursor = AiProviderSessionCursor::new("mock.thread", "rejected-empty-thread") + .expect("test cursor should validate"); + let fixture = fixture_with_provider( + MockProvider::new(Vec::new()) + .with_provider_session_cursor(cursor) + .with_prepare_failure(AiProviderFailureCategory::ProviderRejection), + ) + .await; + let session = AiSessionRecord::find_by_id(&fixture.database, &fixture.lease.session_id().0) + .await + .expect("session lookup should succeed") + .expect("session should exist"); + let update = AiSessionRecord::compare_and_swap( + &fixture.database, + &session.id, + session.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + message_head: Some(1), + ..Default::default() + }, + ) + .await + .expect("session watermark update should succeed"); + assert!(matches!(update, ConditionalUpdateOutcome::Updated(_))); + AiMessageRecord::insert( + &fixture.database, + CreateAiMessageRecordInput { + id: fixture.lease.input_message_id(), + session_id: fixture.lease.session_id().0, + sequence: 1, + message_role: "user".to_owned(), + author_principal_kind: Some("user".to_owned()), + author_subject: Some(fixture.principal.subject().to_owned()), + client_message_id: Some(Uuid::new_v4()), + content_hash: Some("c".repeat(64)), + run_id: Some(fixture.lease.run_id().0), + provider_kind: None, + provider_model: None, + protected_preview: None, + block_count: 1, + completion_state: "complete".to_owned(), + finalized_at: Some(OffsetDateTime::now_utc().unix_timestamp()), + content_purged_at: None, + }, + ) + .await + .expect("input message should insert"); + + let provider_sessions = Arc::new( + OrmAiProviderSessionService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(SystemClock), + AiProviderSessionLimits::default(), + Duration::minutes(5), + ) + .expect("provider-session service should validate"), + ); + let descriptor = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + "a".repeat(64), + "mock-retained/v1", + "b".repeat(64), + ) + .expect("descriptor should validate"); + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(TestUsageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8_192, 64 * 1_024) + .expect("provider limits should validate"), + ); + + let error = executor + .execute_with_provider_session( + Arc::new(Mutex::new(fixture.lease.clone())), + plan(&fixture), + AiProviderSessionTurnPlan::new(descriptor, "d".repeat(64)) + .expect("session plan should validate"), + provider_sessions, + None, + ) + .await + .expect_err("a proven pre-transport rejection should defer after cleanup fencing"); + + assert!(matches!(error, AiError::ProviderSessionDeferred)); + assert_eq!(fixture.mock.request_count(), 0); + assert_eq!(reservation_state(&fixture.database).await, "released"); + let bindings = fixture + .database + .transaction(TransactionMode::Default, |tx| { + Box::pin(async move { + tx.query::() + .limit(2) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await + .expect("provider-session binding query should succeed"); + assert_eq!(bindings.len(), 1); + assert_eq!( + bindings[0].state, + AiProviderSessionState::CleanupRequired.as_str() + ); + assert_eq!( + bindings[0].cleanup_reason_code.as_deref(), + Some("provider_session_pre_transport_failure") + ); + } + #[tokio::test] async fn rebind_fence_loss_discards_the_empty_session_and_defers_safely() { let cursor = AiProviderSessionCursor::new("mock.thread", "losing-rebind-thread") 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 80f675a..fd55218 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -24,13 +24,13 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; use crate::{ - AiProvider, AiProviderCapabilitySessionBinding, AiProviderFailureCategory, - AiProviderRunBinding, AiProviderRunCloseOutcome, AiProviderRunCloseReason, - AiProviderRunInterruptOutcome, ModelBuiltinTool, ModelContinuationMode, ModelInputBlock, - ModelReasoningEffort, ModelReasoningEffortProfile, ModelReasoningSummaryRequest, ModelRequest, - ModelToolDefinition, ModelWebSearchDomainPolicy, ProviderCapabilities, ProviderDynamicToolCall, - ProviderDynamicToolResponder, ProviderError, ProviderEventStream, ProviderKind, - ProviderRequestContext, + AiProvider, AiProviderCapabilitySessionBinding, AiProviderDispatchOutcome, + AiProviderFailureCategory, AiProviderRunBinding, AiProviderRunCloseOutcome, + AiProviderRunCloseReason, AiProviderRunInterruptOutcome, ModelBuiltinTool, + ModelContinuationMode, ModelInputBlock, ModelReasoningEffort, ModelReasoningEffortProfile, + ModelReasoningSummaryRequest, ModelRequest, ModelToolDefinition, ModelWebSearchDomainPolicy, + ProviderCapabilities, ProviderDynamicToolCall, ProviderDynamicToolResponder, ProviderError, + ProviderEventStream, ProviderKind, ProviderRequestContext, }; const MAXIMUM_PROCESSES: usize = 4_096; @@ -72,6 +72,19 @@ fn provider_timeout_error() -> ProviderError { ProviderError::Classified(AiProviderFailureCategory::Timeout) } +fn codex_dispatch_outcome( + result: Result, +) -> AiProviderDispatchOutcome { + match result { + Ok(stream) => AiProviderDispatchOutcome::Dispatched(stream), + Err( + error @ (ProviderError::NewlyBoundTurnRejected(_) + | ProviderError::RetainedTurnRejected(_)), + ) => AiProviderDispatchOutcome::RejectedBeforeDispatch(error), + Err(error) => AiProviderDispatchOutcome::FailedAfterPossibleDispatch(error), + } +} + const OPTED_OUT_NOTIFICATION_METHODS: [&str; 5] = [ "thread/status/changed", "thread/settings/updated", @@ -1386,6 +1399,17 @@ impl AiProvider for AiCodexAppServerProvider { } } + async fn dispatch( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> AiProviderDispatchOutcome { + if let Err(error) = self.prepare_dispatch(&request, &context).await { + return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } + codex_dispatch_outcome(self.stream(request, context).await) + } + async fn stream_with_dynamic_tools( &self, request: ModelRequest, @@ -1455,6 +1479,21 @@ impl AiProvider for AiCodexAppServerProvider { } } + async fn dispatch_with_dynamic_tools( + &self, + request: ModelRequest, + context: ProviderRequestContext, + responder: Arc, + ) -> AiProviderDispatchOutcome { + if let Err(error) = self.prepare_dispatch(&request, &context).await { + return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } + codex_dispatch_outcome( + self.stream_with_dynamic_tools(request, context, responder) + .await, + ) + } + async fn interrupt_run( &self, binding: &AiProviderRunBinding, @@ -4194,6 +4233,29 @@ impl AiCodexAppServerProtocolActor { Ok(frame) } + /// Reports whether an exact retained cursor completed its resume + /// lifecycle and may proceed to local turn preparation. + /// + /// A correlated `thread/resume` response alone is insufficient: the + /// matching `thread/started` notification must also have arrived, unless + /// the reviewed content-free retained-usage snapshot fallback completed + /// the lifecycle. Hosts should keep reading strict actor input until this + /// predicate becomes true before calling [`Self::start_turn`]. + #[must_use] + pub fn retained_resume_ready(&self, cursor: &crate::AiProviderSessionCursor) -> bool { + cursor.kind() == "codex.app_server.thread.v2" + && self.active_thread_id.as_deref() == Some(cursor.expose_to_provider_adapter()) + && self.thread_lifecycle_phase == ThreadLifecyclePhase::Complete + && (self.thread_lifecycle_operation.is_none() + || (self.thread_lifecycle_operation == Some(ThreadLifecycleOperation::Resume) + && self.retained_usage_snapshot_observed)) + && self.pending_turn_thread_id.is_none() + && self.active_turn_id.is_none() + && self.deleting_thread_id.is_none() + && !self.turn_response_observed + && !self.turn_started_observed + } + /// Encodes text-only user input for one exact lifecycle-complete thread. /// /// Trusted instructions are deliberately not copied into the user input @@ -6424,6 +6486,32 @@ pub(crate) mod tests { }; use uuid::Uuid; + #[test] + fn dispatch_classifier_proves_only_typed_pre_turn_rejections() { + assert!(matches!( + codex_dispatch_outcome(Err(ProviderError::RetainedTurnRejected( + crate::AiCodexRetainedTurnRejection::ResumeLifecycle, + ))), + AiProviderDispatchOutcome::RejectedBeforeDispatch(ProviderError::RetainedTurnRejected( + crate::AiCodexRetainedTurnRejection::ResumeLifecycle + )) + )); + assert!(matches!( + codex_dispatch_outcome(Err(ProviderError::NewlyBoundTurnRejected( + crate::AiCodexBoundTurnRejection::ActivationUnavailable, + ))), + AiProviderDispatchOutcome::RejectedBeforeDispatch( + ProviderError::NewlyBoundTurnRejected( + crate::AiCodexBoundTurnRejection::ActivationUnavailable + ) + ) + )); + assert!(matches!( + codex_dispatch_outcome(Err(ProviderError::Rejected)), + AiProviderDispatchOutcome::FailedAfterPossibleDispatch(ProviderError::Rejected) + )); + } + mod canonical_tool_surface { use graphql_orm::prelude::*; @@ -12480,12 +12568,15 @@ pub(crate) mod tests { actor .resume_thread(&cursor, &turn()) .expect("the same actor should begin a new resume lifecycle"); + assert!(!actor.retained_resume_ready(&cursor)); actor .accept(br#"{"id":3,"result":{"thread":{"id":"thread-retained-1"}}}"#) .expect("resume response should belong to the new lifecycle"); + assert!(!actor.retained_resume_ready(&cursor)); actor .accept(&thread_started_notification("thread-retained-1")) .expect("resume notification should complete the new lifecycle"); + assert!(actor.retained_resume_ready(&cursor)); actor .start_turn("thread-retained-1", &turn()) .expect("turn should start only after both resume observations"); @@ -12519,17 +12610,88 @@ pub(crate) mod tests { actor .resume_thread(&cursor, &turn()) .expect("resume should begin a fresh observation phase"); + assert!(!actor.retained_resume_ready(&cursor)); actor .accept(&thread_started_notification("thread-retained-1")) .expect("resume notification may arrive first"); + assert!(!actor.retained_resume_ready(&cursor)); actor .accept(br#"{"id":3,"result":{"thread":{"id":"thread-retained-1"}}}"#) .expect("resume response should complete the lifecycle"); + assert!(actor.retained_resume_ready(&cursor)); actor .start_turn("thread-retained-1", &turn()) .expect("turn should start after notification-first correlation"); } + #[test] + fn retained_actor_completes_three_consecutive_turns_on_one_cursor() { + let mut actor = initialized_protocol_actor(); + actor + .start_persistent_empty_thread( + "model-1", + ModelReasoningEffort::Unspecified, + &trusted_bootstrap(), + &[], + ) + .expect("persistent create should encode"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("create response should bind"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("create notification should complete the lifecycle"); + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("retained cursor should validate"); + + for cycle in 0_u64..3 { + actor + .resume_thread(&cursor, &turn()) + .expect("each terminal turn should admit another exact resume"); + assert!(!actor.retained_resume_ready(&cursor)); + let resume_id = 3 + cycle * 2; + let resume_response = format!( + r#"{{"id":{resume_id},"result":{{"thread":{{"id":"thread-retained-1"}}}}}}"# + ); + if cycle % 2 == 0 { + actor + .accept(resume_response.as_bytes()) + .expect("response-first resume should bind"); + assert!(!actor.retained_resume_ready(&cursor)); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("response-first notification should complete resume"); + } else { + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("notification-first resume should bind"); + assert!(!actor.retained_resume_ready(&cursor)); + actor + .accept(resume_response.as_bytes()) + .expect("notification-first response should complete resume"); + } + assert!(actor.retained_resume_ready(&cursor)); + + actor + .start_turn("thread-retained-1", &turn()) + .expect("ready retained lifecycle should admit a turn"); + let turn_id = format!("turn-retained-{cycle}"); + let turn_response_id = resume_id + 1; + let turn_response = + format!(r#"{{"id":{turn_response_id},"result":{{"turn":{{"id":"{turn_id}"}}}}}}"#); + actor + .accept(turn_response.as_bytes()) + .expect("turn response should bind"); + actor + .accept(&turn_started_notification("thread-retained-1", &turn_id)) + .expect("turn notification should bind"); + actor + .accept(&turn_completed_notification("thread-retained-1", &turn_id)) + .expect("tool-free terminal turn should complete"); + } + } + #[test] fn retained_actor_requires_each_lifecycle_pair_and_preserves_dynamic_definitions() { let input = AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) diff --git a/crates/graphql-orm-ai/src/run_state.rs b/crates/graphql-orm-ai/src/run_state.rs index 6709345..9d6d75e 100644 --- a/crates/graphql-orm-ai/src/run_state.rs +++ b/crates/graphql-orm-ai/src/run_state.rs @@ -401,6 +401,7 @@ const fn is_retryable_failure_code(code: &str) -> bool { code.as_bytes(), b"provider_session_cleanup_unavailable" | b"provider_budget_denied" + | b"provider_pre_transport_failed" | b"agent_rule_budget_exceeded" | b"agent_rule_changed_after_provider" | b"agent_turn_limit_reached" diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 41ba6af..9dd305c 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.10" +version = "0.95.12" dependencies = [ "agql-auth", "async-graphql", @@ -1357,7 +1357,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.10.2" +version = "0.10.3" dependencies = [ "async-graphql", "async-graphql-parser", diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 198ce94..ec2bf8b 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,8 +19,8 @@ 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.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-ai` | `0.95.12` | `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 | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) |