From 1d768a079dfca3963defa43658507a737a040365 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sun, 23 Aug 2026 17:11:48 +1000 Subject: [PATCH] feat(ai): expose authorized tool call details --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 30 +++ crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 20 ++ crates/graphql-orm-ai/README.md | 6 +- .../src/orm_tool_result_preview.rs | 177 ++++++++++++++++-- crates/graphql-orm-ai/src/provider_calls.rs | 137 +++++++++++++- .../graphql-orm-ai/src/tool_result_preview.rs | 28 +++ .../graphql-orm-ai/tests/session_graphql.rs | 4 +- docs/reference/workspace-packages.md | 2 +- 10 files changed, 390 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82d39d35..9c3ddc52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.91.1" +version = "0.92.0" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 3a9064e0..16f402ee 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,36 @@ 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.92.0] - 2026-08-23 + +Persistent schema module: **0.63.0** (unchanged from 0.91.1). + +### Added + +- `AiToolCallResultPreviewView` now includes an optional host-projected + `arguments` value. `AiToolResultPreviewAuthorizer` has a default-deny + `authorize_and_project_arguments` seam so existing hosts disclose nothing + until they opt in. +- Owner-authorized previews now return the existing versioned, content-free + `AiApplicationToolFailureEnvelope` for a durably failed read tool when its + state, public classification, stored authorization code, and protected + envelope agree exactly. + +### Security + +- The preview service still rehydrates the current principal and checks exact + session ownership plus current session/scope read authority. Successful + results retain descriptor fingerprint, current tool policy, disclosure + schema, classification, depth, record, and byte validation. +- Argument projection is independently default-deny, host-authored, and + capped at 64 KiB. A missing/stale descriptor can return only the public safe + failure envelope, never stored arguments. Secret-classified results remain + unavailable to the browser. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, backup, or restore migration. The GraphQL SDL adds the +nullable `Arguments` field to the existing tool-call preview object. + ## [0.91.1] - 2026-08-23 Persistent schema module: **0.63.0** (unchanged from 0.91.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index ee6e6328..5aeca9e9 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.91.1" +version = "0.92.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 959e49b5..4c7991d0 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,26 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.91.1 to 0.92.0: owner-authorized tool arguments and safe failures + +Adopt `graphql-orm-ai` 0.92.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, protected-payload, backup, or restore migration. + +The existing `aiToolCallResultPreview` GraphQL object adds nullable +`arguments`. Successful `preview` values are unchanged. A durably failed +read-only call can now return the crate-authored public failure envelope as +`preview`, including its stable code and retryable flag, instead of returning +no object. Clients should render that envelope as a safe error and must not +infer resolver details that are absent from it. + +`AiToolResultPreviewAuthorizer` implementations may override the new +`authorize_and_project_arguments` method. Its default returns `None`, so +existing implementations remain default-deny. Hosts opting in must project +only arguments safe for the rehydrated current browser principal and must +withhold secrets. Existing direct `AiToolCallResultPreviewView` struct literals +must initialize the new `arguments` field. + ## 0.91.0 to 0.91.1: Codex FixedBroker projection and retained session admission Adopt `graphql-orm-ai` 0.91.1 from one reviewed full monorepo revision. The AI diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 0d70fb38..b7d3dad3 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.91.1", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.92.0", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -79,6 +79,10 @@ the compiled test-backed recipe and the missing reusable bootstrap API. - One bounded owner-authorized conversation bootstrap combines the newest messages, durable watermark, active/recent runs, tool calls, provider activity and retention reset state for race-free replay/live handoff. +- An owner-authorized tool-call preview rehydrates current authority before + returning host-projected arguments and either a disclosure-validated result + or the exact content-free safe failure envelope. Secret results never enter + this browser contract. - Provider-neutral adapters plus deterministic network-free mocks. - Optional provider profiles, attachments, skills, UI intents, rules, and usage/pricing controls, each behind independent proof and policy boundaries. diff --git a/crates/graphql-orm-ai/src/orm_tool_result_preview.rs b/crates/graphql-orm-ai/src/orm_tool_result_preview.rs index 7d9fc168..2fa30d4a 100644 --- a/crates/graphql-orm-ai/src/orm_tool_result_preview.rs +++ b/crates/graphql-orm-ai/src/orm_tool_result_preview.rs @@ -19,6 +19,8 @@ use crate::{ GraphqlInvocationContext, ProtectedContentEnvelope, ToolGraphqlRequest, }; +const MAXIMUM_BROWSER_ARGUMENT_BYTES: usize = 64 * 1024; + /// Generated-ORM result-preview service for application hosts. pub struct OrmAiToolCallResultPreviewService { database: Database, @@ -55,6 +57,30 @@ impl OrmAiToolCallResultPreviewService { .await .map_err(map_protection) } + + async fn project_arguments( + &self, + principal: &agql_auth::ResolvedPrincipal, + scope: &crate::AiScope, + descriptor: &crate::AiToolDescriptor, + arguments: &serde_json::Value, + ) -> Result, AiError> { + let Some(projected) = self + .authorizer + .authorize_and_project_arguments(principal, scope, descriptor, arguments) + .await? + else { + return Ok(None); + }; + if serde_json::to_vec(&projected) + .map_err(|_| AiError::PersistenceFailed)? + .len() + > MAXIMUM_BROWSER_ARGUMENT_BYTES + { + return Err(AiError::Forbidden); + } + Ok(Some(projected)) + } } #[async_trait] @@ -117,13 +143,19 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { .map_err(|error| map_orm(OrmPublicError::from(error)))? .filter(|run| run.session_id == session.id) .ok_or(AiError::NotFound)?; + let successful = + call.state == "completed" && call.authorization_code.as_deref() == Some("allowed"); + let safe_failure = call + .authorization_code + .as_deref() + .and_then(application_tool_failure_code) + .filter(|_| matches!(call.state.as_str(), "execution_failed" | "egress_denied")); if call.run_id != run.id - || call.state != "completed" + || (!successful && safe_failure.is_none()) || call.completed_at.is_none() || call.payload_purged_at.is_some() || call.protected_result.is_none() || call.protected_arguments.is_none() - || call.authorization_code.as_deref() != Some("allowed") { return Ok(None); } @@ -132,8 +164,62 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { .runtime .tool_catalog() .descriptor(&tool_id) - .filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint) - .ok_or(AiError::Forbidden)?; + .filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint); + let policy = self + .runtime + .content_protection_policy_resolver() + .resolve(current.principal(), &scope) + .await?; + if !policy.ready || policy.scope != scope { + return Err(AiError::RuntimeNotReady); + } + if let Some(code) = safe_failure { + let classification = parse_classification( + call.result_classification + .as_deref() + .ok_or(AiError::PersistenceFailed)?, + )?; + if classification != DataClassification::Public { + return Err(AiError::PersistenceFailed); + } + let stored = self + .open( + &policy, + protection_context(call.id, "protected_result", &scope), + call.protected_result + .as_ref() + .ok_or(AiError::PersistenceFailed)?, + ) + .await?; + let preview = extract_safe_failure(&stored, code)?; + let arguments = if let Some(descriptor) = descriptor + && descriptor.browser_result_preview.is_some() + { + let stored_arguments = self + .open( + &policy, + protection_context(call.id, "protected_arguments", &scope), + call.protected_arguments + .as_ref() + .ok_or(AiError::PersistenceFailed)?, + ) + .await?; + self.project_arguments(¤t, &scope, descriptor, &stored_arguments) + .await? + } else { + None + }; + return Ok(Some(AiToolCallResultPreviewView { + session_id: session.id, + run_id: run.id, + tool_call_id: call.id, + tool_id: call.tool_id, + classification: classification_name(classification).to_owned(), + arguments: arguments.map(async_graphql::Json), + preview: async_graphql::Json(preview), + })); + } + let descriptor = descriptor.ok_or(AiError::Forbidden)?; let preview_policy = match descriptor.browser_result_preview { Some(policy) => policy, None => return Ok(None), @@ -154,14 +240,6 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { if classification > preview_policy.maximum_classification { return Ok(None); } - let policy = self - .runtime - .content_protection_policy_resolver() - .resolve(current.principal(), &scope) - .await?; - if !policy.ready || policy.scope != scope { - return Err(AiError::RuntimeNotReady); - } let arguments = self .open( &policy, @@ -203,6 +281,14 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { if preauthorization.principal().reference() != &requested_reference { return Err(AiError::ReauthorizationFailed); } + let arguments = self + .project_arguments( + preauthorization.principal(), + &scope, + descriptor, + &request.variables, + ) + .await?; let stored = self .open( &policy, @@ -251,6 +337,7 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { tool_call_id: call.id, tool_id: descriptor.id.as_str().to_owned(), classification: classification_name(classification).to_owned(), + arguments: arguments.map(async_graphql::Json), preview: async_graphql::Json(preview), })) } @@ -296,6 +383,41 @@ fn extract_exact_result(value: &serde_json::Value) -> Result<&serde_json::Value, object.get("data").ok_or(AiError::PersistenceFailed) } +fn application_tool_failure_code(value: &str) -> Option { + use crate::AiApplicationToolFailureCode as Code; + + match value { + "invalid_arguments" => Some(Code::InvalidArguments), + "selection_too_large" => Some(Code::SelectionTooLarge), + "relationship_depth_exceeded" => Some(Code::RelationshipDepthExceeded), + "result_budget_exceeded" => Some(Code::ResultBudgetExceeded), + "capability_stale" => Some(Code::CapabilityStale), + "authorization_denied" => Some(Code::AuthorizationDenied), + "temporarily_unavailable" => Some(Code::TemporarilyUnavailable), + "tool_unavailable" => Some(Code::ToolUnavailable), + "resolver_validation_failed" => Some(Code::ResolverValidationFailed), + "not_found" => Some(Code::NotFound), + _ => None, + } +} + +fn extract_safe_failure( + value: &serde_json::Value, + code: crate::AiApplicationToolFailureCode, +) -> Result { + let object = value.as_object().ok_or(AiError::PersistenceFailed)?; + if object.len() != 4 + || object.get("version").and_then(serde_json::Value::as_u64) + != Some(u64::from(crate::AI_APPLICATION_TOOL_FAILURE_VERSION)) + || object.get("ok").and_then(serde_json::Value::as_bool) != Some(false) + || object.get("code").and_then(serde_json::Value::as_str) != Some(code.as_str()) + || object.get("retryable").and_then(serde_json::Value::as_bool) != Some(code.retryable()) + { + return Err(AiError::PersistenceFailed); + } + Ok(value.clone()) +} + fn json_shape(value: &serde_json::Value, depth: usize) -> Result<(usize, u64), AiError> { if depth > 64 { return Err(AiError::Forbidden); @@ -349,3 +471,34 @@ const fn classification_name(value: DataClassification) -> &'static str { DataClassification::Secret => "secret", } } + +#[cfg(test)] +mod tests { + use super::{application_tool_failure_code, extract_safe_failure}; + use crate::{AiApplicationToolFailureCode, AiApplicationToolFailureEnvelope, AiError}; + + #[test] + fn safe_failure_preview_accepts_only_the_exact_content_free_envelope() { + let code = AiApplicationToolFailureCode::AuthorizationDenied; + let envelope = AiApplicationToolFailureEnvelope::new(code).to_json(); + assert_eq!(application_tool_failure_code(code.as_str()), Some(code)); + assert_eq!( + extract_safe_failure(&envelope, code).expect("exact safe envelope should validate"), + envelope + ); + + let mut mismatched = + AiApplicationToolFailureEnvelope::new(AiApplicationToolFailureCode::ToolUnavailable) + .to_json(); + assert!(matches!( + extract_safe_failure(&mismatched, code), + Err(AiError::PersistenceFailed) + )); + mismatched["code"] = serde_json::Value::String(code.as_str().to_owned()); + mismatched["detail"] = serde_json::Value::String("must not cross".to_owned()); + assert!(matches!( + extract_safe_failure(&mismatched, code), + Err(AiError::PersistenceFailed) + )); + } +} diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index afb5f5d3..d82d93ba 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4300,6 +4300,16 @@ mod tests { #[async_trait] impl AiToolResultPreviewAuthorizer for RecordIdPreviewAuthorizer { + async fn authorize_and_project_arguments( + &self, + _principal: &ResolvedPrincipal, + _scope: &AiScope, + _descriptor: &AiToolDescriptor, + arguments: &serde_json::Value, + ) -> Result, AiError> { + Ok(self.0.load(Ordering::SeqCst).then(|| arguments.clone())) + } + async fn authorize_and_project( &self, _principal: &ResolvedPrincipal, @@ -10696,7 +10706,7 @@ mod tests { .observe_tool_result(&persisted) .expect("durable result should match the pending provider call"); - let record = AiToolCallRecord::find_by_id(&fixture.database, &persisted.id().0) + let mut record = AiToolCallRecord::find_by_id(&fixture.database, &persisted.id().0) .await .expect("tool call lookup should succeed") .expect("tool call should exist"); @@ -10785,7 +10795,132 @@ mod tests { .expect("reviewed browser preview should be present"); assert_eq!(preview.run_id, fixture.lease.run_id().0); assert_eq!(preview.tool_id, "records.read"); + assert_eq!( + preview.arguments.map(|arguments| arguments.0), + Some(json!({"recordId": "54"})) + ); assert_eq!(preview.preview.0, json!({"record": {"recordId": "54"}})); + let successful_protected_result = record + .protected_result + .clone() + .expect("successful result should remain protected"); + let successful_authorization_policy_version = record.authorization_policy_version.clone(); + let successful_authorization_state_digest = record.authorization_state_digest.clone(); + let successful_disclosure_schema_fingerprint = record.disclosure_schema_fingerprint.clone(); + let successful_result_classification = record.result_classification.clone(); + let protection_policy = fixture + .runtime + .content_protection_policy_resolver() + .resolve(&fixture.principal, &fixture.scope) + .await + .expect("test protection policy should resolve"); + let safe_failure = AiApplicationToolFailureEnvelope::new( + AiApplicationToolFailureCode::AuthorizationDenied, + ) + .to_json(); + let protected_safe_failure = fixture + .runtime + .content_protector() + .protect( + &protection_policy, + &ContentProtectionContext { + entity: "graphql_orm_ai_tool_calls".to_owned(), + row_id: record.id.to_string(), + field: "protected_result".to_owned(), + scope: fixture.scope.clone(), + }, + safe_failure.clone(), + ) + .await + .expect("safe failure should protect"); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + let protected_safe_failure = serde_json::to_value(protected_safe_failure) + .expect("protected failure should serialize"); + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + protected_result: Some(Some(protected_safe_failure)), + authorization_code: Some(Some( + AiApplicationToolFailureCode::AuthorizationDenied + .as_str() + .to_owned(), + )), + authorization_policy_version: Some(None), + authorization_state_digest: Some(None), + result_classification: Some(Some("public".to_owned())), + state: Some("execution_failed".to_owned()), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test call should become one safe failure"); + let failed_preview = preview_service + .result_preview( + &fixture.principal, + AiToolCallResultPreviewInput { + session_id: fixture.lease.session_id().0, + tool_call_id: persisted.id().0, + }, + ) + .await + .expect("safe failed preview should resolve") + .expect("safe failed preview should be visible"); + assert_eq!(failed_preview.classification, "public"); + assert_eq!( + failed_preview.arguments.map(|arguments| arguments.0), + Some(json!({"recordId": "54"})) + ); + assert_eq!(failed_preview.preview.0, safe_failure); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + protected_result: Some(Some(successful_protected_result)), + authorization_code: Some(Some("allowed".to_owned())), + authorization_policy_version: Some( + successful_authorization_policy_version, + ), + authorization_state_digest: Some( + successful_authorization_state_digest, + ), + disclosure_schema_fingerprint: Some( + successful_disclosure_schema_fingerprint, + ), + result_classification: Some(successful_result_classification), + state: Some("completed".to_owned()), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test call should restore its successful result"); assert!(matches!( preview_service .result_preview( diff --git a/crates/graphql-orm-ai/src/tool_result_preview.rs b/crates/graphql-orm-ai/src/tool_result_preview.rs index 8ed05200..f51ef212 100644 --- a/crates/graphql-orm-ai/src/tool_result_preview.rs +++ b/crates/graphql-orm-ai/src/tool_result_preview.rs @@ -31,7 +31,17 @@ pub struct AiToolCallResultPreviewView { pub tool_id: String, /// Persisted result classification. pub classification: String, + /// Host-projected tool arguments safe for the current browser principal. + /// + /// `None` means the host withheld the arguments. This field is independently + /// authorized from the result and never falls back to the protected stored + /// value. + pub arguments: Option>, /// Host-projected value validated against the descriptor disclosure schema. + /// + /// A failed read tool returns only the crate-authored, content-free + /// [`crate::AiApplicationToolFailureEnvelope`] after its persisted code and + /// payload agree exactly. pub preview: async_graphql::Json, } @@ -45,6 +55,24 @@ pub struct AiToolCallResultPreviewView { /// fingerprinted descriptor policy and disclosure schema. #[async_trait] pub trait AiToolResultPreviewAuthorizer: Send + Sync { + /// Returns a currently authorized argument projection, or `None` to + /// disclose no arguments. + /// + /// The service has already rehydrated the current session owner, checked + /// session/scope read authority, reopened the protected value, and bound + /// the exact registered descriptor. Implementations must withhold secret + /// values and keep the projection bounded. The library never substitutes + /// the stored arguments when this method returns `None`. + async fn authorize_and_project_arguments( + &self, + _principal: &ResolvedPrincipal, + _scope: &AiScope, + _descriptor: &AiToolDescriptor, + _arguments: &serde_json::Value, + ) -> Result, AiError> { + Ok(None) + } + /// Returns a currently authorized subset, or `None` to disclose nothing. async fn authorize_and_project( &self, diff --git a/crates/graphql-orm-ai/tests/session_graphql.rs b/crates/graphql-orm-ai/tests/session_graphql.rs index 8bed53bd..487903f6 100644 --- a/crates/graphql-orm-ai/tests/session_graphql.rs +++ b/crates/graphql-orm-ai/tests/session_graphql.rs @@ -74,6 +74,7 @@ impl AiToolCallResultPreviewService for RecordingToolPreviewService { tool_call_id: input.tool_call_id, tool_id: "records.read".to_owned(), classification: "internal".to_owned(), + arguments: Some(async_graphql::Json(json!({"recordId": "54"}))), preview: async_graphql::Json(json!({"recordId": "54"})), })) } @@ -533,7 +534,7 @@ async fn tool_result_preview_query_passes_exact_pair_and_current_principal() { let response = schema .execute( Request::new(format!( - "{{ aiToolCallResultPreview(input: {{ sessionId: \"{session_id}\", toolCallId: \"{tool_call_id}\" }}) {{ sessionId runId toolCallId toolId classification preview }} }}" + "{{ aiToolCallResultPreview(input: {{ sessionId: \"{session_id}\", toolCallId: \"{tool_call_id}\" }}) {{ sessionId runId toolCallId toolId classification arguments preview }} }}" )) .data(principal("preview-owner")), ) @@ -549,6 +550,7 @@ async fn tool_result_preview_query_passes_exact_pair_and_current_principal() { "toolCallId": tool_call_id, "toolId": "records.read", "classification": "internal", + "arguments": {"recordId": "54"}, "preview": {"recordId": "54"}, } }) diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 69ce6bfd..dcd2427f 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.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.91.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.92.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.26.0` | `crates/graphql-orm-macros` | `sqlite` | none |