diff --git a/Cargo.lock b/Cargo.lock index c6fd827..cfdc1f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.88.1" +version = "0.88.2" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index c9e9e61..c6ea55b 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,29 @@ 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.88.2] - 2026-08-22 + +Persistent schema module: **0.63.0** (unchanged from 0.88.1). + +### Fixed + +- Codex app-server registrations can now admit an exact capability-delivery + session binding after validating its model, reasoning effort, and embedded + executable/sandbox registration identity. Retained thread creation, resume, + failed-bind cleanup, and detached cleanup all recognize that complete + binding instead of rejecting it as though it were a raw registration + fingerprint. + +### Security + +- Capability-session admission accepts the validated binding object, never an + arbitrary fingerprint. The admitted fingerprint remains bound to the exact + delivery mode, capability-index set, static bootstrap tools, provider + projection, model, reasoning effort, and immutable Codex registration. + +There is no schema, data, protected-payload, GraphQL SDL, backup or restore +migration in this release. + ## [0.88.1] - 2026-08-22 Persistent schema module: **0.63.0** (unchanged from 0.88.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 6093034..1bf0772 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.88.1" +version = "0.88.2" 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 649b5fb..95ab6fd 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.88.1 to 0.88.2: retained capability-session admission + +Adopt `graphql-orm-ai` 0.88.2 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. + +Codex app-server hosts that persist +`AiProviderSessionDescriptor::new_with_capability_binding` descriptors must +install the same exact `AiProviderCapabilitySessionBinding` on the immutable +`AiCodexAppServerRegistration` before constructing the provider and cleanup +service. The registration rejects a binding unless its model and reasoning +effort are admitted and its embedded registration identity equals the exact +effort-bound executable, sandbox, launch-profile, bootstrap, and adapter +identity. + +The admission overlay deliberately does not alter that underlying identity: +the binding already incorporates it, avoiding a circular fingerprint. Hosts +must not accept or reconstruct a bare final fingerprint. Existing raw +registration descriptors remain compatible. + ## 0.88.0 to 0.88.1: Codex FixedBroker schema projection Adopt `graphql-orm-ai` 0.88.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 e4b8849..ac9245d 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -226,12 +226,20 @@ configuration or exact Codex registration, and expose only Set the same selected value on `ModelRequest::reasoning_effort` and `AiBudgetReservationRequest::reasoning_effort`. The runtime rejects an explicit value absent from the active model profile before provider execution. -For retained Codex sessions, create the provider-session descriptor with -`registration.provider_session_fingerprint(selected_effort)?`; the app-server -override affects subsequent turns, so changing effort requires exact cursor -cleanup and rebind. Effort selection neither enables visible reasoning -summaries nor grants tools, egress, filesystem, shell, browser, MCP, approval, -or mutation authority. See the +For retained Codex sessions without capability delivery, create the +provider-session descriptor with +`registration.provider_session_fingerprint(selected_effort)?`. A retained +capability-delivery host instead constructs the exact +`AiProviderCapabilitySessionBinding` from that raw fingerprint, installs the +binding with `registration.with_capability_session_binding(binding.clone())?`, +and persists a descriptor created by +`AiProviderSessionDescriptor::new_with_capability_binding`. The adapter +validates the binding's embedded raw identity and then recognizes its complete +fingerprint for create, resume, and cleanup. The app-server effort override +affects subsequent turns, so changing effort requires exact cursor cleanup and +rebind. Effort selection neither enables visible reasoning summaries nor +grants tools, egress, filesystem, shell, browser, MCP, approval, or mutation +authority. See the [provider-session guide](docs/provider-sessions-and-hosted-activity.md) and [migration guide](MIGRATION.md). diff --git a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md index 879f3a0..1cbb0c3 100644 --- a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md +++ b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md @@ -110,6 +110,44 @@ and bind the new effort fingerprint. Pre-0.80 v3 registration fingerprints are accepted only by the deletion adapter for draining; they are never valid for a turn or resume. +A retained capability-delivery descriptor persists the complete +`AiProviderCapabilitySessionBinding::fingerprint()` instead of that raw value. +Install the exact binding object on the Codex registration before constructing +the provider and cleanup adapter: + +```rust +let raw_registration = + registration.provider_session_fingerprint(selected_effort)?; +let binding = AiProviderCapabilitySessionBinding::new( + delivery_mode, + capability_index_set_fingerprint, + static_bootstrap_tool_fingerprints, + provider_projection_version, + logical_model, + selected_effort, + raw_registration, +)?; +let registration = registration + .with_capability_session_binding(binding.clone())?; +let descriptor = AiProviderSessionDescriptor::new_with_capability_binding( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.protocol_version(), + policy_fingerprint, + &binding, +)?; +``` + +Admission validates the model, effort, delivery mode, and embedded raw +registration identity; it never accepts a bare final fingerprint. The overlay +does not change `registration.identity()` because the binding already embeds +that value. Applying an identity-bearing registration builder afterward clears +all prior capability admissions, so hosts install the binding last. The same +exact admission fences empty-thread creation, retained turns, failed-bind +discard, and detached cleanup. Frozen `EagerExact`, `ProviderDeferred`, and +`FixedBroker` bindings are representable; `ClientDeferred` cannot freeze later +definition installation into one Codex thread and remains rejected. + For stateless operation the scope is one ephemeral Codex thread: the actor retains the selected effort through `thread/start` and its `turn/start`, then clears it only after the terminal turn notification. A later ephemeral 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 17cf6f1..f2a42e3 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -24,12 +24,12 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; use crate::{ - AiProvider, AiProviderFailureCategory, AiProviderRunBinding, AiProviderRunCloseOutcome, - AiProviderRunCloseReason, AiProviderRunInterruptOutcome, ModelContinuationMode, - ModelInputBlock, ModelReasoningEffort, ModelReasoningEffortProfile, - ModelReasoningSummaryRequest, ModelRequest, ModelToolDefinition, ProviderCapabilities, - ProviderDynamicToolCall, ProviderDynamicToolResponder, ProviderError, ProviderEventStream, - ProviderKind, ProviderRequestContext, + AiProvider, AiProviderCapabilitySessionBinding, AiProviderFailureCategory, + AiProviderRunBinding, AiProviderRunCloseOutcome, AiProviderRunCloseReason, + AiProviderRunInterruptOutcome, ModelContinuationMode, ModelInputBlock, ModelReasoningEffort, + ModelReasoningEffortProfile, ModelReasoningSummaryRequest, ModelRequest, ModelToolDefinition, + ProviderCapabilities, ProviderDynamicToolCall, ProviderDynamicToolResponder, ProviderError, + ProviderEventStream, ProviderKind, ProviderRequestContext, }; const MAXIMUM_PROCESSES: usize = 4_096; @@ -44,6 +44,7 @@ const MAXIMUM_VERSION_BYTES: usize = 200; const MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES: usize = 4 * 1024; const MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN: usize = 16 * 1024; const MAXIMUM_RUNTIME_WARNINGS_PER_TURN: usize = 8; +const MAXIMUM_CAPABILITY_SESSION_BINDINGS: usize = 256; const MAXIMUM_TIMEOUT: Duration = Duration::from_secs(60 * 60); fn provider_timeout_error() -> ProviderError { @@ -345,6 +346,7 @@ pub struct AiCodexAppServerRegistration { launch_profile: AiCodexAppServerLaunchProfile, bootstrap_instructions: AiCodexAppServerBootstrapInstructions, reasoning_effort_profile: Option, + capability_session_bindings: BTreeMap, identity: String, } @@ -392,6 +394,7 @@ impl AiCodexAppServerRegistration { launch_profile, bootstrap_instructions, reasoning_effort_profile: None, + capability_session_bindings: BTreeMap::new(), identity: String::new(), }; registration.refresh_identity(); @@ -453,7 +456,69 @@ impl AiCodexAppServerRegistration { Ok(self) } + /// Admits one exact capability-delivery binding for retained sessions. + /// + /// The binding's complete fingerprint may then appear in a durable + /// provider-session descriptor. Its embedded registration identity must + /// equal this registration's exact effort-bound executable, sandbox, + /// launch-profile, bootstrap, and adapter identity. The admission overlay + /// does not alter that underlying identity because the binding already + /// incorporates it; changing the identity still invalidates the binding. + /// + /// This method accepts the validated binding object, never a bare final + /// fingerprint. It grants no capability discovery, execution, retention, + /// egress, budget, or resolver authority. + /// + /// # Errors + /// + /// Returns [`ProviderError::InvalidConfiguration`] unless the registration + /// supports retained dynamic tools and the binding uses `EagerExact`, + /// `ProviderDeferred`, or `FixedBroker` with an exact matching model, + /// reasoning effort, and embedded registration identity. Client-deferred + /// definitions cannot be frozen into one retained Codex thread. + pub fn with_capability_session_binding( + mut self, + binding: AiProviderCapabilitySessionBinding, + ) -> Result { + let expected_registration_identity = + self.provider_session_fingerprint(binding.reasoning_effort())?; + if !self.experimental_dynamic_tools() + || !matches!( + binding.delivery_mode(), + crate::AiCapabilityDeliveryMode::EagerExact + | crate::AiCapabilityDeliveryMode::ProviderDeferred + | crate::AiCapabilityDeliveryMode::FixedBroker + ) + || binding.model() != self.logical_model + || binding.registration_identity() != expected_registration_identity + || (self.capability_session_bindings.len() >= MAXIMUM_CAPABILITY_SESSION_BINDINGS + && !self + .capability_session_bindings + .contains_key(binding.fingerprint())) + { + return Err(ProviderError::InvalidConfiguration( + "invalid Codex capability-session binding".to_owned(), + )); + } + if self + .capability_session_bindings + .get(binding.fingerprint()) + .is_some_and(|existing| existing != &binding) + { + return Err(ProviderError::InvalidConfiguration( + "conflicting Codex capability-session binding".to_owned(), + )); + } + self.capability_session_bindings + .insert(binding.fingerprint().to_owned(), binding); + Ok(self) + } + fn refresh_identity(&mut self) { + // Every admitted capability binding incorporates the previous raw + // identity. An identity-bearing builder used afterward must therefore + // discard those admissions rather than retaining stale fingerprints. + self.capability_session_bindings.clear(); self.identity = registration_identity(self); } @@ -541,17 +606,31 @@ impl AiCodexAppServerRegistration { } fn admits_provider_session_fingerprint(&self, fingerprint: &str) -> bool { - std::iter::once(ModelReasoningEffort::Unspecified) - .chain( - self.reasoning_effort_profile - .as_ref() - .into_iter() - .flat_map(|profile| profile.supported().iter().copied()), - ) - .any(|effort| { - self.provider_session_fingerprint(effort) - .is_ok_and(|expected| expected == fingerprint) - }) + self.capability_session_bindings.contains_key(fingerprint) + || std::iter::once(ModelReasoningEffort::Unspecified) + .chain( + self.reasoning_effort_profile + .as_ref() + .into_iter() + .flat_map(|profile| profile.supported().iter().copied()), + ) + .any(|effort| { + self.provider_session_fingerprint(effort) + .is_ok_and(|expected| expected == fingerprint) + }) + } + + fn admits_provider_session_effort( + &self, + fingerprint: &str, + effort: ModelReasoningEffort, + ) -> bool { + self.provider_session_fingerprint(effort) + .is_ok_and(|expected| expected == fingerprint) + || self + .capability_session_bindings + .get(fingerprint) + .is_some_and(|binding| binding.reasoning_effort() == effort) } fn admits_cleanup_fingerprint(&self, fingerprint: &str) -> bool { @@ -1266,13 +1345,13 @@ impl AiProvider for AiCodexAppServerProvider { ) -> Result { self.capabilities() .validate_reasoning_effort(&request.model, request.reasoning_effort)?; - let session_fingerprint = self - .registration - .provider_session_fingerprint(request.reasoning_effort)?; if descriptor.provider_kind() != &ProviderKind::LocalHarness || descriptor.provider_profile_id() != self.registration.provider_profile_id() || descriptor.provider_model() != self.registration.logical_model() - || descriptor.registration_fingerprint() != session_fingerprint + || !self.registration.admits_provider_session_effort( + descriptor.registration_fingerprint(), + request.reasoning_effort, + ) || descriptor.protocol_version() != self.registration.protocol_version() { return Err(ProviderError::Rejected); @@ -1785,11 +1864,10 @@ fn retained_session_matches_effort( session: &crate::AiOpenedProviderSession, effort: ModelReasoningEffort, ) -> bool { - registration - .provider_session_fingerprint(effort) - .is_ok_and(|fingerprint| { - session.claim().descriptor().registration_fingerprint() == fingerprint - }) + registration.admits_provider_session_effort( + session.claim().descriptor().registration_fingerprint(), + effort, + ) } struct ActiveTurnGuard { @@ -5780,6 +5858,14 @@ pub(crate) mod tests { "d".repeat(64), ) .expect("provider-session descriptor should validate"); + opened_session_with_descriptor(binding, cursor, descriptor) + } + + fn opened_session_with_descriptor( + binding: AiProviderRunBinding, + cursor: crate::AiProviderSessionCursor, + descriptor: crate::AiProviderSessionDescriptor, + ) -> crate::AiOpenedProviderSession { let claim = crate::AiProviderSessionClaim { binding_id: Uuid::new_v4(), session_id: binding.session_id(), @@ -5831,6 +5917,53 @@ pub(crate) mod tests { ) } + fn capability_session_binding( + registration: &AiCodexAppServerRegistration, + effort: ModelReasoningEffort, + ) -> AiProviderCapabilitySessionBinding { + capability_session_binding_with_mode( + registration, + effort, + crate::AiCapabilityDeliveryMode::FixedBroker, + ) + } + + fn capability_session_binding_with_mode( + registration: &AiCodexAppServerRegistration, + effort: ModelReasoningEffort, + mode: crate::AiCapabilityDeliveryMode, + ) -> AiProviderCapabilitySessionBinding { + AiProviderCapabilitySessionBinding::new( + mode, + "c".repeat(64), + BTreeSet::from([dynamic_tool().fingerprint]), + "test-fixed-broker-v1", + registration.logical_model(), + effort, + registration + .provider_session_fingerprint(effort) + .expect("selected effort should fingerprint"), + ) + .expect("capability-session binding should validate") + } + + fn capability_registration( + effort: ModelReasoningEffort, + ) -> ( + Arc, + AiProviderCapabilitySessionBinding, + ) { + let base = dynamic_registration("1.0.0"); + let binding = capability_session_binding(&base, effort); + let registration = Arc::new( + (*base) + .clone() + .with_capability_session_binding(binding.clone()) + .expect("exact capability-session binding should be admitted"), + ); + (registration, binding) + } + fn reasoning_effort_profile() -> ModelReasoningEffortProfile { ModelReasoningEffortProfile::new( "model-1", @@ -6191,6 +6324,72 @@ pub(crate) mod tests { )); } + #[test] + fn capability_session_admission_validates_the_embedded_registration_identity() { + let base = dynamic_registration("1.0.0"); + let identity = base.identity().to_owned(); + let binding = capability_session_binding(&base, ModelReasoningEffort::Unspecified); + let admitted = (*base) + .clone() + .with_capability_session_binding(binding.clone()) + .expect("exact binding should be admitted"); + assert_eq!(admitted.identity(), identity); + assert!(admitted.admits_provider_session_fingerprint(binding.fingerprint())); + assert!(admitted.admits_provider_session_effort( + binding.fingerprint(), + ModelReasoningEffort::Unspecified, + )); + assert!(admitted.admits_cleanup_fingerprint(binding.fingerprint())); + let changed = admitted.with_bootstrap_instructions(trusted_bootstrap()); + assert!(!changed.admits_provider_session_fingerprint(binding.fingerprint())); + + let eager = capability_session_binding_with_mode( + &base, + ModelReasoningEffort::Unspecified, + crate::AiCapabilityDeliveryMode::EagerExact, + ); + let eager_registration = (*base) + .clone() + .with_capability_session_binding(eager.clone()) + .expect("a frozen eager direct-tool binding should be admitted"); + assert!(eager_registration.admits_provider_session_effort( + eager.fingerprint(), + ModelReasoningEffort::Unspecified, + )); + + let wrong_identity = AiProviderCapabilitySessionBinding::new( + crate::AiCapabilityDeliveryMode::FixedBroker, + binding.capability_index_fingerprint(), + binding.static_bootstrap_tool_fingerprints().clone(), + binding.provider_projection_version(), + binding.model(), + binding.reasoning_effort(), + "f".repeat(64), + ) + .expect("synthetic mismatched binding should be structurally valid"); + assert!(matches!( + (*base) + .clone() + .with_capability_session_binding(wrong_identity), + Err(ProviderError::InvalidConfiguration(_)) + )); + + let wrong_model = AiProviderCapabilitySessionBinding::new( + crate::AiCapabilityDeliveryMode::FixedBroker, + binding.capability_index_fingerprint(), + binding.static_bootstrap_tool_fingerprints().clone(), + binding.provider_projection_version(), + "other-model", + binding.reasoning_effort(), + binding.registration_identity(), + ) + .expect("synthetic mismatched binding should be structurally valid"); + assert!(matches!( + (*base).clone().with_capability_session_binding(wrong_model), + Err(ProviderError::InvalidConfiguration(_)) + )); + } + #[test] fn protocol_authors_only_the_closed_turn_start_effort_field() { let efforts = [ @@ -8144,6 +8343,91 @@ pub(crate) mod tests { assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn capability_bound_session_creates_resumes_and_discards_under_exact_admission() { + let counters = Arc::new(Counters::new()); + let (registration, capability_binding) = + capability_registration(ModelReasoningEffort::Unspecified); + let provider = + AiCodexAppServerProvider::new(registration.clone(), pool(counters.clone(), 2, 4)); + let request = dynamic_model_request(); + let context = provider_context(registration.provider_profile_id(), &request); + let run_binding = context + .run_binding() + .expect("executor context should carry the exact run binding"); + let descriptor = crate::AiProviderSessionDescriptor::new_with_capability_binding( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.protocol_version(), + "d".repeat(64), + &capability_binding, + ) + .expect("capability-bound descriptor should validate"); + + let unadmitted = AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + pool(Arc::new(Counters::new()), 1, 2), + ); + assert!(matches!( + unadmitted + .create_empty_session(&run_binding, &descriptor, &request) + .await, + Err(ProviderError::Rejected) + )); + + let cursor = provider + .create_empty_session(&run_binding, &descriptor, &request) + .await + .expect("admitted capability-bound session should create"); + let opened = + opened_session_with_descriptor(run_binding, cursor.clone(), descriptor.clone()) + .activate_newly_bound_empty(run_binding, &cursor) + .expect("newly bound cursor should activate"); + let first = provider + .stream_with_dynamic_tools( + request.clone(), + context + .clone() + .with_provider_session(opened) + .expect("opened session should match the request"), + Arc::new(FakeDynamicResponder), + ) + .await + .expect("first capability-bound turn should start") + .collect::>() + .await; + assert!(first.iter().all(Result::is_ok)); + + let resumed = opened_session_with_descriptor(run_binding, cursor, descriptor.clone()); + let continuation = provider + .stream_with_dynamic_tools( + request.clone(), + context + .with_provider_session(resumed) + .expect("resumed session should match the request"), + Arc::new(FakeDynamicResponder), + ) + .await + .expect("capability-bound retained turn should resume") + .collect::>() + .await; + assert!(continuation.iter().all(Result::is_ok)); + + let cleanup_binding = binding_for_owner(2); + let cleanup_cursor = provider + .create_empty_session(&cleanup_binding, &descriptor, &request) + .await + .expect("second exact empty session should create"); + provider + .discard_empty_session(&cleanup_binding, &descriptor, &cleanup_cursor) + .await + .expect("failed-bind cleanup should admit the capability fingerprint"); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.deleted_threads.load(Ordering::SeqCst), 1); + assert!(registration.admits_cleanup_fingerprint(descriptor.registration_fingerprint())); + } + #[tokio::test] async fn mixed_read_capabilities_start_newly_bound_dynamic_turn_without_resume() { let counters = Arc::new(Counters::new()); diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 0ab7a29..a1ca2d4 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.23.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.88.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.88.2` | `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 |