From 97900344d98d4ace0aed137bab138ec3af2c7f1c Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 15 Sep 2026 12:14:36 +1000 Subject: [PATCH 1/2] fix(ai): cache discovery metadata across fenced runs --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 9 + crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 11 + crates/graphql-orm-ai/README.md | 9 + .../graphql-orm-ai/src/capability_delivery.rs | 389 +++++++++++++++++- docs/reference/workspace-packages.md | 2 +- 7 files changed, 418 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3aa92aa..f082358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.98.0" +version = "0.98.1" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index d7242bf..9b3ba2f 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -20,6 +20,15 @@ checkpoint facts. For the current workspace baseline and active gates, use the ## Unreleased +### Added + +Optional `AiCapabilityDiscoveryBroker::with_discovery_cache` retains bounded +principal/session-specific discovery metadata across fenced runs for up to seven days. +A cached candidate can be described without repeating discovery. Current index fingerprints, +principal rehydration and host policy are checked before a fresh run-bound execution handle +is issued. Cache expiry, eviction or process restart requires discovery again. No data migration +or GraphQL contract change is required; the cache is disabled by default. + ### Fixed Conversation bootstrap now includes nullable `AiConversationRunSummary::failure_disposition` diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index f744062..515cae2 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.98.0" +version = "0.98.1" 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 a144a55..69c8c56 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,17 @@ 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.98.1 + +Hosts may opt into `AiCapabilityDiscoveryBroker::with_discovery_cache(ttl, maximum_searches)` +to reuse discovery candidates across runs of the same principal and AI session. Keep the broker +(or its clones) for the desired cache lifetime. The TTL is one second through seven days and +capacity is one through 1,024 bounded searches, with oldest-first eviction. The default remains +disabled. Each new run still creates a fresh `AiCapabilityBrokerSession` and describes a cached +candidate before execution; old loaded references do not become reusable. Describe revalidates +current metadata and authority, and execution retains all existing fences and resolver checks. +No data migration is needed; no durable schema, GraphQL SDL, backup or restore format changes. + ## 0.98.0 Conversation bootstrap now includes nullable `AiConversationRunSummary::failure_disposition` diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 476ce73..eb8fde1 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -365,3 +365,12 @@ contracts, and security checks. - [Read-only tools](docs/read-only-tool-loop.md), [supervised mutations](docs/supervised-tool-loop.md), and [provider turns](docs/worker-provider-turn.md) - [Recovery and restore](docs/recovery-and-restore.md) - [Migration guide](MIGRATION.md) and [changelog](CHANGELOG.md) + +### Discovery reuse across runs + +A long-lived `AiCapabilityDiscoveryBroker` may enable `with_discovery_cache(ttl, maximum_searches)` +to retain discovery metadata for the same principal and AI session across runs. This avoids +repeating model-driven discovery when catalogue fingerprints are unchanged. Cached descriptions +still rehydrate current principals and issue fresh run-bound execution handles. Permissions, +loaded handles and application results are not cache entries. The cache is bounded, process-local, +disabled by default, and expires entries after at most seven days; see [MIGRATION.md](MIGRATION.md). diff --git a/crates/graphql-orm-ai/src/capability_delivery.rs b/crates/graphql-orm-ai/src/capability_delivery.rs index 43235fe..cf3095c 100644 --- a/crates/graphql-orm-ai/src/capability_delivery.rs +++ b/crates/graphql-orm-ai/src/capability_delivery.rs @@ -713,6 +713,21 @@ impl AiLoadedCapabilityBinding { } } +#[derive(Clone)] +struct CachedCapabilityDiscovery { + principal_fingerprint: String, + session_id: AiSessionId, + search: AiCapabilityIndexSetSearchResult, + expires_at: OffsetDateTime, +} + +#[derive(Clone)] +struct CapabilityDiscoveryCache { + entries: Arc>>, + ttl: Duration, + maximum_searches: usize, +} + /// Discovery and loaded-capability broker with fresh authority checks. #[derive(Clone)] pub struct AiCapabilityDiscoveryBroker { @@ -721,6 +736,7 @@ pub struct AiCapabilityDiscoveryBroker { authority: Arc, clock: Arc, loaded_ttl: Duration, + discovery_cache: Option, } impl AiCapabilityDiscoveryBroker { @@ -747,9 +763,95 @@ impl AiCapabilityDiscoveryBroker { authority, clock, loaded_ttl, + discovery_cache: None, }) } + /// Enables bounded process-local reuse of discovery candidates across runs. + /// + /// Cache entries belong to one principal reference and AI session. Only + /// authority-neutral discovery metadata is retained: describe still checks + /// the current index, rehydrates the principal and authorizes the candidate, + /// then issues a new short-lived binding for the current fenced run. + /// Execution bindings, permissions and tool results are never cached here. + /// Cloned brokers share this cache; replacing the broker starts it empty. + /// Entries expire after an absolute TTL and the oldest searches are evicted + /// at capacity. The cache is disabled unless this builder is called. + /// + /// # Errors + /// + /// Rejects TTLs outside one second through seven days or capacities outside + /// one through 1,024 searches. Each search retains the canonical search bounds. + pub fn with_discovery_cache( + mut self, + ttl: Duration, + maximum_searches: u16, + ) -> Result { + if ttl < Duration::seconds(1) + || ttl > Duration::days(7) + || !(1..=1_024).contains(&maximum_searches) + { + return Err(AiError::InvalidConfiguration( + "capability discovery cache bounds are invalid".to_owned(), + )); + } + self.discovery_cache = Some(CapabilityDiscoveryCache { + entries: Arc::new(Mutex::new(VecDeque::new())), + ttl, + maximum_searches: usize::from(maximum_searches), + }); + Ok(self) + } + + fn cache_discovery( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + search: &AiCapabilityIndexSetSearchResult, + ) { + let Some(cache) = &self.discovery_cache else { + return; + }; + let now = self.clock.now(); + let mut entries = cache.entries.lock().unwrap_or_else(PoisonError::into_inner); + entries.retain(|entry| entry.expires_at > now); + entries.push_back(CachedCapabilityDiscovery { + principal_fingerprint: principal_reference_fingerprint(principal_reference), + session_id: run.session_id, + search: search.clone(), + expires_at: now + cache.ttl, + }); + while entries.len() > cache.maximum_searches { + entries.pop_front(); + } + } + + fn cached_discovery( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + capability_id: &AiToolId, + candidate_fingerprint: &str, + ) -> Option { + let cache = self.discovery_cache.as_ref()?; + let now = self.clock.now(); + let fingerprint = principal_reference_fingerprint(principal_reference); + let mut entries = cache.entries.lock().unwrap_or_else(PoisonError::into_inner); + entries.retain(|entry| entry.expires_at > now); + entries + .iter() + .rev() + .find(|entry| { + entry.principal_fingerprint == fingerprint + && entry.session_id == run.session_id + && entry.search.candidates.iter().any(|candidate| { + &candidate.id == capability_id + && candidate.entry_fingerprint == candidate_fingerprint + }) + }) + .map(|entry| entry.search.clone()) + } + /// Searches current model-safe metadata after rehydrating the principal /// and applying current host policy to every candidate. /// @@ -1114,7 +1216,7 @@ struct BrokerSessionState { /// candidate. It is never a durable authority and never substitutes for the /// published default-deny catalogue: losing it fails the next describe or /// execute closed with a bounded retryable stale-selection outcome and the -/// model rediscovers. +/// model rediscovers unless the broker has a matching unexpired discovery cache entry. #[derive(Clone, Debug)] pub struct AiCapabilityBrokerSession { inner: Arc>, @@ -1646,6 +1748,7 @@ impl AiCapabilityDiscoveryBroker { .map(candidate_value) .collect::>(), }); + self.cache_discovery(principal_reference, run, &result); session.record_search(result); Ok(value) } @@ -1710,8 +1813,9 @@ impl AiCapabilityDiscoveryBroker { /// Dispatches one frozen `graphql.capabilities.describe` call. /// - /// The candidate must come from a discovery result retained for this run - /// and its fingerprint must still match. A drifted index, an unknown + /// The candidate must come from discovery retained for this run or, when + /// enabled, the unexpired cache for this principal and AI session. Its + /// fingerprint must still match. A drifted index, an unknown /// identifier, and an identifier never returned by discovery are all /// reported as one bounded retryable stale selection, so describe cannot be /// used to probe for capabilities the current principal cannot see. @@ -1736,6 +1840,14 @@ impl AiCapabilityDiscoveryBroker { let capability_id = AiToolId::parse(parsed.capability_id).map_err(|_| stale_selection())?; let search = session .candidate_search(&capability_id, &parsed.candidate_fingerprint) + .or_else(|| { + self.cached_discovery( + principal_reference, + run, + &capability_id, + &parsed.candidate_fingerprint, + ) + }) .ok_or_else(stale_selection)?; let indexes = self.current_indexes.current_index_set(run)?; verify_search_binding(&indexes, &search).map_err(|_| stale_selection())?; @@ -3126,6 +3238,277 @@ mod tests { ); } + struct DiscoveryCacheFixture { + broker: AiCapabilityDiscoveryBroker, + principal: PrincipalReference, + run: AiCapabilityRunBinding, + clock: Arc, + authority: Arc, + index: Arc, + } + + impl DiscoveryCacheFixture { + fn new(capacity: u16) -> Self { + let principal = principal(); + let reference = principal.reference(); + let clock = Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)); + let authority = Arc::new(Authority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }); + let index = Arc::new(CurrentIndex(RwLock::new(generated_index( + "target-policy-v1", + )))); + let broker = AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + index.clone(), + authority.clone(), + clock.clone(), + Duration::seconds(30), + ) + .expect("broker") + .with_discovery_cache(Duration::days(1), capacity) + .expect("cache"); + Self { + broker, + principal: reference, + run: run_binding(), + clock, + authority, + index, + } + } + + async fn discover(&self, run: &AiCapabilityRunBinding) -> serde_json::Value { + let result = self.broker.dispatch_discover( + &self.principal, run, &Self::session(), + &json!({"text": "reviewed application record", "kind": "generated_query", "maximumResults": 1}), + ).await.expect("discovery"); + json!({ + "capabilityId": result["candidates"][0]["capabilityId"], + "candidateFingerprint": result["candidates"][0]["candidateFingerprint"] + }) + } + + fn session() -> AiCapabilityBrokerSession { + AiCapabilityBrokerSession::new(AiCapabilityDeliveryLimits::default()) + .expect("run state") + } + } + + #[tokio::test] + async fn discovery_cache_reuses_metadata_across_runs_with_fresh_execution_fences() { + let fixture = DiscoveryCacheFixture::new(4); + let candidate = fixture.discover(&fixture.run).await; + let first_session = DiscoveryCacheFixture::session(); + let first = fixture + .broker + .dispatch_describe(&fixture.principal, &fixture.run, &first_session, &candidate) + .await + .expect("cached candidate describes in first run"); + fixture.clock.advance_seconds(3_600); + let mut next_run = fixture.run.clone(); + next_run.run_id = AiRunId(Uuid::from_u128(200)); + next_run.attempt_id = Uuid::from_u128(201); + let next_session = DiscoveryCacheFixture::session(); + let next = fixture + .broker + .clone() + .dispatch_describe(&fixture.principal, &next_run, &next_session, &candidate) + .await + .expect("one-hour-old candidate describes without another discovery"); + assert_eq!(next_session.amplification().discover_calls, 0); + assert_eq!(next_session.amplification().describe_calls, 1); + assert_ne!(first.loaded_reference(), next.loaded_reference()); + let execute = + json!({"loadedReference": next.loaded_reference(), "selections": ["records.id"]}); + fixture + .broker + .authorize_broker_execution(&fixture.principal, &next_run, &next_session, &execute) + .await + .expect("new run has fresh execution authority"); + let old_execute = + json!({"loadedReference": first.loaded_reference(), "selections": ["records.id"]}); + assert!(matches!( + fixture + .broker + .authorize_broker_execution( + &fixture.principal, + &next_run, + &next_session, + &old_execute, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + assert!(matches!( + fixture + .broker + .authorize_broker_execution( + &fixture.principal, + &fixture.run, + &next_session, + &execute, + ) + .await, + Err(AiError::Forbidden) + )); + fixture.authority.allowed.store(false, Ordering::SeqCst); + assert!(matches!( + fixture + .broker + .authorize_broker_execution(&fixture.principal, &next_run, &next_session, &execute,) + .await, + Err(AiError::Forbidden) + )); + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &next_run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::Forbidden) + )); + } + + #[tokio::test] + async fn discovery_cache_isolates_principals_sessions_and_rejects_index_drift() { + let fixture = DiscoveryCacheFixture::new(4); + let candidate = fixture.discover(&fixture.run).await; + let mut other_run = fixture.run.clone(); + other_run.session_id = AiSessionId(Uuid::from_u128(999)); + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &other_run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + let mut other = principal(); + if let AuthPrincipal::User(user) = &mut other { + user.user_id = "another-user".to_owned(); + } + assert!(matches!( + fixture + .broker + .dispatch_describe( + &other.reference(), + &fixture.run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + let mut forged = candidate.clone(); + forged["candidateFingerprint"] = json!("f".repeat(64)); + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &fixture.run, + &DiscoveryCacheFixture::session(), + &forged, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + *fixture.index.0.write().expect("index") = generated_index("target-policy-v2"); + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &fixture.run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + } + + #[tokio::test] + async fn discovery_cache_expires_evicts_and_stays_optional() { + let fixture = DiscoveryCacheFixture::new(1); + let candidate = fixture.discover(&fixture.run).await; + fixture.clock.advance_seconds(86_400); + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &fixture.run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + fixture.discover(&fixture.run).await; + let mut other_run = fixture.run.clone(); + other_run.session_id = AiSessionId(Uuid::from_u128(999)); + fixture.discover(&other_run).await; + assert!(matches!( + fixture + .broker + .dispatch_describe( + &fixture.principal, + &fixture.run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + fixture + .broker + .dispatch_describe( + &fixture.principal, + &other_run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await + .expect("newest entry survives eviction"); + let mut uncached = fixture.broker.clone(); + uncached.discovery_cache = None; + assert!(matches!( + uncached + .dispatch_describe( + &fixture.principal, + &other_run, + &DiscoveryCacheFixture::session(), + &candidate, + ) + .await, + Err(AiError::InvalidInput(_)) + )); + for (ttl, capacity) in [ + (Duration::ZERO, 1), + (Duration::days(8), 1), + (Duration::days(1), 0), + (Duration::days(1), 1_025), + ] { + assert!( + uncached + .clone() + .with_discovery_cache(ttl, capacity) + .is_err() + ); + } + } + #[tokio::test] async fn describe_exposes_compiler_owned_result_record_cost_bounds() { let principal = principal(); diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 4e462e8..38287be 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.30.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.98.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.98.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.11.0` | `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 | From 2fb98ad89bfc0823c9b3f0050b9e6e6fb9bcf676 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 15 Sep 2026 12:25:09 +1000 Subject: [PATCH 2/2] docs(ai): clarify fixed-broker cache adoption and review dates --- crates/graphql-orm-ai/MIGRATION.md | 2 +- crates/graphql-orm-ai/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 69c8c56..fb35400 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-09-02 +last_reviewed: 2026-09-15 review_by: 2027-02-01 supersedes: [] --- diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index eb8fde1..ef5f419 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -369,7 +369,7 @@ contracts, and security checks. ### Discovery reuse across runs A long-lived `AiCapabilityDiscoveryBroker` may enable `with_discovery_cache(ttl, maximum_searches)` -to retain discovery metadata for the same principal and AI session across runs. This avoids +to retain `dispatch_discover` results for the same principal and AI session across fixed-broker runs. This avoids repeating model-driven discovery when catalogue fingerprints are unchanged. Cached descriptions still rehydrate current principals and issue fresh run-bound execution handles. Permissions, loaded handles and application results are not cache entries. The cache is bounded, process-local,