From aa63ec4684a726d669f6dd986a12244cf0ddec33 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 02:54:51 +1000 Subject: [PATCH 1/2] feat(ai): classify contained stateless native refusals --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 17 ++- .../graphql-orm-ai-tool-profiles/Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/MIGRATION.md | 20 +++- crates/graphql-orm-ai-tool-profiles/README.md | 2 +- .../graphql-orm-ai-tool-profiles/src/error.rs | 25 ++++ crates/graphql-orm-ai/CHANGELOG.md | 34 +++++- crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 30 ++++- crates/graphql-orm-ai/README.md | 8 +- .../docs/implementation-status.md | 9 +- crates/graphql-orm-ai/src/orm_background.rs | 1 + crates/graphql-orm-ai/src/orm_coordinator.rs | 86 +++++++++++-- .../src/orm_supervised_coordinator.rs | 16 ++- crates/graphql-orm-ai/src/provider.rs | 13 ++ crates/graphql-orm-ai/src/provider_calls.rs | 113 +++++++++++++++++- crates/graphql-orm-ai/src/providers/mock.rs | 24 +++- crates/graphql-orm-ai/src/run_state.rs | 1 + docs/reference/workspace-packages.md | 4 +- 20 files changed, 382 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a147f7dd..706f074d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.85.0" +version = "0.86.0" dependencies = [ "agql-auth", "async-graphql", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.8.0" +version = "0.9.0" dependencies = [ "async-graphql", "async-graphql-parser", diff --git a/Cargo.toml b/Cargo.toml index ec3b8864..98fa550e 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.23.0", default-features = false } -graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.8.0" } +graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.9.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.1", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.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 76c4e493..7982858f 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -3,13 +3,28 @@ title: "graphql-orm-ai-tool-profiles changelog" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-21 +last_reviewed: 2026-08-22 review_by: 2027-02-11 supersedes: [] --- # Changelog +## [0.9.0] - 2026-08-22 + +### Added + +- `AiError::StatelessNativeItemRejected` is the proof-bearing terminal error + for a completed, authoritatively metered StatelessReplay turn whose refused + provider-native item was contained and produced no admitted answer or host + tool effect. It retains the stable `AI_PROVIDER_FAILED` public code. + +### Security + +- The variant is not a generic rejection category. An incomplete, retained, + unmetered, content-producing, tool-producing, or uncontained provider turn + must remain `ProviderFailed` and preserve uncertainty. + ## [0.8.0] - 2026-08-21 ### Added diff --git a/crates/graphql-orm-ai-tool-profiles/Cargo.toml b/crates/graphql-orm-ai-tool-profiles/Cargo.toml index e85d26fa..6ecef21a 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.8.0" +version = "0.9.0" 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 31dd0fda..957aaeca 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -3,13 +3,31 @@ title: "graphql-orm-ai-tool-profiles migration guide" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-21 +last_reviewed: 2026-08-22 review_by: 2027-02-11 supersedes: [] --- # Migration Guide +## 0.8.0 to 0.9.0: proof-bearing stateless native-item refusal + +Adopt `graphql-orm-ai-tool-profiles` 0.9.0 with `graphql-orm-ai` 0.86.0 from +one reviewed full monorepo revision. + +`AiError` gains `StatelessNativeItemRejected`. Only the provider-call executor +may return it, after committing authoritative usage for a completed +StatelessReplay turn and proving that no assistant answer, application tool, +hosted tool, citation, cursor, or unknown provider event was admitted. The +provider adapter must separately prove the refused native item was contained. +Every incomplete or ambiguous turn remains `ProviderFailed`. + +The new variant deliberately retains the stable `AI_PROVIDER_FAILED` public +code. Update deliberately exhaustive internal matches so it cannot be folded +back into provider uncertainty. There is no schema, database, data, GraphQL +SDL, manifest, capability, fingerprint, protected-content, credential, or AI +schema-module migration from this package change. + ## 0.7.0 to 0.8.0: canonical federated capability-index sets Adopt `graphql-orm-ai-tool-profiles` 0.8.0 and `graphql-orm-ai` 0.85.0 from one diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 495abc83..6ceb56af 100644 --- a/crates/graphql-orm-ai-tool-profiles/README.md +++ b/crates/graphql-orm-ai-tool-profiles/README.md @@ -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.8.0" } +graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.9.0" } 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 935bbc33..62f009b3 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/error.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/error.rs @@ -51,6 +51,17 @@ pub enum AiError { /// Provider operation failed safely. #[error("AI provider operation failed")] ProviderFailed, + /// A stateless provider turn completed and was metered, but the adapter + /// refused one provider-native item outside the admitted model surface. + /// + /// This is a proof-bearing terminal classification, not a generic parser + /// error. It may be returned only after authoritative usage was committed, + /// no assistant answer was admitted, no application or hosted tool effect + /// crossed the host boundary, and the adapter's deployment contract proves + /// the refused native item was contained. Retained-session turns and + /// incomplete streams must use [`Self::ProviderFailed`] instead. + #[error("AI provider-native item was rejected")] + StatelessNativeItemRejected, /// Runtime has not passed startup/restore readiness checks. #[error("AI runtime is not ready")] RuntimeNotReady, @@ -79,6 +90,7 @@ impl AiError { Self::ReauthorizationFailed => "AI_REAUTHORIZATION_FAILED", Self::ToolExecutionFailed => "AI_TOOL_EXECUTION_FAILED", Self::ProviderFailed => "AI_PROVIDER_FAILED", + Self::StatelessNativeItemRejected => "AI_PROVIDER_FAILED", Self::RuntimeNotReady => "AI_RUNTIME_NOT_READY", Self::PersistenceFailed => "AI_PERSISTENCE_FAILED", Self::ProviderSessionDeferred => "AI_PROVIDER_SESSION_DEFERRED", @@ -86,6 +98,19 @@ impl AiError { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stateless_native_item_rejection_keeps_the_provider_failure_public_code() { + assert_eq!( + AiError::StatelessNativeItemRejected.public_code(), + "AI_PROVIDER_FAILED" + ); + } +} + impl ErrorExtensions for AiError { fn extend(&self) -> async_graphql::Error { async_graphql::Error::new(self.to_string()).extend_with(|_, extensions| { diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 424475e9..c39378a2 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-21 +last_reviewed: 2026-08-22 review_by: 2027-02-01 supersedes: [] --- @@ -18,6 +18,38 @@ 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.86.0] - 2026-08-22 + +Persistent schema module: **0.63.0** (unchanged from 0.85.0). + +### Added + +- `ProviderError::StatelessNativeItemRejected` is the adapter-to-executor + terminal signal for a completed provider-native item that a stateless local + deployment proves was contained outside the admitted host tool surface. +- `AiError::StatelessNativeItemRejected`, supplied by + `graphql-orm-ai-tool-profiles` 0.9.0, carries the executor's stronger proof + into the read-only and supervised coordinators. + +### Changed + +- A proven stateless native-item refusal now terminates `Failed` with outcome + code `provider_native_item_rejected` and admits retry instead of becoming + `RecoveryRequired/provider_turn_uncertain`. +- The provider-call executor drains and validates authoritative completion and + usage before accepting this proof, settles actual tokens/cost, and commits + the budget reservation even though no provider result is returned. + +### Security + +- The certain path is limited to `StatelessReplay` with no response cursor, + answer, citation, application-tool event, hosted-tool event, or unknown + provider event. An incomplete stream, retained turn, answer delta, or any + unproven native effect remains ordinary provider uncertainty. + +There is no schema, data, protected-payload, GraphQL SDL, backup or restore +migration in this release. + ## [0.85.0] - 2026-08-21 Persistent schema module: **0.63.0** (unchanged from 0.84.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 41e05898..05599622 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.85.0" +version = "0.86.0" 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 c18ae87c..4d5e7018 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-21 +last_reviewed: 2026-08-22 review_by: 2027-02-01 supersedes: [] --- @@ -19,6 +19,34 @@ 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.85.0 to 0.86.0: metered stateless native-item refusal + +Adopt `graphql-orm-ai` 0.86.0 and `graphql-orm-ai-tool-profiles` 0.9.0 from one +reviewed full monorepo revision. The AI schema module remains **0.63.0**. There +is no database, data, table, column, index, constraint, backfill, GraphQL SDL, +protected-payload, backup or restore migration. + +An adapter may end a dispatched stream with +`ProviderError::StatelessNativeItemRejected` only after it has emitted an +authoritative `Usage` and `ResponseCompleted`, and only when its deployment +contract proves the refused provider-native item was contained. The executor +accepts that claim only for `ModelContinuationMode::StatelessReplay` with no +provider cursor, assistant text, citation, application-tool event, +provider-hosted-tool event, or unknown event. It settles the authoritative +usage, commits the reservation, and returns +`AiError::StatelessNativeItemRejected`. Do not use either variant for a parser +error, incomplete stream, retained session, unmetered response, or an operation +that might have escaped the provider sandbox. + +The read-only and supervised coordinators close this proof as `Failed` with +outcome code `provider_native_item_rejected`. That code is explicitly admitted +by `classify_run_retry` when no assistant output exists, so the failure record +offers a new run over the same user message. All generic provider errors still +close for recovery because their effects remain uncertain. Clients that +previously rendered this exact adapter refusal as +`provider_turn_uncertain` should render the new bounded failure code and expose +their existing retry action. + ## 0.84.0 to 0.85.0: executable federated bounded capability delivery Adopt `graphql-orm-ai` 0.85.0 and `graphql-orm-ai-tool-profiles` 0.8.0 at one diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 81ed8b5c..7da56147 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.85.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.86.0", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -190,6 +190,12 @@ A denial at reservation is pre-transport and certain: the run fails with `provider_budget_denied` and stays retryable. See the [usage and budgets guide](docs/usage-and-budgets.md). +A local stateless adapter can likewise close a refused provider-native item as +`provider_native_item_rejected` only after the completed turn's authoritative +usage is committed and both adapter and executor prove there was no admitted +answer or host tool effect. That failure is retryable. Incomplete, retained, or +otherwise ambiguous provider turns remain recovery-required. + ## Reasoning effort profiles `ModelReasoningEffort` is the closed provider-neutral selection: diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 8ee73fb1..07c177a7 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,11 +10,16 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.85.0` with AI schema module +`graphql-orm-ai` is at crate version `0.86.0` with AI schema module `0.63.0`. It uses workspace `graphql-orm` `0.23.0`, backend-neutral -`graphql-orm-ai-tool-profiles` `0.8.0`, and external `agql-auth` +`graphql-orm-ai-tool-profiles` `0.9.0`, and external `agql-auth` `0.15.0` at `e841ffd382082ad7419be259fe957f949b956ff7`. +Completed stateless local-provider turns can carry a proof-bearing contained +native-item refusal after authoritative usage settlement. Those runs close as +retryable `Failed/provider_native_item_rejected`; every ambiguous provider +turn retains the recovery-required boundary. + The active work order, dependencies, and exit gates are maintained in the [AI production-readiness plan](../../../docs/plans/active/ai-production-readiness/README.md). This page is the concise crate-local capability boundary; detailed design and diff --git a/crates/graphql-orm-ai/src/orm_background.rs b/crates/graphql-orm-ai/src/orm_background.rs index 5f54329b..f8ef8eec 100644 --- a/crates/graphql-orm-ai/src/orm_background.rs +++ b/crates/graphql-orm-ai/src/orm_background.rs @@ -4037,6 +4037,7 @@ fn classify_background_retrieval_error( | ProviderError::BudgetDenied | ProviderError::Unsupported | ProviderError::Rejected + | ProviderError::StatelessNativeItemRejected | ProviderError::NewlyBoundTurnRejected(_) | ProviderError::Cancelled | ProviderError::Classified(_) => { diff --git a/crates/graphql-orm-ai/src/orm_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index 7b1cecd4..6f5fa5bc 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -476,11 +476,14 @@ pub trait AiAgentProviderTurnExecutor: Send + Sync { /// Executes one exactly planned turn for the current attempt/generation. /// /// Returning [`AiError::PreTransportBudgetDenied`] is a proof-bearing - /// contract: the - /// denial must have occurred before provider transport, and no budget - /// reservation may remain held. Once transport might have occurred, an - /// executor must return [`AiError::ProviderFailed`] instead so the - /// coordinator preserves uncertainty. + /// contract: the denial must have occurred before provider transport, and + /// no budget reservation may remain held. Returning + /// [`AiError::StatelessNativeItemRejected`] is also proof-bearing: the + /// completed StatelessReplay turn's authoritative usage must already be + /// committed, no answer or admitted tool effect may exist, and the adapter + /// must prove the refused native item was contained. Every other error + /// after transport might have occurred must be [`AiError::ProviderFailed`] + /// so the coordinator preserves uncertainty. /// /// # Errors /// @@ -1716,6 +1719,11 @@ impl AiReadOnlyAgentCoordinator { .finish_failed(&lease, &guard, "provider_budget_denied") .await; } + Err(ProviderTurnFailure::StatelessNativeItemRejected) => { + return self + .finish_failed(&lease, &guard, "provider_native_item_rejected") + .await; + } Err(ProviderTurnFailure::LeaseLost(error)) => return Err(error), Err(ProviderTurnFailure::Cancelled(settlement)) => { self.settle_interrupted_provider_session(&lease, &guard, settlement) @@ -2503,6 +2511,7 @@ impl AiReadOnlyAgentCoordinator { enum ProviderTurnFailure { Provider, BudgetDenied, + StatelessNativeItemRejected, Deferred, LeaseLost(AiError), /// Owner cancellation won the fence; the value reports what the resulting @@ -2510,16 +2519,20 @@ enum ProviderTurnFailure { Cancelled(crate::AiRunInterruptSettlement), } -/// Separates a certain pre-transport refusal from an uncertain provider turn. +/// 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. Every other -/// executor error keeps the fail-closed uncertain classification. +/// 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 +/// fail-closed uncertain classification. const fn classify_provider_turn_failure(error: &AiError) -> ProviderTurnFailure { match error { AiError::PreTransportBudgetDenied => ProviderTurnFailure::BudgetDenied, + AiError::StatelessNativeItemRejected => ProviderTurnFailure::StatelessNativeItemRejected, _ => ProviderTurnFailure::Provider, } } @@ -3877,6 +3890,63 @@ mod tests { ); } + #[tokio::test] + async fn metered_stateless_native_item_refusal_is_failed_and_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::StatelessNativeItemRejected)])), + 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 proof-bearing native-item refusal should fail cleanly"); + + 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_native_item_rejected".to_owned()] + ); + 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_native_item_rejected"), + ), + crate::AiRunRetryAdmission::Allowed + ); + } + #[tokio::test] async fn generic_budget_denial_cannot_claim_pre_transport_certainty() { 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 027469f2..a50e4bfb 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -1099,6 +1099,16 @@ impl AiSupervisedAgentCoordinator { .finish_failed(&lease, &guard, "provider_budget_denied") .await; } + Err(SupervisedProviderTurnFailure::StatelessNativeItemRejected) => { + if let Some((service, claim)) = &reclaimed { + let _ = service + .require_cleanup(claim, "provider_session_reclaimed_handoff_failed") + .await; + } + return self + .finish_failed(&lease, &guard, "provider_native_item_rejected") + .await; + } Err(SupervisedProviderTurnFailure::LeaseLost(error)) => return Err(error), }; if self.run_control.cancellation(&lease).await?.is_some() { @@ -1713,10 +1723,11 @@ impl AiSupervisedAgentCoordinator { enum SupervisedProviderTurnFailure { Provider, BudgetDenied, + StatelessNativeItemRejected, LeaseLost(AiError), } -/// Separates a certain pre-transport budget refusal from an uncertain turn. +/// 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 @@ -1724,6 +1735,9 @@ enum SupervisedProviderTurnFailure { const fn classify_supervised_turn_failure(error: &AiError) -> SupervisedProviderTurnFailure { match error { AiError::PreTransportBudgetDenied => SupervisedProviderTurnFailure::BudgetDenied, + AiError::StatelessNativeItemRejected => { + SupervisedProviderTurnFailure::StatelessNativeItemRejected + } _ => SupervisedProviderTurnFailure::Provider, } } diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 535ad870..5e26e018 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1851,6 +1851,18 @@ pub enum ProviderError { /// Provider rejected safe request metadata. #[error("provider rejected request")] Rejected, + /// A completed, metered stateless turn contained one provider-native item + /// that the adapter's deployment contract proves was contained outside + /// the admitted host tool surface. + /// + /// Adapters must emit this only as the terminal stream error after + /// authoritative usage and completion events. The call executor performs + /// the remaining stateless/no-answer/no-tool proof before promoting it to + /// [`AiError::StatelessNativeItemRejected`]. A parse error, incomplete + /// stream, retained turn, or uncontained native operation is + /// [`Self::Rejected`] or another ordinary uncertain provider error. + #[error("stateless provider-native item rejected")] + StatelessNativeItemRejected, /// A newly bound empty retained thread failed a closed activation check. #[error("provider newly-bound turn rejected: {0}")] NewlyBoundTurnRejected(AiCodexBoundTurnRejection), @@ -1877,6 +1889,7 @@ impl ProviderError { | Self::BudgetDenied | Self::Unsupported | Self::Rejected + | Self::StatelessNativeItemRejected | Self::NewlyBoundTurnRejected(_) => AiProviderFailureCategory::ProviderRejection, Self::RateLimited => AiProviderFailureCategory::RateLimit, Self::Unavailable => AiProviderFailureCategory::TransportUnavailable, diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index f0568621..afb5f5d3 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -3408,6 +3408,7 @@ impl AiProviderCallExecutor { let mut started_builtin_calls = BTreeMap::::new(); let mut completed_builtin_calls = BTreeSet::new(); let mut reasoning_summary_bytes = 0_u64; + let mut stateless_native_item_rejected = false; let mut live_coalescer = self .live_delta_sink .as_ref() @@ -3460,10 +3461,22 @@ impl AiProviderCallExecutor { let Some(item) = item else { break; }; - let event = item.map_err(|error| { - self.record_provider_failure(error.safe_category()); - AiError::ProviderFailed - })?; + if stateless_native_item_rejected { + self.record_provider_failure(AiProviderFailureCategory::ProtocolViolation); + return Err(AiError::ProviderFailed); + } + let event = match item { + Ok(event) => event, + Err(error @ ProviderError::StatelessNativeItemRejected) => { + self.record_provider_error(&error); + stateless_native_item_rejected = true; + continue; + } + Err(error) => { + self.record_provider_error(&error); + return Err(AiError::ProviderFailed); + } + }; let event_bytes = serde_json::to_vec(&event) .map_err(|_| AiError::ProviderFailed)? .len(); @@ -3658,6 +3671,22 @@ impl AiProviderCallExecutor { if started_builtin_calls.len() != completed_builtin_calls.len() { return Err(AiError::ProviderFailed); } + if stateless_native_item_rejected + && (request_snapshot.continuation_mode != ModelContinuationMode::StatelessReplay + || provider_response_id.is_some() + || events.iter().any(|event| { + !matches!( + event, + ProviderEvent::ResponseStarted { response_id: None } + | ProviderEvent::ReasoningSummaryDelta { .. } + | ProviderEvent::Usage { .. } + | ProviderEvent::ResponseCompleted { response_id: None } + ) + })) + { + self.record_provider_failure(AiProviderFailureCategory::ProtocolViolation); + return Err(AiError::ProviderFailed); + } let mut builtin_usage = AiProviderBuiltinUsage::default(); for (call_id, kind) in &started_builtin_calls { if !completed_builtin_calls.contains(call_id) { @@ -3749,6 +3778,10 @@ impl AiProviderCallExecutor { ) .await?; + if stateless_native_item_rejected { + return Err(AiError::StatelessNativeItemRejected); + } + Ok(AiProviderCallResult { session_id: lease.session_id(), run_id: lease.run_id(), @@ -9190,6 +9223,78 @@ mod tests { .expect("caller can terminally finish after handling the result"); } + #[tokio::test] + async fn completed_stateless_native_item_refusal_commits_usage_and_returns_exact_proof() { + let fixture = fixture_with_provider( + MockProvider::new(vec![ + ProviderEvent::ResponseStarted { response_id: None }, + ProviderEvent::Usage { + input_tokens: 12, + output_tokens: 3, + cached_input_tokens: 2, + }, + ProviderEvent::ResponseCompleted { response_id: None }, + ]) + .with_stateless_native_item_rejection(), + ) + .await; + let mut provider_plan = plan(&fixture); + provider_plan.request.continuation_mode = ModelContinuationMode::StatelessReplay; + 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("test provider limits should validate"), + ); + + assert!(matches!( + executor.execute(&fixture.lease, provider_plan).await, + Err(AiError::StatelessNativeItemRejected) + )); + assert_eq!(fixture.mock.request_count(), 1); + assert_eq!(reservation_state(&fixture.database).await, "committed"); + } + + #[tokio::test] + async fn stateless_native_item_refusal_with_an_answer_remains_uncertain() { + let fixture = fixture_with_provider( + MockProvider::new(vec![ + ProviderEvent::ResponseStarted { response_id: None }, + ProviderEvent::TextDelta { + text: "an answer that must prevent clean retry".to_owned(), + }, + ProviderEvent::Usage { + input_tokens: 12, + output_tokens: 3, + cached_input_tokens: 2, + }, + ProviderEvent::ResponseCompleted { response_id: None }, + ]) + .with_stateless_native_item_rejection(), + ) + .await; + let mut provider_plan = plan(&fixture); + provider_plan.request.continuation_mode = ModelContinuationMode::StatelessReplay; + 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("test provider limits should validate"), + ); + + assert!(matches!( + executor.execute(&fixture.lease, provider_plan).await, + Err(AiError::ProviderFailed) + )); + assert_eq!(reservation_state(&fixture.database).await, "uncertain"); + } + #[tokio::test] async fn provider_failures_emit_only_the_closed_safe_category() { let fixture = fixture_with_provider( diff --git a/crates/graphql-orm-ai/src/providers/mock.rs b/crates/graphql-orm-ai/src/providers/mock.rs index 67bbafa6..2e4fcf4b 100644 --- a/crates/graphql-orm-ai/src/providers/mock.rs +++ b/crates/graphql-orm-ai/src/providers/mock.rs @@ -51,6 +51,8 @@ pub struct MockProvider { #[cfg(test)] stream_failure: Option, #[cfg(test)] + stateless_native_item_rejected: bool, + #[cfg(test)] prepare_failure: Option, #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] provider_session_cursor: Option, @@ -90,6 +92,8 @@ impl MockProvider { #[cfg(test)] stream_failure: None, #[cfg(test)] + stateless_native_item_rejected: false, + #[cfg(test)] prepare_failure: None, #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] provider_session_cursor: None, @@ -141,6 +145,12 @@ impl MockProvider { self } + #[cfg(test)] + pub(crate) fn with_stateless_native_item_rejection(mut self) -> Self { + self.stateless_native_item_rejected = true; + self + } + #[cfg(test)] pub(crate) fn with_prepare_failure( mut self, @@ -277,9 +287,17 @@ impl AiProvider for MockProvider { }; #[cfg(not(test))] let events = self.events.clone(); - Ok(Box::pin(stream::iter( - events.iter().cloned().map(Ok).collect::>(), - ))) + #[cfg(test)] + let stream_items = { + let mut stream_items = events.iter().cloned().map(Ok).collect::>(); + if self.stateless_native_item_rejected { + stream_items.push(Err(ProviderError::StatelessNativeItemRejected)); + } + stream_items + }; + #[cfg(not(test))] + let stream_items = events.iter().cloned().map(Ok).collect::>(); + Ok(Box::pin(stream::iter(stream_items))) } #[cfg(any(feature = "sqlite", feature = "postgres"))] diff --git a/crates/graphql-orm-ai/src/run_state.rs b/crates/graphql-orm-ai/src/run_state.rs index d5602f4c..6709345d 100644 --- a/crates/graphql-orm-ai/src/run_state.rs +++ b/crates/graphql-orm-ai/src/run_state.rs @@ -407,6 +407,7 @@ const fn is_retryable_failure_code(code: &str) -> bool { | b"provider_unavailable" | b"provider_rate_limited" | b"provider_request_rejected" + | b"provider_native_item_rejected" | b"runtime_not_ready" ) } diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 2064c687..012e8d43 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.23.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.85.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | -| `graphql-orm-ai-tool-profiles` | `0.8.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm-ai` | `0.86.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai-tool-profiles` | `0.9.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.23.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | From d554b5ee814596625202277f4ee2b19ead3a0f59 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 03:25:53 +1000 Subject: [PATCH 2/2] fix(ai): keep error tests after implementations --- crates/graphql-orm-ai-tool-profiles/src/error.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/graphql-orm-ai-tool-profiles/src/error.rs b/crates/graphql-orm-ai-tool-profiles/src/error.rs index 62f009b3..a30c325d 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/error.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/error.rs @@ -98,6 +98,14 @@ impl AiError { } } +impl ErrorExtensions for AiError { + fn extend(&self) -> async_graphql::Error { + async_graphql::Error::new(self.to_string()).extend_with(|_, extensions| { + extensions.set("code", self.public_code()); + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -110,11 +118,3 @@ mod tests { ); } } - -impl ErrorExtensions for AiError { - fn extend(&self) -> async_graphql::Error { - async_graphql::Error::new(self.to_string()).extend_with(|_, extensions| { - extensions.set("code", self.public_code()); - }) - } -}