From ccbe49b992ccd62fbe100a6005f0fc9fa620e451 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 21 Aug 2026 12:34:24 +1000 Subject: [PATCH] feat(ai): dispatch federated capability broker --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 20 +- .../graphql-orm-ai-tool-profiles/Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/MIGRATION.md | 23 +- crates/graphql-orm-ai-tool-profiles/README.md | 2 +- .../src/capability_index.rs | 451 +++- crates/graphql-orm-ai/CHANGELOG.md | 61 +- crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 70 +- crates/graphql-orm-ai/README.md | 5 +- .../capability-discovery-and-execution.md | 137 +- .../docs/implementation-status.md | 10 +- .../graphql-orm-ai/src/capability_delivery.rs | 2324 +++++++++++++++-- crates/graphql-orm-ai/src/orm_coordinator.rs | 251 +- crates/graphql-orm-ai/src/orm_tools.rs | 605 +++++ crates/graphql-orm-ai/src/provider_calls.rs | 622 ++++- docs/reference/workspace-packages.md | 4 +- 18 files changed, 4331 insertions(+), 264 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57d1b43b..a147f7dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.84.0" +version = "0.85.0" dependencies = [ "agql-auth", "async-graphql", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.7.0" +version = "0.8.0" dependencies = [ "async-graphql", "async-graphql-parser", diff --git a/Cargo.toml b/Cargo.toml index 2b1f1ab5..ec3b8864 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.7.0" } +graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.8.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 88ea7501..76c4e493 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -3,13 +3,31 @@ title: "graphql-orm-ai-tool-profiles changelog" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-08-21 review_by: 2027-02-11 supersedes: [] --- # Changelog +## [0.8.0] - 2026-08-21 + +### Added + +- `AiCapabilityIndexSet` canonically combines independently compiled logical + target indexes for federated discovery. Its deterministic aggregate + fingerprint binds each target to its exact index, global search preserves + stable ranking, and every capability resolves to one owning index. + `AiCapabilityIndexSetLimits` independently bounds targets, aggregate entries + and entry bytes, and global search results. + +### Security + +- Empty sets, duplicate targets, cross-target capability-ID collisions and + invalid member fingerprints fail closed. The set invents no aggregate SDL, + semantic catalogue or policy identity; execution must revalidate the exact + owning index and ordinary resolver authority. + ## [0.7.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 5c8da7b4..e85d26fa 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.7.0" +version = "0.8.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 80881eac..31dd0fda 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -3,13 +3,34 @@ title: "graphql-orm-ai-tool-profiles migration guide" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-08-21 review_by: 2027-02-11 supersedes: [] --- # Migration Guide +## 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 +reviewed full monorepo revision. Continue compiling one exact +`AiCapabilityIndex` beside each owning target after its finished SDL and +semantic catalogue are complete. Combine the active indexes with +`AiCapabilityIndexSet::compile` and use its fingerprint for delivery surfaces +and retained provider-session bindings. + +Remove consumer-authored combined-index fingerprints and single-target broker +selection. Capability IDs must be globally unique across the active set; a +collision is now a deployment error. Discovery ranks the complete set, while +loading and execution revalidate the exact member index's target, schema, +semantic catalogue and policy fingerprints. Existing single-index sources can +continue through the compatibility adapter. Validate deployment-specific +`AiCapabilityIndexSetLimits` for target count, aggregate entry count, canonical +entry bytes and global search results when the defaults are not appropriate. + +No database, data, GraphQL SDL, table, column, index, constraint, backfill, +protected-content, credential or AI schema-module migration is required. + ## 0.6.0 to 0.7.0: proof-bearing pre-transport budget denial Adopt `graphql-orm-ai-tool-profiles` 0.7.0 with `graphql-orm-ai` 0.84.0 from diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 5353c35a..495abc83 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.7.0" } +graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.8.0" } serde_json = "1" ``` diff --git a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs index 4c7fd390..56589951 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs @@ -5,7 +5,10 @@ //! is descriptive only; callers must reauthorize and load the exact current //! capability before execution. -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use graphql_orm_operation_catalog::{ GeneratedGraphqlOperationCategory, GraphqlAggregateOperator, GraphqlOperationKind, @@ -27,6 +30,9 @@ use crate::{ /// Current compact capability-index contract version. pub const AI_CAPABILITY_INDEX_VERSION: u16 = 1; +/// Current deterministic multi-target capability-index-set contract version. +pub const AI_CAPABILITY_INDEX_SET_VERSION: u16 = 1; + const MAXIMUM_SEARCH_TEXT_BYTES: usize = 1_024; const MAXIMUM_PUBLIC_TEXT_BYTES: usize = 1_024; const MAXIMUM_PUBLIC_NAME_BYTES: usize = 256; @@ -69,6 +75,46 @@ impl AiCapabilityIndexLimits { } } +/// Independent ceilings for one canonical federated index set. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiCapabilityIndexSetLimits { + /// Maximum independently owned logical targets. + pub maximum_indexes: u16, + /// Maximum entries across every member index. + pub maximum_entries: u32, + /// Maximum canonical JSON bytes across every member entry. + pub maximum_total_bytes: u64, + /// Maximum results returned by one global search. + pub maximum_search_results: u16, +} + +impl Default for AiCapabilityIndexSetLimits { + fn default() -> Self { + Self { + maximum_indexes: 256, + maximum_entries: 16_384, + maximum_total_bytes: 64 * 1_024 * 1_024, + maximum_search_results: 32, + } + } +} + +impl AiCapabilityIndexSetLimits { + fn validate(self) -> Result<(), AiError> { + if !(1..=1_024).contains(&self.maximum_indexes) + || !(1..=1_048_576).contains(&self.maximum_entries) + || !(1_024..=4 * 1_024 * 1_024 * 1_024).contains(&self.maximum_total_bytes) + || !(1..=128).contains(&self.maximum_search_results) + { + return Err(configuration_error( + "capability index set limits are invalid", + )); + } + Ok(()) + } +} + /// Provider-neutral capability family. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -451,27 +497,15 @@ impl AiCapabilityIndex { ) -> Result { query.validate(self.limits.maximum_search_results)?; let terms = search_terms(&query.text); - let mut ranked = - self.entries - .values() - .filter(|entry| { - query - .namespace - .as_ref() - .is_none_or(|namespace| &entry.namespace == namespace) - && query.kind.is_none_or(|kind| entry.kind == kind) - && query.entity_or_operation.as_ref().is_none_or(|name| { - semantic_key(&entry.operation_name) == semantic_key(name) - || entry.entity_name.as_ref().is_some_and(|entity| { - semantic_key(entity) == semantic_key(name) - }) - }) - }) - .filter_map(|entry| { - let score = search_score(entry, &terms); - (score > 0).then_some((score, entry)) - }) - .collect::>(); + let mut ranked = self + .entries + .values() + .filter(|entry| entry_matches_query(entry, query)) + .filter_map(|entry| { + let score = search_score(entry, &terms); + (score > 0).then_some((score, entry)) + }) + .collect::>(); ranked.sort_by(|left, right| { right .0 @@ -481,18 +515,7 @@ impl AiCapabilityIndex { let candidates = ranked .into_iter() .take(usize::from(query.maximum_results)) - .map(|(_, entry)| AiCapabilitySearchCandidate { - id: entry.id.clone(), - kind: entry.kind, - name: entry.name.clone(), - description: entry.description.clone(), - namespace: entry.namespace.clone(), - entity_name: entry.entity_name.clone(), - operation_name: entry.operation_name.clone(), - operation_shape: entry.operation_shape, - capability_fingerprint: entry.capability_fingerprint.clone(), - entry_fingerprint: entry.fingerprint.clone(), - }) + .map(|(_, entry)| search_candidate(entry)) .collect(); Ok(AiCapabilitySearchResult { index_fingerprint: self.fingerprint.clone(), @@ -504,6 +527,213 @@ impl AiCapabilityIndex { } } +/// Deterministic collection of independently compiled capability indexes. +/// +/// An index set preserves the exact logical target, schema, semantic +/// catalogue, and policy fingerprint of every owning index. It is not a +/// synthetic GraphQL schema and grants no authority. Capability identifiers +/// must be globally unique so discovery can resolve each candidate to exactly +/// one owning target before current policy and resolver authorization run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AiCapabilityIndexSet { + indexes: BTreeMap>, + capability_owners: BTreeMap, + fingerprint: String, + maximum_search_results: u16, + limits: AiCapabilityIndexSetLimits, +} + +impl AiCapabilityIndexSet { + /// Compiles a canonical index set from independently reviewed indexes. + /// + /// Input order does not affect the aggregate fingerprint. The aggregate + /// binds every target to its exact index fingerprint without inventing a + /// combined schema or catalogue. Duplicate targets and capability IDs are + /// rejected even when their contents happen to match. + /// + /// # Errors + /// + /// Returns an error for an empty or oversized set, a duplicate target, a + /// duplicate capability identifier, or an invalid member fingerprint. + pub fn compile(indexes: I) -> Result + where + I: IntoIterator, + T: Into>, + { + Self::compile_with_limits(indexes, AiCapabilityIndexSetLimits::default()) + } + + /// Compiles a canonical index set with explicit aggregate ceilings. + /// + /// # Errors + /// + /// Returns an error for invalid limits or when the member set exceeds any + /// configured target, entry, or search-result ceiling. + pub fn compile_with_limits( + indexes: I, + limits: AiCapabilityIndexSetLimits, + ) -> Result + where + I: IntoIterator, + T: Into>, + { + limits.validate()?; + let mut canonical = BTreeMap::new(); + let mut capability_owners = BTreeMap::new(); + let mut total_bytes = 0_u64; + let mut maximum_search_results = limits.maximum_search_results; + for index in indexes { + let index = index.into(); + if !valid_sha256_fingerprint(&index.fingerprint) { + return Err(configuration_error( + "capability index set contains an invalid fingerprint", + )); + } + let target_id = index.target_id.clone(); + if canonical.contains_key(&target_id) { + return Err(configuration_error( + "capability index set contains a duplicate target", + )); + } + for id in index.entries.keys() { + if capability_owners + .insert(id.clone(), target_id.clone()) + .is_some() + { + return Err(configuration_error( + "capability index set contains a duplicate capability", + )); + } + } + for entry in index.entries.values() { + let entry_bytes = + u64::try_from(canonical_json_bytes(entry).len()).map_err(|_| { + configuration_error("capability index set byte size overflowed") + })?; + total_bytes = total_bytes.checked_add(entry_bytes).ok_or_else(|| { + configuration_error("capability index set byte size overflowed") + })?; + } + maximum_search_results = + maximum_search_results.min(index.limits.maximum_search_results); + canonical.insert(target_id, index); + if canonical.len() > usize::from(limits.maximum_indexes) { + return Err(configuration_error( + "capability index set contains too many targets", + )); + } + if capability_owners.len() > limits.maximum_entries as usize { + return Err(configuration_error( + "capability index set contains too many entries", + )); + } + if total_bytes > limits.maximum_total_bytes { + return Err(configuration_error( + "capability index set contains too many bytes", + )); + } + } + if canonical.is_empty() { + return Err(configuration_error("capability index set is empty")); + } + let members = canonical + .iter() + .map(|(target_id, index)| { + json!({ + "target_id": target_id, + "index_fingerprint": index.fingerprint, + }) + }) + .collect::>(); + let fingerprint = sha256_json(&json!({ + "version": AI_CAPABILITY_INDEX_SET_VERSION, + "members": members, + })); + Ok(Self { + indexes: canonical, + capability_owners, + fingerprint, + maximum_search_results, + limits, + }) + } + + /// Canonical aggregate fingerprint for every exact member index. + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// Aggregate ceilings validated for this set. + pub const fn limits(&self) -> AiCapabilityIndexSetLimits { + self.limits + } + + /// Member indexes in stable logical-target order. + pub fn indexes(&self) -> impl ExactSizeIterator> { + self.indexes.values() + } + + /// Resolves one exact member index by logical target. + pub fn index(&self, target_id: &GraphqlExecutionTargetId) -> Option<&Arc> { + self.indexes.get(target_id) + } + + /// Resolves the sole owning index for a globally unique capability ID. + pub fn owning_index(&self, id: &AiToolId) -> Option<&Arc> { + self.capability_owners + .get(id) + .and_then(|target_id| self.indexes.get(target_id)) + } + + /// Resolves one exact entry and its owning index. This grants no authority. + pub fn entry( + &self, + id: &AiToolId, + ) -> Option<(&Arc, &AiCapabilityIndexEntry)> { + let index = self.owning_index(id)?; + Some((index, index.entry(id)?)) + } + + /// Searches every member index as one deterministic bounded namespace. + /// + /// # Errors + /// + /// Returns an error for an invalid query or result ceiling. Search emits + /// model-safe metadata only and grants no authority. + pub fn search( + &self, + query: &AiCapabilitySearchQuery, + ) -> Result { + query.validate(self.maximum_search_results)?; + let terms = search_terms(&query.text); + let mut ranked = self + .indexes + .values() + .flat_map(|index| index.entries.values()) + .filter(|entry| entry_matches_query(entry, query)) + .filter_map(|entry| { + let score = search_score(entry, &terms); + (score > 0).then_some((score, entry)) + }) + .collect::>(); + ranked.sort_by(|left, right| { + right + .0 + .cmp(&left.0) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); + let candidates = ranked + .into_iter() + .take(usize::from(query.maximum_results)) + .map(|(_, entry)| search_candidate(entry)) + .collect(); + Ok(AiCapabilityIndexSetSearchResult { + index_set_fingerprint: self.fingerprint.clone(), + candidates, + }) + } +} + /// Closed bounded discovery request. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -587,6 +817,51 @@ pub struct AiCapabilitySearchResult { pub candidates: Vec, } +/// Bounded deterministic discovery response across a canonical index set. +/// +/// Member schema, semantic-catalogue, target-policy, and index fingerprints +/// remain on their owning indexes and are revalidated when a candidate is +/// loaded. This response binds the complete active set without fabricating a +/// synthetic cross-subgraph schema identity. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiCapabilityIndexSetSearchResult { + /// Exact aggregate index-set fingerprint searched. + pub index_set_fingerprint: String, + /// Ranked globally unique candidates with stable ID tie-breaking. + pub candidates: Vec, +} + +fn entry_matches_query(entry: &AiCapabilityIndexEntry, query: &AiCapabilitySearchQuery) -> bool { + query + .namespace + .as_ref() + .is_none_or(|namespace| &entry.namespace == namespace) + && query.kind.is_none_or(|kind| entry.kind == kind) + && query.entity_or_operation.as_ref().is_none_or(|name| { + semantic_key(&entry.operation_name) == semantic_key(name) + || entry + .entity_name + .as_ref() + .is_some_and(|entity| semantic_key(entity) == semantic_key(name)) + }) +} + +fn search_candidate(entry: &AiCapabilityIndexEntry) -> AiCapabilitySearchCandidate { + AiCapabilitySearchCandidate { + id: entry.id.clone(), + kind: entry.kind, + name: entry.name.clone(), + description: entry.description.clone(), + namespace: entry.namespace.clone(), + entity_name: entry.entity_name.clone(), + operation_name: entry.operation_name.clone(), + operation_shape: entry.operation_shape, + capability_fingerprint: entry.capability_fingerprint.clone(), + entry_fingerprint: entry.fingerprint.clone(), + } +} + #[allow(clippy::too_many_arguments)] fn generated_entry( id: AiToolId, @@ -1116,6 +1391,10 @@ fn sha256_json(value: &serde_json::Value) -> String { hex::encode(Sha256::digest(canonical_json_bytes(value))) } +fn valid_sha256_fingerprint(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn input_error(message: &str) -> AiError { AiError::InvalidInput(message.to_owned()) } @@ -1285,4 +1564,108 @@ mod tests { assert_eq!(result.candidates[0].id.as_str(), "app.alpha"); assert_eq!(result.candidates[1].id.as_str(), "app.beta"); } + + #[test] + fn index_set_searches_multiple_targets_deterministically() { + let semantic = semantic_catalogue(); + let compile = |target: &str, descriptor: AiToolDescriptor| { + Arc::new( + AiCapabilityIndex::compile( + GraphqlExecutionTargetId::parse(target).expect("target"), + format!("schema-{target}"), + &semantic, + None, + None, + None, + [descriptor], + format!("policy-{target}"), + AiCapabilityIndexLimits::default(), + ) + .expect("index"), + ) + }; + let jim = compile( + "jim", + descriptor("jim.jobs", "List recent jobs and Actions.", 0), + ); + let fame = compile( + "fame", + descriptor("fame.endpoints", "List connected managed endpoints.", 0), + ); + let first = AiCapabilityIndexSet::compile([jim.clone(), fame.clone()]).expect("set"); + let second = AiCapabilityIndexSet::compile([fame.clone(), jim.clone()]).expect("set"); + assert_eq!(first.fingerprint(), second.fingerprint()); + assert_eq!(first.indexes().len(), 2); + assert_eq!( + first + .owning_index(&AiToolId::parse("jim.jobs").unwrap()) + .unwrap() + .target_id() + .as_str(), + "jim" + ); + let jobs = first + .search(&AiCapabilitySearchQuery { + text: "recent jobs".to_owned(), + namespace: None, + kind: None, + entity_or_operation: None, + maximum_results: 2, + }) + .expect("search"); + assert_eq!(jobs.index_set_fingerprint, first.fingerprint()); + assert_eq!(jobs.candidates[0].id.as_str(), "jim.jobs"); + let endpoints = first + .search(&AiCapabilitySearchQuery { + text: "connected endpoints".to_owned(), + namespace: None, + kind: None, + entity_or_operation: None, + maximum_results: 2, + }) + .expect("search"); + assert_eq!(endpoints.candidates[0].id.as_str(), "fame.endpoints"); + assert!( + AiCapabilityIndexSet::compile_with_limits( + [jim.clone(), fame.clone()], + AiCapabilityIndexSetLimits { + maximum_entries: 1, + ..AiCapabilityIndexSetLimits::default() + }, + ) + .is_err() + ); + assert!( + AiCapabilityIndexSet::compile_with_limits( + [jim, fame], + AiCapabilityIndexSetLimits { + maximum_total_bytes: 1_024, + ..AiCapabilityIndexSetLimits::default() + } + ) + .is_err() + ); + } + + #[test] + fn index_set_rejects_cross_target_capability_collisions() { + let semantic = semantic_catalogue(); + let compile = |target: &str| { + Arc::new( + AiCapabilityIndex::compile( + GraphqlExecutionTargetId::parse(target).expect("target"), + format!("schema-{target}"), + &semantic, + None, + None, + None, + [descriptor("shared.lookup", "Look up records.", 0)], + format!("policy-{target}"), + AiCapabilityIndexLimits::default(), + ) + .expect("index"), + ) + }; + assert!(AiCapabilityIndexSet::compile([compile("first"), compile("second")]).is_err()); + } } diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index db2b2bfc..424475e9 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,7 +18,66 @@ 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). -## [Unreleased] +## [0.85.0] - 2026-08-21 + +Persistent schema module: **0.63.0** (unchanged from 0.84.0). + +### Added + +- The coordinator now dispatches the closed + `graphql.capabilities.discover`, `graphql.capabilities.describe`, and + `graphql.capabilities.execute` surfaces through + `OrmAiApplicationToolCallService`, so fixed-broker and client-deferred + delivery use the same durable started/completed rows, protected output, + egress evidence, checkpoints, cancellation and safe-failure envelopes as + ordinary read tools. +- `AiCapabilityDeliveryTurn` owns the selected delivery mode, exact mutable + client-deferred surface and bounded per-run broker state. Its current surface + can be supplied to initial and continuation provider plans without a host + recreating definitions. +- `AiReadOnlyAgentTurnPlanner::continuation_plan_with_capability_delivery` + receives the surviving crate-owned delivery state after a broker result is + committed. Existing planners retain a fail-closed default bridge. +- `AiCapabilityIndexSet` and `AiCurrentCapabilityIndexSet` make one broker + span independently owned federated targets without a synthetic combined + schema or a consumer-authored aggregate fingerprint. + +### Changed + +- Client-deferred discovery loads only the exact bounded generated-query + candidates returned under fresh current authority, then installs only those + definitions on the next continuation. An empty current-authority result + clears earlier deferred definitions instead of retaining a stale provider + surface. Fixed-broker execution accepts closed dot-path input construction, + explicit selection paths, relationship arguments/cardinality and total + result bounds; the canonical compact-plan compiler remains final authority. +- A loaded capability remains valid across monotonic lease renewals in the + same session/run/attempt/provider binding. Cross-run, stale-generation, + principal, provider-session, catalogue, schema, policy and capability + substitution still fail closed. +- Cross-target capability IDs must be globally unique. Search binds the + canonical aggregate set while load and execution bind and revalidate the + selected capability's exact owning index. +- Fixed-broker describe returns an exact compact plan schema only when the + complete planning contract fits the crate-owned describe bound (512 KiB by + default); it reports an unavailable schema instead of truncating one. +- Broker result overflow is persisted as one deterministic safe failure on + the already-started durable tool row rather than escaping into a duplicate + execution attempt. + +### Security + +- Discovery and describe remain authority-neutral metadata operations. + Execute rehydrates the current principal, reapplies current target/tool + policy, authorizes the exact short-lived loaded binding, and invokes only a + generated read through ordinary GraphQL resolver and disclosure checks. + Static tools, mutations, subscriptions, arbitrary GraphQL, URLs, SQL and + callbacks cannot enter the fixed broker. + +There is no schema, data, protected-payload, GraphQL SDL, backup or restore +migration in this release. + +## [0.84.0] - 2026-08-21 Persistent schema module: **0.63.0**. diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index ea4d0ac4..41e05898 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.84.0" +version = "0.85.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 0d1397e5..c18ae87c 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,7 +19,75 @@ 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). -## Unreleased: budget reclamation and pre-transport denial (crate 0.83.0 to 0.84.0; schema 0.62.0 to 0.63.0) +## 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 +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. + +The delivery types introduced in 0.81 now have an ordinary durable execution +path. Build one complete current index per owning logical target, combine them +with `AiCapabilityIndexSet::compile`, and supply the set through +`AiCurrentCapabilityIndexSet`. Use the aggregate set fingerprint for the +delivery surface and retained session binding. Do not synthesize a combined +schema/catalogue fingerprint; global capability-ID collisions fail readiness. +Existing single-index implementations retain a compatibility adapter. +`AiCapabilityAuthorityPolicy::authorize` now also receives the exact owning +`AiCapabilityIndex`; use its logical target and fingerprints when applying +current target policy. Do not infer an owner from capability naming. + +Build the exact currently eligible read definitions, then construct one +`AiCapabilityDeliveryTurn::select`. Build the provider request from +`delivery.current_surface()` using +`AiProviderCallPlan::new_with_capability_surface`, and attach a clone of the +same delivery value with `AiReadOnlyAgentTurnPlan::with_capability_delivery`. +Do not construct or edit broker definitions. + +Client-deferred installation is required after every accepted discovery, +including a discovery that returns no currently permitted candidates. The +crate then clears previously installed generated definitions and retains only +the exact static bootstrap and discovery definitions. + +Override +`AiReadOnlyAgentTurnPlanner::continuation_plan_with_capability_delivery` when +using capability delivery. The optional value is the crate-owned run state +that survived the preceding turn. After client-deferred discovery it already +contains the exact loaded definitions. Use its `current_surface()` with +`AiProviderCallPlan::new_continuation_with_capability_surface`, then attach a +clone of that same delivery turn. Recreating state under the same public +fingerprint, retaining an earlier surface, or returning a plan without the +delivery turn fails closed. + +Hosts that constrain tools through hierarchical agent rules must admit the +exact broker-definition fingerprints present in the selected surface as +approval-free read tools. This grants no application authority: discover and +describe return bounded metadata, and execute still performs fresh principal, +policy, target/schema/catalogue/capability, resolver, disclosure, egress, +budget and fence checks. + +Size loop limits for delivery amplification. A novel fixed-broker capability +uses discover, describe and execute (three application-tool calls); a +completed-turn adapter also needs a provider continuation for each result, +while an in-turn dynamic adapter can keep the same provider turn open. A +still-loaded capability reuses execute only. Client-deferred use adds discovery +and one continuation before the exact tool call. All calls count against the +existing tool-call, provider-turn, duration, budget and rule ceilings; +increasing them is a host policy decision, never an automatic library bypass. + +Size `AiCapabilityDeliveryLimits::maximum_describe_bytes` together with the +ordinary application-tool result and provider-input ceilings. It bounds the +complete on-demand planning contract (512 KiB by default); a larger exact +schema is omitted with `planSchemaAvailable: false`, never truncated. + +Changing delivery mode, index set, static bootstrap tools, projection, model, +reasoning effort or registration identity changes the provider capability +session binding. Retained sessions with a different binding remain +cleanup-only and must reach exact absence before rebind. Process restart loses +only the bounded non-authoritative broker cache: a later describe/execute +returns a safe stale-selection result and the model must rediscover. + +## 0.83.0 to 0.84.0: budget reclamation and pre-transport denial (schema 0.62.0 to 0.63.0) ### Schema module diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 63f4ed7f..81ed8b5c 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.84.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.85.0", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -71,7 +71,8 @@ the compiled test-backed recipe and the missing reusable bootstrap API. one read-only provider plan may expose these capabilities beside exact legacy/static read descriptors without duplicating either policy contract. Secret/`NeverExport` fields never enter provider schemas. -- A deterministic compact capability index, bounded lexical discovery, +- Deterministic per-target compact capability indexes, one canonical + collision-free multi-target index set, bounded lexical discovery, coordinator-selected eager/client-deferred/provider-deferred/fixed-broker delivery, and short-lived current-authority loaded bindings. See [capability discovery and execution](docs/capability-discovery-and-execution.md). diff --git a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md index 4dddaa09..6bd1d579 100644 --- a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md +++ b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md @@ -3,7 +3,7 @@ title: "Capability discovery and execution" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-08-21 review_by: 2027-02-01 supersedes: [] --- @@ -37,10 +37,21 @@ coordinate, resolver URL, auth mechanism, policy expression, token, delegated authority, hidden field, secret classification or protected value. It is a complete catalogue, not a prompt payload and not an authorization cache. +A federated host compiles one index per owning logical target, then combines +them with `AiCapabilityIndexSet::compile`. The set sorts targets +deterministically, rejects every cross-target capability-ID collision, and +fingerprints the exact target-to-index membership. It does not invent a +combined SDL, semantic catalogue, or policy fingerprint. Search ranks entries +globally, while load and execute recover the sole owning index and revalidate +that target's schema, semantic catalogue, target policy, capability, and +current resolver authority. A host-authored combined fingerprint is neither +needed nor accepted. + ```rust,no_run -# use graphql_orm_ai::{AiCapabilityIndex, AiCapabilityIndexLimits}; -# fn build(target: graphql_orm_ai::GraphqlExecutionTargetId, schema_fingerprint: String, semantics: &graphql_orm_ai::GraphqlSemanticCatalog, queries: &graphql_orm_ai::AiGraphqlQueryCapabilityCatalog, static_tools: Vec) -> Result { -AiCapabilityIndex::compile( +# use std::sync::Arc; +# use graphql_orm_ai::{AiCapabilityIndex, AiCapabilityIndexLimits, AiCapabilityIndexSet}; +# fn build(target: graphql_orm_ai::GraphqlExecutionTargetId, schema_fingerprint: String, semantics: &graphql_orm_ai::GraphqlSemanticCatalog, queries: &graphql_orm_ai::AiGraphqlQueryCapabilityCatalog, static_tools: Vec) -> Result { +let index = AiCapabilityIndex::compile( target, schema_fingerprint, semantics, @@ -50,7 +61,8 @@ AiCapabilityIndex::compile( static_tools, "exact-target-policy-v7", AiCapabilityIndexLimits::default(), -) +)?; +AiCapabilityIndexSet::compile([Arc::new(index)]) # } ``` @@ -59,17 +71,20 @@ AiCapabilityIndex::compile( The built-in search uses bounded normalized lexical terms, optional exact namespace/kind/entity-or-operation filters, deterministic scores and stable ID tie-breaking. It needs no embeddings or external database. A host may later -implement the closed current-index and authority traits with a reviewed search -service, but returned IDs and fingerprints must still bind to the canonical -index. +implement the closed current-index-set and authority traits with a reviewed +search service, but returned IDs and fingerprints must still bind to the +canonical set and the candidate's exact owning index. `AiCapabilityDiscoveryBroker` rehydrates the current principal and reapplies host scope, target, kind, classification, provider and session policy while filtering search results, again while loading, and again immediately before -execution. Its `AiLoadedCapabilityBinding` is crate-created, private-field, +execution. `AiCapabilityAuthorityPolicy` receives the exact owning index on +each check; hosts apply target policy from that proof rather than parsing an +ID. Its `AiLoadedCapabilityBinding` is crate-created, private-field, short-lived and fenced to owner reference, session, run, attempt, lease, -provider session, target policy, schema, semantic catalogue, index, entry, -kind and capability. Revocation or drift between any two stages fails closed. +provider session, aggregate index set, owning target policy, schema, semantic +catalogue, index, entry, kind and capability. Revocation or drift between any +two stages fails closed. The final call must still pass through the ordinary durable application-tool broker and authenticated GraphQL resolver. @@ -82,9 +97,9 @@ negotiation metadata, never authority. | Mode | Initial surface | Use | | --- | --- | --- | | `EagerExact` | All already-filtered exact definitions | Small sets within exact count and byte limits | -| `ClientDeferred` | Discovery only; freshly loaded exact definitions on the next continuation | Stateless/local or client-executed search | +| `ClientDeferred` | Exact static bootstrap plus discovery; freshly loaded generated-query definitions on the next continuation | Stateless/local or client-executed search | | `ProviderDeferred` | Already-filtered definitions marked for reviewed native deferred loading | Native provider tool search | -| `FixedBroker` | Frozen discover/describe/execute definitions | Retained sessions whose definitions cannot change | +| `FixedBroker` | Exact static bootstrap plus frozen discover/describe/execute definitions | Retained sessions whose generated definitions cannot change | `prepare_client_deferred_continuation` accepts only definitions matching the crate-owned loaded bindings and the configured selection count. Fixed broker @@ -93,9 +108,94 @@ selected capability's authoritative compiler validates every name and type. No broker accepts arbitrary GraphQL, target, alias, fragment, introspection, SQL, URL or callback. -`AiProviderCapabilitySessionBinding` fingerprints delivery mode, index, static -bootstrap definitions, projection algorithm, model, reasoning effort and the -underlying registration identity. Pass it to +### Coordinator construction and dispatch + +One `AiCapabilityDeliveryTurn` is the run-owned bridge between mode selection, +the exact provider surface and durable broker execution: + +```rust,ignore +let delivery = AiCapabilityDeliveryTurn::select( + provider_capabilities, + index_set.fingerprint(), + currently_eligible_read_definitions, + retained_definitions_frozen, + provider_capability_session_binding, + capability_broker, + AiCapabilityBrokerSession::new(delivery_limits)?, +)?; + +let surface = delivery.current_surface(); +request.tools = surface.tools().to_vec(); +let provider_plan = AiProviderCallPlan::new_with_capability_surface( + provider_kind, + request, + budget, + transfers, + correlation_id, + &surface, + runtime.tool_catalog(), + static_policy, + generated_target_policy, +)?; +let turn = AiReadOnlyAgentTurnPlan::new( + provider_plan, + result_egress_route, + rules, + uses_byok, +)? +.with_capability_delivery(delivery.clone())?; +``` + +The host composes `OrmAiApplicationToolCallService` as the coordinator's +ordinary tool executor. The coordinator recognizes only the three frozen IDs +from the exact offered surface and dispatches them through that service; a +host executor retains a fail-closed default and does not gain broker execution +implicitly. + +For a continuation, override +`AiReadOnlyAgentTurnPlanner::continuation_plan_with_capability_delivery`. Use +the supplied delivery turn's current surface with +`AiProviderCallPlan::new_continuation_with_capability_surface`, and attach a +clone of that same turn. Client-deferred discovery installs freshly loaded +generated-query definitions only after its durable tool result and checkpoint +commit. An empty current-authority discovery clears earlier generated +definitions rather than retaining a stale provider surface. Recreating blank +broker state under an equal public fingerprint, retaining a pre-discovery +surface, or substituting definitions fails closed. + +Hierarchical rule policy must admit the exact broker fingerprints present in +the crate-owned surface as approval-free read tools. That is a loop constraint, +not application authority: the execute operation still rehydrates the current +principal and passes the selected generated query through the ordinary policy, +delegation, resolver and disclosure boundary. + +Fixed-broker `describe` returns the exact compact planning schema only on +demand. The complete description, including that schema, is bounded by +`AiCapabilityDeliveryLimits::maximum_describe_bytes` (512 KiB by default and +4 MiB at the compiled ceiling). An oversized schema is not truncated or +partially exposed: the response sets `planSchemaAvailable` to `false`, and the +provider must choose another reviewed capability or return a bounded answer. + +### Turn amplification + +Delivery work consumes normal bounded-loop capacity: + +| Path | Extra broker calls before/including application execution | +| --- | ---: | +| Eager exact | 0 | +| Provider deferred | Provider-native discovery only; exact application call still counts normally | +| Client deferred, newly selected capability | 1 discover, then the exact application call | +| Fixed broker, novel capability | discover + describe + execute = 3 | +| Fixed broker, still-loaded capability | execute = 1 | + +Every completed broker result is ordinary provider input, so stateless loops +also consume the corresponding continuation turns. Size provider-turn, +tool-call, duration, budget and hierarchical-rule ceilings for this bounded +amplification. The library never raises a host ceiling automatically. + +`AiProviderCapabilitySessionBinding` fingerprints delivery mode, the complete +canonical index set, static bootstrap definitions, projection algorithm, +model, reasoning effort and the underlying registration identity. Pass it to `AiProviderSessionDescriptor::new_with_capability_binding`; the existing durable registration fence then makes any change cleanup-and-rebind only. @@ -259,8 +359,9 @@ an automatic consequence of migrations: deletion adapter from the persisted provider kind and registration metadata, open only the exact protected cursor, then record exact absence or bounded retry. -5. Compile the finished schema, semantics, index, target policy and provider - registrations; only then start ordinary workers and subscriptions. +5. Compile every target's finished schema, semantics, index and target policy; + compile their canonical index set and provider registrations; only then + start ordinary workers and subscriptions. Changing provider profile, delivery mode, model/effort, projection or catalogue requires the same cleanup/absence/rebind lifecycle. A live provider session is diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index dc3b844e..8ee73fb1 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,9 +10,9 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.84.0` with AI schema module +`graphql-orm-ai` is at crate version `0.85.0` with AI schema module `0.63.0`. It uses workspace `graphql-orm` `0.23.0`, backend-neutral -`graphql-orm-ai-tool-profiles` `0.7.0`, and external `agql-auth` +`graphql-orm-ai-tool-profiles` `0.8.0`, and external `agql-auth` `0.15.0` at `e841ffd382082ad7419be259fe957f949b956ff7`. The active work order, dependencies, and exit gates are maintained in the @@ -76,8 +76,10 @@ verification evidence belongs in the focused guides. current host policy, and ordinary resolver authorization. Read-only and the bounded sequential supervised path are implemented; mixed, parallel, and stateless consequential execution remains closed. -- Finished schema and semantic metadata now produce one complete deterministic - compact capability index. Providers receive an exact small eager set, +- Finished schemas and semantic metadata now produce canonical deterministic + compact capability indexes. A fingerprinted index set combines multiple + owning subgraphs without erasing their target/schema identities or allowing + cross-index substitution. Providers receive an exact small eager set, client/native deferred definitions, or a frozen three-tool broker according to declared capability and exact byte/count limits. Discovery and loaded bindings recheck current principal/policy and grant no resolver authority. diff --git a/crates/graphql-orm-ai/src/capability_delivery.rs b/crates/graphql-orm-ai/src/capability_delivery.rs index c7f2e599..a825a540 100644 --- a/crates/graphql-orm-ai/src/capability_delivery.rs +++ b/crates/graphql-orm-ai/src/capability_delivery.rs @@ -1,7 +1,7 @@ //! Provider-neutral capability delivery, loading, and run fencing. -use std::collections::BTreeSet; -use std::sync::Arc; +use std::collections::{BTreeSet, VecDeque}; +use std::sync::{Arc, Mutex, PoisonError}; use agql_auth::{Clock, CurrentPrincipalResolver, PrincipalReference, ResolvedPrincipal}; use async_trait::async_trait; @@ -12,11 +12,22 @@ use time::{Duration, OffsetDateTime}; use uuid::Uuid; use crate::{ - AiCapabilityIndex, AiCapabilityIndexEntry, AiCapabilityKind, AiCapabilitySearchQuery, - AiCapabilitySearchResult, AiError, AiRunId, AiSessionId, AiToolId, ModelReasoningEffort, - ModelToolDefinition, ProviderCapabilities, ProviderKind, + AiCapabilityIndex, AiCapabilityIndexEntry, AiCapabilityIndexSet, + AiCapabilityIndexSetSearchResult, AiCapabilityKind, AiCapabilitySearchQuery, AiError, AiRunId, + AiSessionId, AiToolId, ModelReasoningEffort, ModelToolDefinition, ProviderCapabilities, + ProviderKind, }; +/// Frozen broker tool identifier for bounded capability discovery. +pub const AI_CAPABILITY_DISCOVER_TOOL_ID: &str = "graphql.capabilities.discover"; +/// Frozen broker tool identifier for loading one exact capability contract. +pub const AI_CAPABILITY_DESCRIBE_TOOL_ID: &str = "graphql.capabilities.describe"; +/// Frozen broker tool identifier for executing one loaded exact capability. +pub const AI_CAPABILITY_EXECUTE_TOOL_ID: &str = "graphql.capabilities.execute"; + +/// Current broker request/response envelope version. +pub const AI_CAPABILITY_BROKER_VERSION: u16 = 1; + /// Capability-definition delivery strategy selected by the coordinator. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -41,6 +52,27 @@ pub struct AiCapabilityDeliveryLimits { pub maximum_eager_definition_bytes: u32, /// Maximum exact definitions installed after one discovery continuation. pub maximum_deferred_definitions: u16, + /// Maximum bounded discovery results retained per fenced run. + #[serde(default = "default_maximum_retained_searches")] + pub maximum_retained_searches: u16, + /// Maximum short-lived loaded bindings retained per fenced run. + #[serde(default = "default_maximum_loaded_bindings")] + pub maximum_loaded_bindings: u16, + /// Maximum canonical bytes in one describe planning contract. + #[serde(default = "default_maximum_describe_bytes")] + pub maximum_describe_bytes: u32, +} + +const fn default_maximum_retained_searches() -> u16 { + 4 +} + +const fn default_maximum_loaded_bindings() -> u16 { + 16 +} + +const fn default_maximum_describe_bytes() -> u32 { + 512 * 1024 } impl Default for AiCapabilityDeliveryLimits { @@ -49,7 +81,38 @@ impl Default for AiCapabilityDeliveryLimits { maximum_eager_definitions: 16, maximum_eager_definition_bytes: 256 * 1024, maximum_deferred_definitions: 8, + maximum_retained_searches: default_maximum_retained_searches(), + maximum_loaded_bindings: default_maximum_loaded_bindings(), + maximum_describe_bytes: default_maximum_describe_bytes(), + } + } +} + +impl AiCapabilityDeliveryLimits { + /// Rejects a limit set that cannot represent a complete + /// discover/describe/execute broker interaction. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] when any bound is zero or a + /// broker bound is outside its compiled ceiling. + pub fn validate(self) -> Result<(), AiError> { + if self.maximum_eager_definitions == 0 + || self.maximum_eager_definition_bytes == 0 + || self.maximum_deferred_definitions == 0 + || self.maximum_deferred_definitions > self.maximum_loaded_bindings + || self.maximum_retained_searches == 0 + || self.maximum_retained_searches > 64 + || self.maximum_loaded_bindings == 0 + || self.maximum_loaded_bindings > 256 + || self.maximum_describe_bytes < 1_024 + || self.maximum_describe_bytes > 4 * 1_024 * 1_024 + { + return Err(AiError::InvalidConfiguration( + "capability delivery limits are invalid".to_owned(), + )); } + Ok(()) } } @@ -81,7 +144,9 @@ impl AiCapabilityDeliverySurface { /// Exact initial provider definitions. Client-deferred mode contains only /// discovery; provider-deferred mode contains filtered definitions marked - /// for native deferred loading; fixed mode contains only the frozen broker. + /// for native deferred loading. A run-owned surface returned by + /// [`AiCapabilityDeliveryTurn::current_surface`] also retains the exact + /// static bootstrap definitions fingerprinted into its session binding. pub fn tools(&self) -> &[ModelToolDefinition] { &self.tools } @@ -113,6 +178,16 @@ pub fn prepare_capability_delivery_surface( definition.validate().map_err(|_| { AiError::InvalidConfiguration("capability definition is invalid".to_owned()) })?; + if matches!( + definition.tool_id.as_str(), + AI_CAPABILITY_DISCOVER_TOOL_ID + | AI_CAPABILITY_DESCRIBE_TOOL_ID + | AI_CAPABILITY_EXECUTE_TOOL_ID + ) { + return Err(AiError::InvalidConfiguration( + "application definitions use a reserved capability-broker ID".to_owned(), + )); + } if !ids.insert(definition.tool_id.as_str()) || !names.insert(definition.provider_name.as_str()) { @@ -158,8 +233,7 @@ pub fn prepare_client_deferred_continuation( mut exact_definitions: Vec, limits: AiCapabilityDeliveryLimits, ) -> Result, AiError> { - if loaded.is_empty() - || loaded.len() != exact_definitions.len() + if loaded.len() != exact_definitions.len() || loaded.len() > usize::from(limits.maximum_deferred_definitions) { return Err(AiError::InvalidInput( @@ -193,16 +267,16 @@ pub fn prepare_client_deferred_continuation( fn discovery_definition(index_fingerprint: &str) -> ModelToolDefinition { broker_definition( - "graphql.capabilities.discover", + AI_CAPABILITY_DISCOVER_TOOL_ID, "graphql_capabilities_discover", - "Search the current reviewed GraphQL capability index.", + "Search the current reviewed GraphQL read-query capability index.", index_fingerprint, json!({ "type": "object", "properties": { "text": {"type": "string", "maxLength": 1024}, "namespace": {"type": ["string", "null"], "maxLength": 256}, - "kind": {"type": ["string", "null"], "enum": ["reviewed_static", "generated_query", "generated_mutation", "generated_subscription", null]}, + "kind": {"type": ["string", "null"], "enum": ["generated_query", null]}, "entityOrClass": {"type": ["string", "null"], "maxLength": 256}, "maximumResults": {"type": "integer", "minimum": 1, "maximum": 32} }, @@ -215,7 +289,7 @@ fn discovery_definition(index_fingerprint: &str) -> ModelToolDefinition { fn fixed_broker_definitions(index_fingerprint: &str) -> Vec { let discover = discovery_definition(index_fingerprint); let describe = broker_definition( - "graphql.capabilities.describe", + AI_CAPABILITY_DESCRIBE_TOOL_ID, "graphql_capabilities_describe", "Load one exact current capability and return its bounded public planning contract.", index_fingerprint, @@ -230,7 +304,7 @@ fn fixed_broker_definitions(index_fingerprint: &str) -> Vec }), ); let execute = broker_definition( - "graphql.capabilities.execute", + AI_CAPABILITY_EXECUTE_TOOL_ID, "graphql_capabilities_execute", "Execute one previously loaded exact capability using a closed public-name query plan.", index_fingerprint, @@ -243,16 +317,52 @@ fn fixed_broker_definitions(index_fingerprint: &str) -> Vec "items": { "type": "object", "properties": { - "name": {"type": "string", "maxLength": 256}, + "name": {"type": "string", "minLength": 1, "maxLength": 256}, "value": {"type": ["string", "integer", "number", "boolean", "null"]} }, "required": ["name", "value"], "additionalProperties": false } }, - "selections": {"type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"type": "string", "maxLength": 512}} + "selections": {"type": "array", "maxItems": 256, "uniqueItems": true, "items": {"type": "string", "maxLength": 512}}, + "relationshipArguments": { + "type": "array", "maxItems": 64, + "items": { + "type": "object", + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 512}, + "arguments": { + "type": "array", "maxItems": 64, + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "value": {"type": ["string", "integer", "number", "boolean", "null"]} + }, + "required": ["name", "value"], + "additionalProperties": false + } + } + }, + "required": ["path", "arguments"], + "additionalProperties": false + } + }, + "relationshipMaximumItems": { + "type": "array", "maxItems": 64, + "items": { + "type": "object", + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 512}, + "maximumItems": {"type": "integer", "minimum": 1, "maximum": 10000} + }, + "required": ["path", "maximumItems"], + "additionalProperties": false + } + }, + "maximumItems": {"type": ["integer", "null"], "minimum": 1, "maximum": 10000} }, - "required": ["loadedReference", "arguments", "selections"], + "required": ["loadedReference", "arguments", "selections", "relationshipArguments", "relationshipMaximumItems", "maximumItems"], "additionalProperties": false }), ); @@ -296,14 +406,7 @@ pub fn select_capability_delivery_mode( retained_definitions_frozen: bool, limits: AiCapabilityDeliveryLimits, ) -> Result { - if limits.maximum_eager_definitions == 0 - || limits.maximum_eager_definition_bytes == 0 - || limits.maximum_deferred_definitions == 0 - { - return Err(AiError::InvalidConfiguration( - "capability delivery limits are invalid".to_owned(), - )); - } + limits.validate()?; let supported = &provider.capability_delivery_modes; let eager_fits = exact_definition_count <= usize::from(limits.maximum_eager_definitions) && exact_definition_bytes <= limits.maximum_eager_definition_bytes as usize; @@ -315,7 +418,9 @@ pub fn select_capability_delivery_mode( Some(AiCapabilityDeliveryMode::EagerExact) } else if supported.contains(&AiCapabilityDeliveryMode::ProviderDeferred) { Some(AiCapabilityDeliveryMode::ProviderDeferred) - } else if supported.contains(&AiCapabilityDeliveryMode::ClientDeferred) { + } else if !retained_definitions_frozen + && supported.contains(&AiCapabilityDeliveryMode::ClientDeferred) + { Some(AiCapabilityDeliveryMode::ClientDeferred) } else if supported.contains(&AiCapabilityDeliveryMode::FixedBroker) { Some(AiCapabilityDeliveryMode::FixedBroker) @@ -336,7 +441,7 @@ pub fn select_capability_delivery_mode( pub struct AiProviderCapabilitySessionBinding { /// Selected delivery mode. delivery_mode: AiCapabilityDeliveryMode, - /// Canonical compact-index fingerprint. + /// Canonical capability-index-set fingerprint. capability_index_fingerprint: String, /// Stable fingerprints of frozen static bootstrap tools. static_bootstrap_tool_fingerprints: BTreeSet, @@ -417,11 +522,19 @@ impl AiProviderCapabilitySessionBinding { self.delivery_mode } - /// Exact compact-index fingerprint. + /// Exact canonical capability-index-set fingerprint. pub fn capability_index_fingerprint(&self) -> &str { &self.capability_index_fingerprint } + /// Exact canonical capability-index-set fingerprint. + /// + /// This is the explicit multi-target name for + /// [`Self::capability_index_fingerprint`]. + pub fn capability_index_set_fingerprint(&self) -> &str { + &self.capability_index_fingerprint + } + /// Exact frozen static bootstrap tool fingerprints. pub fn static_bootstrap_tool_fingerprints(&self) -> &BTreeSet { &self.static_bootstrap_tool_fingerprints @@ -488,6 +601,7 @@ pub trait AiCapabilityAuthorityPolicy: Send + Sync { async fn authorize( &self, principal: &ResolvedPrincipal, + owning_index: &AiCapabilityIndex, entry: &AiCapabilityIndexEntry, run: &AiCapabilityRunBinding, ) -> Result; @@ -503,6 +617,33 @@ pub trait AiCurrentCapabilityIndex: Send + Sync { ) -> Result, AiError>; } +/// Supplies the current canonical set of independently compiled indexes. +/// +/// Implementations may cache immutable catalogue data but must never cache an +/// authority decision. Every capability ID in the returned set has one exact +/// owning logical target. A single-index source automatically implements this +/// contract for backward-compatible consumers. +pub trait AiCurrentCapabilityIndexSet: Send + Sync { + /// Returns the complete current multi-target index set for one run. + fn current_index_set( + &self, + run: &AiCapabilityRunBinding, + ) -> Result, AiError>; +} + +impl AiCurrentCapabilityIndexSet for T +where + T: AiCurrentCapabilityIndex + ?Sized, +{ + fn current_index_set( + &self, + run: &AiCapabilityRunBinding, + ) -> Result, AiError> { + let index = self.current_index(run)?; + Ok(Arc::new(AiCapabilityIndexSet::compile([index])?)) + } +} + /// Opaque short-lived crate-owned loaded capability proof. /// /// Fields are private and the type is not deserializable, so a host or model @@ -515,6 +656,7 @@ pub struct AiLoadedCapabilityBinding { capability_kind: AiCapabilityKind, capability_fingerprint: String, entry_fingerprint: String, + index_set_fingerprint: String, index_fingerprint: String, schema_fingerprint: String, semantic_catalogue_fingerprint: String, @@ -556,7 +698,7 @@ impl AiLoadedCapabilityBinding { #[derive(Clone)] pub struct AiCapabilityDiscoveryBroker { principal_resolver: Arc, - current_index: Arc, + current_indexes: Arc, authority: Arc, clock: Arc, loaded_ttl: Duration, @@ -570,7 +712,7 @@ impl AiCapabilityDiscoveryBroker { /// Rejects TTLs outside one second through five minutes. pub fn new( principal_resolver: Arc, - current_index: Arc, + current_indexes: Arc, authority: Arc, clock: Arc, loaded_ttl: Duration, @@ -582,7 +724,7 @@ impl AiCapabilityDiscoveryBroker { } Ok(Self { principal_resolver, - current_index, + current_indexes, authority, clock, loaded_ttl, @@ -601,14 +743,17 @@ impl AiCapabilityDiscoveryBroker { principal_reference: &PrincipalReference, run: &AiCapabilityRunBinding, query: &AiCapabilitySearchQuery, - ) -> Result { + ) -> Result { let principal = self.rehydrate(principal_reference).await?; - let index = self.current_index.current_index(run)?; - let mut result = index.search(query)?; + let indexes = self.current_indexes.current_index_set(run)?; + let mut result = indexes.search(query)?; let mut permitted = Vec::new(); for candidate in result.candidates { - let entry = index.entry(&candidate.id).ok_or(AiError::Forbidden)?; - let decision = self.authority.authorize(&principal, entry, run).await?; + let (index, entry) = indexes.entry(&candidate.id).ok_or(AiError::Forbidden)?; + let decision = self + .authority + .authorize(&principal, index, entry, run) + .await?; if decision.allowed { permitted.push(candidate); } @@ -628,25 +773,28 @@ impl AiCapabilityDiscoveryBroker { &self, principal_reference: &PrincipalReference, run: &AiCapabilityRunBinding, - search: &AiCapabilitySearchResult, + search: &AiCapabilityIndexSetSearchResult, capability_id: &AiToolId, ) -> Result { let principal = self.rehydrate(principal_reference).await?; - let index = self.current_index.current_index(run)?; - verify_search_binding(&index, search)?; + let indexes = self.current_indexes.current_index_set(run)?; + verify_search_binding(&indexes, search)?; let candidate = search .candidates .iter() .find(|candidate| &candidate.id == capability_id) .ok_or(AiError::Forbidden)?; - let entry = index.entry(capability_id).ok_or(AiError::Forbidden)?; + let (index, entry) = indexes.entry(capability_id).ok_or(AiError::Forbidden)?; if candidate.kind != entry.kind || candidate.capability_fingerprint != entry.capability_fingerprint || candidate.entry_fingerprint != entry.fingerprint { return Err(AiError::Forbidden); } - let decision = self.authority.authorize(&principal, entry, run).await?; + let decision = self + .authority + .authorize(&principal, index, entry, run) + .await?; if !decision.allowed || !valid_binding_value(&decision.policy_fingerprint) { return Err(AiError::Forbidden); } @@ -657,6 +805,7 @@ impl AiCapabilityDiscoveryBroker { capability_kind: entry.kind, capability_fingerprint: entry.capability_fingerprint.clone(), entry_fingerprint: entry.fingerprint.clone(), + index_set_fingerprint: indexes.fingerprint().to_owned(), index_fingerprint: index.fingerprint().to_owned(), schema_fingerprint: index.schema_fingerprint().to_owned(), semantic_catalogue_fingerprint: index.semantic_catalogue_fingerprint().to_owned(), @@ -691,20 +840,21 @@ impl AiCapabilityDiscoveryBroker { || loaded.session_id != run.session_id || loaded.run_id != run.run_id || loaded.attempt_id != run.attempt_id - || loaded.lease_generation != run.lease_generation + || run.lease_generation < loaded.lease_generation || loaded.provider_kind != run.provider_kind || loaded.provider_session_fingerprint != run.provider_session_fingerprint { return Err(AiError::Forbidden); } let principal = self.rehydrate(principal_reference).await?; - let index = self.current_index.current_index(run)?; - let entry = index + let indexes = self.current_indexes.current_index_set(run)?; + let (index, entry) = indexes .entry(&loaded.capability_id) .ok_or(AiError::Forbidden)?; if loaded.capability_kind != entry.kind || loaded.capability_fingerprint != entry.capability_fingerprint || loaded.entry_fingerprint != entry.fingerprint + || loaded.index_set_fingerprint != indexes.fingerprint() || loaded.index_fingerprint != index.fingerprint() || loaded.schema_fingerprint != index.schema_fingerprint() || loaded.semantic_catalogue_fingerprint != index.semantic_catalogue_fingerprint() @@ -712,7 +862,10 @@ impl AiCapabilityDiscoveryBroker { { return Err(AiError::Forbidden); } - let decision = self.authority.authorize(&principal, entry, run).await?; + let decision = self + .authority + .authorize(&principal, index, entry, run) + .await?; if !decision.allowed || decision.policy_fingerprint != loaded.policy_fingerprint { return Err(AiError::Forbidden); } @@ -731,14 +884,10 @@ impl AiCapabilityDiscoveryBroker { } fn verify_search_binding( - index: &AiCapabilityIndex, - search: &AiCapabilitySearchResult, + indexes: &AiCapabilityIndexSet, + search: &AiCapabilityIndexSetSearchResult, ) -> Result<(), AiError> { - if search.index_fingerprint != index.fingerprint() - || search.schema_fingerprint != index.schema_fingerprint() - || search.semantic_catalogue_fingerprint != index.semantic_catalogue_fingerprint() - || search.target_policy_fingerprint != index.target_policy_fingerprint() - { + if search.index_set_fingerprint != indexes.fingerprint() { return Err(AiError::Forbidden); } Ok(()) @@ -759,159 +908,1469 @@ fn hash_json(value: &serde_json::Value) -> String { hex::encode(Sha256::digest(encoded)) } -#[cfg(test)] -mod tests { - use std::sync::{ - RwLock, - atomic::{AtomicBool, Ordering}, - }; - - use agql_auth::{AccessTokenMetadata, AuthPrincipal, AuthUser, FixedClock, SessionContext}; - use graphql_orm::graphql::orm::{ - GeneratedGraphqlOperationDescriptor, GraphqlOperationCatalog, GraphqlSemanticCatalog, - }; +/// One frozen capability-broker meta-operation. +/// +/// The three operations are the complete crate-owned broker surface. Resolving +/// an operation from a tool identifier is discovery, not authorization: every +/// dispatch still rehydrates the principal, reapplies current host policy and, +/// for [`Self::Execute`] only, passes through ordinary resolver authorization. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiCapabilityBrokerOperation { + /// Bounded authority-neutral search over the current compact index. + Discover, + /// Load one exact current capability and return its planning contract. + Describe, + /// Execute one previously loaded exact capability through a closed plan. + Execute, +} - use super::*; +impl AiCapabilityBrokerOperation { + /// Resolves the frozen broker operation for one stable tool identifier. + /// + /// Returns `None` for every ordinary registered capability. + pub fn from_tool_id(tool_id: &AiToolId) -> Option { + match tool_id.as_str() { + AI_CAPABILITY_DISCOVER_TOOL_ID => Some(Self::Discover), + AI_CAPABILITY_DESCRIBE_TOOL_ID => Some(Self::Describe), + AI_CAPABILITY_EXECUTE_TOOL_ID => Some(Self::Execute), + _ => None, + } + } - fn definition(id: &str) -> ModelToolDefinition { - ModelToolDefinition { - tool_id: id.to_owned(), - provider_name: id.replace('.', "_"), - fingerprint: hash_json(&json!({"id": id})), - description: "Read one reviewed record.".to_owned(), - parameters: json!({ - "type": "object", - "properties": {"id": {"type": "string"}}, - "required": ["id"], - "additionalProperties": false - }), - strict: true, - defer_loading: false, + /// Frozen stable tool identifier for this operation. + pub const fn tool_id(self) -> &'static str { + match self { + Self::Discover => AI_CAPABILITY_DISCOVER_TOOL_ID, + Self::Describe => AI_CAPABILITY_DESCRIBE_TOOL_ID, + Self::Execute => AI_CAPABILITY_EXECUTE_TOOL_ID, } } - fn principal() -> AuthPrincipal { - AuthPrincipal::User(AuthUser { - user_id: "capability-user".to_owned(), - session_id: Uuid::from_u128(10), - roles: Vec::new(), - scopes: vec!["jim.read".to_owned()], - session: SessionContext::default(), - token_claims: AccessTokenMetadata { - tenant_id: Some("tenant-1".to_owned()), - ..AccessTokenMetadata::default() - }, + /// Whether this operation can reach an application resolver. + /// + /// Only [`Self::Execute`] can. Discovery and describe return bounded + /// authority-neutral metadata and grant no execution authority. + pub const fn reaches_resolver(self) -> bool { + matches!(self, Self::Execute) + } +} + +/// Returns the three frozen broker definitions for one exact compact index. +/// +/// The definitions are crate-authored and deterministic: a host installs them +/// verbatim and cannot widen, rename, or re-fingerprint the broker surface. +/// +/// # Errors +/// +/// Returns [`AiError::InvalidConfiguration`] for a malformed index +/// fingerprint. +pub fn capability_broker_definitions( + index_fingerprint: &str, +) -> Result, AiError> { + if !crate::valid_sha256(index_fingerprint) { + return Err(AiError::InvalidConfiguration( + "capability delivery index fingerprint is invalid".to_owned(), + )); + } + Ok(fixed_broker_definitions(index_fingerprint)) +} + +/// Canonical serialized byte cost of one exact definition set. +/// +/// This is the measurement `select_capability_delivery_mode` compares against +/// [`AiCapabilityDeliveryLimits::maximum_eager_definition_bytes`]. +/// +/// # Errors +/// +/// Returns [`AiError::InvalidConfiguration`] when a definition cannot be +/// canonically serialized. +pub fn capability_definition_bytes(definitions: &[ModelToolDefinition]) -> Result { + let mut total = 0usize; + for definition in definitions { + let encoded = serde_json::to_vec(definition).map_err(|_| { + AiError::InvalidConfiguration("capability definition is invalid".to_owned()) + })?; + total = total.checked_add(encoded.len()).ok_or_else(|| { + AiError::InvalidConfiguration("capability definitions are too large".to_owned()) + })?; + } + Ok(total) +} + +/// Selects the delivery mode and builds the exact initial surface in one step. +/// +/// This is the single per-turn entry point: it measures the already-filtered +/// exact definitions, calls [`select_capability_delivery_mode`], then +/// [`prepare_capability_delivery_surface`]. Prompt text never reaches either +/// decision. +/// +/// # Errors +/// +/// Returns a safe error when no provider-declared mode can represent the +/// surface, the limits are invalid, or a definition is malformed. +pub fn plan_capability_delivery( + provider: &ProviderCapabilities, + index_fingerprint: &str, + filtered_exact_definitions: Vec, + retained_definitions_frozen: bool, + limits: AiCapabilityDeliveryLimits, +) -> Result { + let bytes = capability_definition_bytes(&filtered_exact_definitions)?; + let decision = select_capability_delivery_mode( + provider, + filtered_exact_definitions.len(), + bytes, + retained_definitions_frozen, + limits, + )?; + prepare_capability_delivery_surface(decision, index_fingerprint, filtered_exact_definitions) +} + +/// Derives one deterministic bounded provider alias for a capability ID. +/// +/// Provider function names are limited to 64 bytes of `[A-Za-z0-9_-]`. The +/// alias substitutes unsafe bytes and, when truncation is required, appends a +/// stable short digest of the complete identifier, so two distinct +/// capabilities can never collide on one alias. The alias is a naming +/// convention only and grants nothing. +pub fn capability_provider_alias(tool_id: &AiToolId) -> String { + let sanitized = tool_id + .as_str() + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') { + char::from(byte) + } else { + '_' + } }) + .collect::(); + if sanitized.len() <= 64 { + return sanitized; } + let digest = hex::encode(Sha256::digest(tool_id.as_str().as_bytes())); + format!("{}_{}", &sanitized[..47], &digest[..16]) +} - fn index(target_policy: &str) -> Arc { - let operation_catalogue = GraphqlOperationCatalog::compose(Vec::<( - &'static [GeneratedGraphqlOperationDescriptor], - bool, - bool, - )>::new()); - let semantics = GraphqlSemanticCatalog::compose(Vec::new(), &operation_catalogue) - .expect("semantic catalogue"); - let descriptor = crate::AiToolDescriptor::new( - "jim.jobs_list", - "List the latest Jim jobs.", - crate::AiToolOperationKind::Query, - "query ReviewedJobs { jobs }", - json!({ - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": false - }), - ) - .expect("descriptor") - .with_maximum_classification(crate::DataClassification::Public); - Arc::new( - AiCapabilityIndex::compile( - crate::GraphqlExecutionTargetId::parse("jim-production").expect("target"), - "schema-v1", - &semantics, - None, - None, - None, - [descriptor], - target_policy, - crate::AiCapabilityIndexLimits::default(), - ) - .expect("capability index"), - ) +/// Observed broker turn/call amplification for one fenced run. +/// +/// A novel capability costs one discover, one describe, and one execute call, +/// while a loaded capability costs one execute call. Completed-turn adapters +/// need one continuation provider turn per result; an in-turn dynamic-tool +/// adapter can perform the same broker calls inside one provider turn. These +/// counters measure application-tool calls, not provider turns. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AiCapabilityBrokerAmplification { + /// Accepted discovery calls. + pub discover_calls: u32, + /// Accepted describe calls. + pub describe_calls: u32, + /// Accepted execute calls. + pub execute_calls: u32, +} + +impl AiCapabilityBrokerAmplification { + /// Total accepted broker calls. + pub const fn total_calls(self) -> u32 { + self.discover_calls + .saturating_add(self.describe_calls) + .saturating_add(self.execute_calls) } +} - struct Resolver(AuthPrincipal); +#[derive(Debug, Default)] +struct BrokerSessionState { + searches: VecDeque, + loaded: VecDeque<(String, AiLoadedCapabilityBinding)>, + deferred_installation_pending: bool, + amplification: AiCapabilityBrokerAmplification, +} - #[async_trait] - impl CurrentPrincipalResolver for Resolver { - async fn resolve( - &self, - reference: &PrincipalReference, - ) -> agql_auth::AuthResult { - ResolvedPrincipal::new( - reference.clone(), - self.0.clone(), - OffsetDateTime::UNIX_EPOCH, - ) - } +/// Bounded process-local broker state for one fenced run. +/// +/// The state retains only crate-owned discovery results and short-lived loaded +/// bindings so a later describe or execute can be matched to an exact earlier +/// 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. +#[derive(Clone, Debug)] +pub struct AiCapabilityBrokerSession { + inner: Arc>, + limits: AiCapabilityDeliveryLimits, +} + +impl AiCapabilityBrokerSession { + /// Creates bounded broker state for one fenced run. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] for invalid delivery limits. + pub fn new(limits: AiCapabilityDeliveryLimits) -> Result { + limits.validate()?; + Ok(Self { + inner: Arc::new(Mutex::new(BrokerSessionState::default())), + limits, + }) } - struct CurrentIndex(RwLock>); + /// Exact bounds applied to retained searches, loaded bindings, and one + /// describe planning contract. + pub const fn limits(&self) -> AiCapabilityDeliveryLimits { + self.limits + } - impl AiCurrentCapabilityIndex for CurrentIndex { - fn current_index( - &self, - _run: &AiCapabilityRunBinding, - ) -> Result, AiError> { - self.0 - .read() - .map(|index| Arc::clone(&index)) - .map_err(|_| AiError::PersistenceFailed) - } + /// Current accepted broker call counts for host-side run sizing. + pub fn amplification(&self) -> AiCapabilityBrokerAmplification { + self.state().amplification } - struct Authority { - allowed: AtomicBool, - policy_fingerprint: RwLock, + /// Loaded bindings still retained for this run. + /// + /// The count is bounded by + /// [`AiCapabilityDeliveryLimits::maximum_loaded_bindings`]. + pub fn loaded_binding_count(&self) -> usize { + self.state().loaded.len() } - #[async_trait] - impl AiCapabilityAuthorityPolicy for Authority { - async fn authorize( - &self, - _principal: &ResolvedPrincipal, - _entry: &AiCapabilityIndexEntry, - _run: &AiCapabilityRunBinding, - ) -> Result { - Ok(AiCapabilityAuthorityDecision { - allowed: self.allowed.load(Ordering::SeqCst), - policy_fingerprint: self - .policy_fingerprint - .read() - .map_err(|_| AiError::PersistenceFailed)? - .clone(), - }) - } + /// Exact currently loaded bindings in load order. + /// + /// A client-deferred continuation installs exactly these capabilities + /// through [`prepare_client_deferred_continuation`]. + pub fn loaded_bindings(&self) -> Vec { + self.state() + .loaded + .iter() + .map(|(_, binding)| binding.clone()) + .collect() } - fn run_binding() -> AiCapabilityRunBinding { - AiCapabilityRunBinding { - session_id: AiSessionId::new(), - run_id: AiRunId::new(), - attempt_id: Uuid::new_v4(), - lease_generation: 1, - provider_kind: ProviderKind::OpenAi, - provider_session_fingerprint: "provider-session-v1".to_owned(), + fn state(&self) -> std::sync::MutexGuard<'_, BrokerSessionState> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn record_search(&self, result: AiCapabilityIndexSetSearchResult) { + let mut state = self.state(); + state.amplification.discover_calls = state.amplification.discover_calls.saturating_add(1); + state.searches.push_back(result); + while state.searches.len() > usize::from(self.limits.maximum_retained_searches) { + state.searches.pop_front(); } } - #[test] - fn frozen_retained_and_large_native_surfaces_select_distinct_modes() { - let mut capabilities = ProviderCapabilities { - custom_tools: true, - ..ProviderCapabilities::default() + fn candidate_search( + &self, + capability_id: &AiToolId, + candidate_fingerprint: &str, + ) -> Option { + self.state() + .searches + .iter() + .rev() + .find(|search| { + search.candidates.iter().any(|candidate| { + &candidate.id == capability_id + && candidate.entry_fingerprint == candidate_fingerprint + }) + }) + .cloned() + } + + fn record_loaded(&self, reference: String, binding: AiLoadedCapabilityBinding) { + let mut state = self.state(); + state.amplification.describe_calls = state.amplification.describe_calls.saturating_add(1); + state.loaded.retain(|(existing, _)| existing != &reference); + state.loaded.push_back((reference, binding)); + while state.loaded.len() > usize::from(self.limits.maximum_loaded_bindings) { + state.loaded.pop_front(); + } + } + + #[cfg_attr(feature = "mssql", allow(dead_code))] + fn record_deferred_search( + &self, + result: AiCapabilityIndexSetSearchResult, + loaded: Vec<(String, AiLoadedCapabilityBinding)>, + ) { + let mut state = self.state(); + state.amplification.discover_calls = state.amplification.discover_calls.saturating_add(1); + state.searches.push_back(result); + while state.searches.len() > usize::from(self.limits.maximum_retained_searches) { + state.searches.pop_front(); + } + state.loaded = loaded.into(); + state.deferred_installation_pending = true; + } + + fn deferred_installation_pending(&self) -> bool { + self.state().deferred_installation_pending + } + + #[cfg_attr(feature = "mssql", allow(dead_code))] + fn complete_deferred_installation(&self) { + self.state().deferred_installation_pending = false; + } + + fn loaded(&self, reference: &str) -> Option { + self.state() + .loaded + .iter() + .find(|(existing, _)| existing == reference) + .map(|(_, binding)| binding.clone()) + } + + fn record_execute(&self) { + let mut state = self.state(); + state.amplification.execute_calls = state.amplification.execute_calls.saturating_add(1); + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerDiscoverArguments { + text: String, + #[serde(default)] + namespace: Option, + #[serde(default)] + kind: Option, + #[serde(default)] + entity_or_class: Option, + maximum_results: u16, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerDescribeArguments { + capability_id: String, + candidate_fingerprint: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerExecuteArgument { + /// Dot-separated GraphQL input path. Decimal path components address + /// bounded list positions, for example `filter.and.0.status.eq`. + name: String, + value: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerRelationshipArguments { + path: String, + #[serde(default)] + arguments: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerRelationshipMaximumItems { + path: String, + maximum_items: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrokerExecuteArguments { + loaded_reference: String, + #[serde(default)] + arguments: Vec, + selections: Vec, + #[serde(default)] + relationship_arguments: Vec, + #[serde(default)] + relationship_maximum_items: Vec, + #[serde(default)] + maximum_items: Option, +} + +fn valid_graphql_name(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_alphabetic()) + && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) +} + +fn valid_broker_selection_path(value: &str) -> bool { + !value.is_empty() + && value.len() <= 512 + && !value.chars().any(char::is_control) + && value.split('.').count() <= 12 + && value.split('.').all(valid_graphql_name) +} + +fn insert_broker_argument( + root: &mut serde_json::Value, + path: &str, + value: serde_json::Value, + seen: &mut BTreeSet, +) -> Result<(), AiError> { + if path.is_empty() || path.len() > 256 || !seen.insert(path.to_owned()) { + return Err(malformed_broker_arguments()); + } + if !matches!( + value, + serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) + ) { + return Err(malformed_broker_arguments()); + } + let segments = path.split('.').collect::>(); + if segments.is_empty() + || segments.len() > 12 + || !valid_graphql_name(segments[0]) + || segments.iter().any(|segment| { + segment.is_empty() + || (!valid_graphql_name(segment) + && segment.parse::().map_or(true, |index| index >= 64)) + }) + { + return Err(malformed_broker_arguments()); + } + insert_broker_argument_segments(root, &segments, value) +} + +fn insert_broker_argument_segments( + current: &mut serde_json::Value, + segments: &[&str], + value: serde_json::Value, +) -> Result<(), AiError> { + let Some((segment, remaining)) = segments.split_first() else { + return Err(malformed_broker_arguments()); + }; + let final_segment = remaining.is_empty(); + match current { + serde_json::Value::Object(object) => { + if !valid_graphql_name(segment) { + return Err(malformed_broker_arguments()); + } + if final_segment { + if object.insert((*segment).to_owned(), value).is_some() { + return Err(malformed_broker_arguments()); + } + return Ok(()); + } + let next_is_index = remaining[0].parse::().is_ok(); + let child = object.entry((*segment).to_owned()).or_insert_with(|| { + if next_is_index { + serde_json::Value::Array(Vec::new()) + } else { + serde_json::Value::Object(serde_json::Map::new()) + } + }); + insert_broker_argument_segments(child, remaining, value) + } + serde_json::Value::Array(array) => { + let index = segment + .parse::() + .ok() + .filter(|index| *index < 64) + .ok_or_else(malformed_broker_arguments)?; + if array.len() <= index { + array.resize(index + 1, serde_json::Value::Null); + } + if final_segment { + if !array[index].is_null() { + return Err(malformed_broker_arguments()); + } + array[index] = value; + return Ok(()); + } + if array[index].is_null() { + array[index] = if remaining[0].parse::().is_ok() { + serde_json::Value::Array(Vec::new()) + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + } + insert_broker_argument_segments(&mut array[index], remaining, value) + } + _ => Err(malformed_broker_arguments()), + } +} + +fn stale_selection() -> AiError { + AiError::InvalidInput("broker capability selection is stale".to_owned()) +} + +fn malformed_broker_arguments() -> AiError { + AiError::InvalidInput("broker arguments are invalid".to_owned()) +} + +/// Bounded authority-neutral planning contract for one loaded capability. +/// +/// Holding a description proves that the exact current capability was loaded +/// under the current principal and current host policy at describe time. It is +/// not execution authority: [`AiCapabilityDiscoveryBroker::authorize_execution`] +/// runs again immediately before any resolver is reached. +#[derive(Clone, Debug)] +pub struct AiCapabilityDescription { + loaded_reference: String, + capability_id: AiToolId, + capability_kind: AiCapabilityKind, + capability_fingerprint: String, + contract: serde_json::Value, +} + +impl AiCapabilityDescription { + /// Opaque non-authoritative reference the model presents to execute. + pub fn loaded_reference(&self) -> &str { + &self.loaded_reference + } + + /// Stable capability identifier that was loaded. + pub const fn capability_id(&self) -> &AiToolId { + &self.capability_id + } + + /// Capability family that was loaded. + pub const fn capability_kind(&self) -> AiCapabilityKind { + self.capability_kind + } + + /// Exact loaded capability fingerprint. + pub fn capability_fingerprint(&self) -> &str { + &self.capability_fingerprint + } + + /// Bounded model-visible planning contract. + pub const fn contract(&self) -> &serde_json::Value { + &self.contract + } + + /// Attaches the exact compact plan schema of the loaded capability. + /// + /// The schema is the same closed provider-facing plan contract eager + /// delivery would have sent, now delivered once for one capability instead + /// of for the whole catalogue. An oversized schema is omitted rather than + /// truncated, and the contract records that fact. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] when the contract can no + /// longer be canonically serialized. + pub fn with_plan_schema( + mut self, + plan_schema: serde_json::Value, + maximum_bytes: u32, + ) -> Result { + { + let Some(contract) = self.contract.as_object_mut() else { + return Err(AiError::InvalidConfiguration( + "capability planning contract is invalid".to_owned(), + )); + }; + contract.insert("planSchema".to_owned(), plan_schema); + contract.insert("planSchemaAvailable".to_owned(), json!(true)); + } + let fits = serde_json::to_vec(&self.contract) + .map(|encoded| encoded.len() <= maximum_bytes as usize) + .unwrap_or(false); + if fits { + return Ok(self); + } + let contract = self + .contract + .as_object_mut() + .expect("the planning contract was proven to be an object"); + contract.remove("planSchema"); + contract.insert("planSchemaAvailable".to_owned(), json!(false)); + if serde_json::to_vec(&self.contract) + .map_or(true, |encoded| encoded.len() > maximum_bytes as usize) + { + return Err(AiError::InvalidConfiguration( + "capability planning contract exceeds its deployment bound".to_owned(), + )); + } + Ok(self) + } + + /// Consumes the description and returns the one bounded model result. + pub fn into_model_result(self) -> serde_json::Value { + self.contract + } +} + +/// Authorized broker execution of one exact previously loaded capability. +/// +/// Construction proves that the loaded binding is unexpired, still bound to +/// this run/attempt/lease/provider session, still matches the current index, +/// and still passes current host policy. Ordinary resolver authorization is +/// still applied by [`crate::AiRuntime::execute_query_capability`]. +#[derive(Clone, Debug)] +pub struct AiCapabilityExecution { + capability_id: AiToolId, + capability_fingerprint: String, + audit_reference: String, + plan: serde_json::Value, +} + +impl AiCapabilityExecution { + /// Exact capability to execute. + pub const fn capability_id(&self) -> &AiToolId { + &self.capability_id + } + + /// Exact capability fingerprint proven at load and re-proven at execution. + pub fn capability_fingerprint(&self) -> &str { + &self.capability_fingerprint + } + + /// Opaque non-authoritative audit reference of the loaded binding. + pub fn audit_reference(&self) -> &str { + &self.audit_reference + } + + /// Closed compact plan handed to the authoritative capability compiler. + pub const fn plan(&self) -> &serde_json::Value { + &self.plan + } + + /// Consumes the execution and returns the closed compact plan. + pub fn into_plan(self) -> serde_json::Value { + self.plan + } +} + +fn candidate_value(candidate: &crate::AiCapabilitySearchCandidate) -> serde_json::Value { + json!({ + "capabilityId": candidate.id.as_str(), + "kind": candidate.kind, + "name": candidate.name, + "description": candidate.description, + "namespace": candidate.namespace, + "entity": candidate.entity_name, + "operation": candidate.operation_name, + "shape": candidate.operation_shape, + "candidateFingerprint": candidate.entry_fingerprint, + }) +} + +fn planning_contract( + entry: &AiCapabilityIndexEntry, + loaded_reference: &str, + expires_in_seconds: i64, +) -> serde_json::Value { + json!({ + "version": AI_CAPABILITY_BROKER_VERSION, + "loadedReference": loaded_reference, + "expiresInSeconds": expires_in_seconds, + "capabilityId": entry.id.as_str(), + "kind": entry.kind, + "name": entry.name, + "description": entry.description, + "namespace": entry.namespace, + "entity": entry.entity_name, + "operation": entry.operation_name, + "shape": entry.operation_shape, + "resultClassification": entry.result_classification, + "resultDescription": entry.result_description, + "risk": entry.risk, + "approval": entry.approval, + "scalarFields": entry.scalar_fields, + "relationships": entry.relationships, + "aggregates": entry.aggregate_features, + "candidateFingerprint": entry.fingerprint, + }) +} + +impl AiCapabilityDiscoveryBroker { + /// Dispatches one frozen `graphql.capabilities.discover` call. + /// + /// The result is bounded authority-neutral generated-query metadata: it contains no + /// argument schema, GraphQL document, target, resolver, or credential, and + /// it grants no load or execution authority. Static tools remain exact + /// bootstrap definitions, while mutations and subscriptions use their + /// separate approval/wait contracts and cannot enter this broker. Every + /// returned candidate has already passed current host policy for the + /// current principal. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidInput`] for malformed broker arguments and + /// fails closed on rehydration, index, or policy failure. + pub async fn dispatch_discover( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + session: &AiCapabilityBrokerSession, + arguments: &serde_json::Value, + ) -> Result { + let parsed: BrokerDiscoverArguments = + serde_json::from_value(arguments.clone()).map_err(|_| malformed_broker_arguments())?; + if parsed + .kind + .is_some_and(|kind| kind != AiCapabilityKind::GeneratedQuery) + { + return Err(malformed_broker_arguments()); + } + let query = AiCapabilitySearchQuery { + text: parsed.text, + namespace: parsed.namespace, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: parsed.entity_or_class, + maximum_results: parsed.maximum_results, + }; + let result = self.search(principal_reference, run, &query).await?; + let value = json!({ + "version": AI_CAPABILITY_BROKER_VERSION, + "indexFingerprint": result.index_set_fingerprint, + "candidates": result + .candidates + .iter() + .map(candidate_value) + .collect::>(), + }); + session.record_search(result); + Ok(value) + } + + /// Dispatches client-deferred discovery and atomically loads exactly the + /// generated-query candidates returned for the next continuation. + /// + /// Unlike the fixed broker, client-deferred delivery does not expose a + /// separate describe operation. The returned candidate set is therefore + /// constrained to generated reads, capped by the deferred-definition + /// limit, freshly loaded under current authority, and installed verbatim + /// by the coordinator after the durable discovery outcome commits. + #[cfg_attr(feature = "mssql", allow(dead_code))] + pub(crate) async fn dispatch_client_deferred_discover( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + session: &AiCapabilityBrokerSession, + arguments: &serde_json::Value, + ) -> Result { + let parsed: BrokerDiscoverArguments = + serde_json::from_value(arguments.clone()).map_err(|_| malformed_broker_arguments())?; + if parsed + .kind + .is_some_and(|kind| kind != AiCapabilityKind::GeneratedQuery) + || parsed.maximum_results > session.limits().maximum_deferred_definitions + { + return Err(malformed_broker_arguments()); + } + let query = AiCapabilitySearchQuery { + text: parsed.text, + namespace: parsed.namespace, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: parsed.entity_or_class, + maximum_results: parsed.maximum_results, + }; + let result = self.search(principal_reference, run, &query).await?; + if result.candidates.len() > usize::from(session.limits().maximum_deferred_definitions) { + return Err(AiError::InvalidConfiguration( + "deferred discovery exceeded its exact definition bound".to_owned(), + )); + } + let mut loaded = Vec::with_capacity(result.candidates.len()); + for candidate in &result.candidates { + let binding = self + .load(principal_reference, run, &result, &candidate.id) + .await?; + loaded.push((binding.audit_reference(), binding)); + } + let value = json!({ + "version": AI_CAPABILITY_BROKER_VERSION, + "indexFingerprint": result.index_set_fingerprint, + "candidates": result + .candidates + .iter() + .map(candidate_value) + .collect::>(), + }); + session.record_deferred_search(result, loaded); + Ok(value) + } + + /// 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 + /// 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. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidInput`] for malformed arguments or a stale + /// selection, and [`AiError::Forbidden`] when current host policy no + /// longer permits the exact candidate. + pub async fn dispatch_describe( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + session: &AiCapabilityBrokerSession, + arguments: &serde_json::Value, + ) -> Result { + let parsed: BrokerDescribeArguments = + serde_json::from_value(arguments.clone()).map_err(|_| malformed_broker_arguments())?; + if !crate::valid_sha256(&parsed.candidate_fingerprint) { + return Err(malformed_broker_arguments()); + } + let capability_id = AiToolId::parse(parsed.capability_id).map_err(|_| stale_selection())?; + let search = session + .candidate_search(&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())?; + let loaded = self + .load(principal_reference, run, &search, &capability_id) + .await?; + let (_, entry) = indexes.entry(&capability_id).ok_or_else(stale_selection)?; + let loaded_reference = loaded.audit_reference(); + let expires_in_seconds = (loaded.expires_at - self.clock.now()) + .whole_seconds() + .max(0); + let contract = planning_contract(entry, &loaded_reference, expires_in_seconds); + let description = AiCapabilityDescription { + loaded_reference: loaded_reference.clone(), + capability_id: loaded.capability_id.clone(), + capability_kind: loaded.capability_kind, + capability_fingerprint: loaded.capability_fingerprint.clone(), + contract, + }; + session.record_loaded(loaded_reference, loaded); + Ok(description) + } + + /// Dispatches one frozen `graphql.capabilities.execute` call up to, but + /// not including, the resolver. + /// + /// Only a generated read capability is executable through the frozen + /// broker; reviewed static tools, mutations, and subscriptions retain + /// their own exact delivery and approval contracts and fail closed here. + /// The returned closed plan contains public names and finite typed values + /// only and is compiled by the authoritative schema-derived compiler. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidInput`] for malformed arguments, an unknown + /// loaded reference, or an expired binding, and [`AiError::Forbidden`] for + /// a non-read capability or any revocation, drift, or substitution. + pub async fn authorize_broker_execution( + &self, + principal_reference: &PrincipalReference, + run: &AiCapabilityRunBinding, + session: &AiCapabilityBrokerSession, + arguments: &serde_json::Value, + ) -> Result { + let parsed: BrokerExecuteArguments = + serde_json::from_value(arguments.clone()).map_err(|_| malformed_broker_arguments())?; + if !crate::valid_sha256(&parsed.loaded_reference) { + return Err(malformed_broker_arguments()); + } + let loaded = session + .loaded(&parsed.loaded_reference) + .ok_or_else(stale_selection)?; + if self.clock.now() > loaded.expires_at { + return Err(stale_selection()); + } + if loaded.capability_kind != AiCapabilityKind::GeneratedQuery { + return Err(AiError::Forbidden); + } + self.authorize_execution(principal_reference, run, &loaded) + .await?; + if parsed.arguments.len() > 64 + || parsed.selections.len() > 256 + || parsed + .selections + .iter() + .any(|selection| !valid_broker_selection_path(selection)) + || parsed.relationship_arguments.len() > 64 + || parsed.relationship_maximum_items.len() > 64 + || parsed + .maximum_items + .is_some_and(|maximum| !(1..=10_000).contains(&maximum)) + { + return Err(malformed_broker_arguments()); + } + let mut plan_arguments = serde_json::Value::Object(serde_json::Map::new()); + let mut seen_arguments = BTreeSet::new(); + for argument in parsed.arguments { + insert_broker_argument( + &mut plan_arguments, + &argument.name, + argument.value, + &mut seen_arguments, + )?; + } + let mut selections = parsed.selections; + selections.sort_unstable(); + if selections.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(malformed_broker_arguments()); + } + let mut relationship_arguments = serde_json::Map::new(); + for relationship in parsed.relationship_arguments { + if !valid_broker_selection_path(&relationship.path) + || relationship.arguments.len() > 64 + || relationship_arguments.contains_key(&relationship.path) + { + return Err(malformed_broker_arguments()); + } + let mut arguments = serde_json::Value::Object(serde_json::Map::new()); + let mut seen = BTreeSet::new(); + for argument in relationship.arguments { + insert_broker_argument(&mut arguments, &argument.name, argument.value, &mut seen)?; + } + relationship_arguments.insert(relationship.path, arguments); + } + let mut relationship_maximum_items = serde_json::Map::new(); + for relationship in parsed.relationship_maximum_items { + if !valid_broker_selection_path(&relationship.path) + || !(1..=10_000).contains(&relationship.maximum_items) + || relationship_maximum_items + .insert(relationship.path, json!(relationship.maximum_items)) + .is_some() + { + return Err(malformed_broker_arguments()); + } + } + let mut plan = serde_json::Map::from_iter([ + ("arguments".to_owned(), plan_arguments), + ("selections".to_owned(), json!(selections)), + ( + "relationshipArguments".to_owned(), + serde_json::Value::Object(relationship_arguments), + ), + ( + "relationshipMaximumItems".to_owned(), + serde_json::Value::Object(relationship_maximum_items), + ), + ]); + if let Some(maximum_items) = parsed.maximum_items { + plan.insert("maximumItems".to_owned(), json!(maximum_items)); + } + session.record_execute(); + Ok(AiCapabilityExecution { + capability_id: loaded.capability_id.clone(), + capability_fingerprint: loaded.capability_fingerprint.clone(), + audit_reference: loaded.audit_reference(), + plan: serde_json::Value::Object(plan), + }) + } +} + +/// Crate-owned capability delivery for one fenced coordinator run. +/// +/// The turn owns the provider surface: it selects the delivery mode from +/// provider declarations and exact definition size, mints the exact initial +/// definitions, and installs freshly loaded definitions on a client-deferred +/// continuation. A host installs the returned definitions verbatim, so prompt +/// text, model output, and host tool authoring cannot widen the surface. +/// +/// The turn is not authority. Every broker call still rehydrates the current +/// principal, reapplies current host policy, and — for execution only — passes +/// through ordinary resolver authorization. +#[derive(Clone)] +pub struct AiCapabilityDeliveryTurn { + mode: AiCapabilityDeliveryMode, + index_fingerprint: String, + session_binding: AiProviderCapabilitySessionBinding, + broker: Arc, + session: AiCapabilityBrokerSession, + #[cfg_attr(feature = "mssql", allow(dead_code))] + static_bootstrap_tools: Arc>, + tools: Arc>>, +} + +impl std::fmt::Debug for AiCapabilityDeliveryTurn { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AiCapabilityDeliveryTurn") + .field("mode", &self.mode) + .field("index_fingerprint", &self.index_fingerprint) + .field("binding", &self.session_binding.fingerprint()) + .finish_non_exhaustive() + } +} + +impl AiCapabilityDeliveryTurn { + /// Selects the delivery mode and mints the exact initial surface. + /// + /// # Errors + /// + /// Returns a safe error when no provider-declared mode can represent the + /// surface, a definition is malformed, or the retained capability-session + /// binding does not describe the selected mode and compact index. + pub fn select( + provider: &ProviderCapabilities, + index_fingerprint: &str, + filtered_exact_definitions: Vec, + retained_definitions_frozen: bool, + session_binding: AiProviderCapabilitySessionBinding, + broker: Arc, + session: AiCapabilityBrokerSession, + ) -> Result { + let static_bootstrap_tools = filtered_exact_definitions + .iter() + .filter(|definition| { + session_binding + .static_bootstrap_tool_fingerprints() + .contains(&definition.fingerprint) + }) + .cloned() + .collect::>(); + if static_bootstrap_tools.len() + != session_binding.static_bootstrap_tool_fingerprints().len() + { + return Err(AiError::InvalidConfiguration( + "capability static bootstrap binding is incomplete".to_owned(), + )); + } + let surface = plan_capability_delivery( + provider, + index_fingerprint, + filtered_exact_definitions, + retained_definitions_frozen, + session.limits(), + )?; + if session_binding.delivery_mode() != surface.mode() + || session_binding.capability_index_fingerprint() != index_fingerprint + { + return Err(AiError::InvalidConfiguration( + "capability session binding does not match the selected surface".to_owned(), + )); + } + let mut tools = surface.tools().to_vec(); + if matches!( + surface.mode(), + AiCapabilityDeliveryMode::ClientDeferred | AiCapabilityDeliveryMode::FixedBroker + ) { + tools.extend(static_bootstrap_tools.iter().cloned()); + tools.sort_by(|left, right| left.tool_id.cmp(&right.tool_id)); + } + Ok(Self { + mode: surface.mode(), + index_fingerprint: index_fingerprint.to_owned(), + session_binding, + broker, + session, + static_bootstrap_tools: Arc::new(static_bootstrap_tools), + tools: Arc::new(Mutex::new(tools)), + }) + } + + /// Coordinator-selected delivery mode for this run. + pub const fn mode(&self) -> AiCapabilityDeliveryMode { + self.mode + } + + /// Exact canonical capability-index-set fingerprint bound into this surface. + pub fn index_fingerprint(&self) -> &str { + &self.index_fingerprint + } + + /// Exact canonical capability-index-set fingerprint bound into this turn. + pub fn index_set_fingerprint(&self) -> &str { + &self.index_fingerprint + } + + /// Immutable retained provider capability-session binding. + pub const fn session_binding(&self) -> &AiProviderCapabilitySessionBinding { + &self.session_binding + } + + /// Broker used for every discover, describe, and execute dispatch. + pub fn broker(&self) -> &Arc { + &self.broker + } + + /// Bounded process-local broker state for this run. + pub const fn session(&self) -> &AiCapabilityBrokerSession { + &self.session + } + + /// Exact definitions a host must install in the next provider request. + pub fn current_tools(&self) -> Vec { + self.tools + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Returns the exact current provider surface for initial or continuation + /// plan construction. + /// + /// Client-deferred discovery can replace the installed exact definitions + /// between turns. A planner must obtain this value from the same delivery + /// turn passed by the coordinator rather than retaining an earlier surface + /// or reconstructing one from public fields. + pub fn current_surface(&self) -> AiCapabilityDeliverySurface { + AiCapabilityDeliverySurface { + mode: self.mode, + tools: self.current_tools(), + } + } + + /// Whether an offered definition set is exactly the crate-owned surface. + pub fn matches_offered_tools(&self, offered: &[ModelToolDefinition]) -> bool { + let current = self.current_tools(); + current.len() == offered.len() + && current + .iter() + .zip(offered.iter()) + .all(|(expected, actual)| expected == actual) + } + + /// Whether another turn carries the exact same in-process run state. + /// + /// Matching fingerprints alone are insufficient on a continuation: a host + /// must carry forward the crate-owned broker session and installed surface + /// rather than recreate empty state under the same public configuration. + #[cfg_attr(feature = "mssql", allow(dead_code))] + pub(crate) fn shares_run_state(&self, other: &Self) -> bool { + self.mode == other.mode + && self.index_fingerprint == other.index_fingerprint + && self.session_binding == other.session_binding + && Arc::ptr_eq(&self.broker, &other.broker) + && Arc::ptr_eq(&self.session.inner, &other.session.inner) + && Arc::ptr_eq(&self.static_bootstrap_tools, &other.static_bootstrap_tools) + && Arc::ptr_eq(&self.tools, &other.tools) + } + + /// Whether the run currently needs freshly loaded exact definitions + /// installed on the next continuation. + pub fn requires_deferred_installation(&self) -> bool { + self.mode == AiCapabilityDeliveryMode::ClientDeferred + && self.session.deferred_installation_pending() + } + + /// Installs the exact freshly loaded definitions for the next + /// client-deferred continuation and returns the complete new surface. + /// + /// Discovery remains offered so a run can keep loading capabilities. Only + /// definitions matching a crate-owned loaded binding are installed; + /// unrelated catalogue definitions cannot be smuggled in. + /// + /// # Errors + /// + /// Returns a safe error unless the run is client-deferred and every + /// supplied definition matches exactly one current loaded binding within + /// the configured selection count. + #[cfg_attr(feature = "mssql", allow(dead_code))] + pub(crate) fn install_deferred_definitions( + &self, + exact_definitions: Vec, + ) -> Result, AiError> { + if self.mode != AiCapabilityDeliveryMode::ClientDeferred { + return Err(AiError::Forbidden); + } + let loaded = self.session.loaded_bindings(); + let installed = prepare_client_deferred_continuation( + &loaded, + exact_definitions, + self.session.limits(), + )?; + let mut tools = vec![discovery_definition(&self.index_fingerprint)]; + tools.extend(self.static_bootstrap_tools.iter().cloned()); + tools.extend(installed); + tools.sort_by(|left, right| left.tool_id.cmp(&right.tool_id)); + let mut current = self.tools.lock().unwrap_or_else(PoisonError::into_inner); + current.clone_from(&tools); + self.session.complete_deferred_installation(); + Ok(tools) + } + + /// Observed broker turn/call amplification for host-side run sizing. + pub fn amplification(&self) -> AiCapabilityBrokerAmplification { + self.session.amplification() + } +} + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +impl AiCapabilityRunBinding { + /// Builds one exact broker run fence from a renewed run lease. + /// + /// The provider capability-session binding supplies the fingerprint, so a + /// delivery-mode, index, bootstrap-tool, projection, model, or reasoning + /// change invalidates every loaded binding taken under the previous + /// binding rather than silently reusing it. + pub fn from_lease( + lease: &crate::AiRunLease, + provider_kind: ProviderKind, + session_binding: &AiProviderCapabilitySessionBinding, + ) -> Self { + Self { + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + lease_generation: lease.lease_generation(), + provider_kind, + provider_session_fingerprint: session_binding.fingerprint().to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + RwLock, + atomic::{AtomicBool, Ordering}, + }; + + use agql_auth::{AccessTokenMetadata, AuthPrincipal, AuthUser, FixedClock, SessionContext}; + use graphql_orm::graphql::orm::{ + GeneratedGraphqlOperationDescriptor, GraphqlEntitySemanticMetadata, + GraphqlOperationCatalog, GraphqlOperationKind, GraphqlSemanticArgumentDescriptor, + GraphqlSemanticCatalog, GraphqlSemanticClassification, GraphqlSemanticExport, + GraphqlSemanticFieldMetadata, GraphqlSemanticOperationDescriptor, GraphqlSemanticTypeKind, + GraphqlSemanticTypeRef, + }; + + use super::*; + + fn definition(id: &str) -> ModelToolDefinition { + ModelToolDefinition { + tool_id: id.to_owned(), + provider_name: id.replace('.', "_"), + fingerprint: hash_json(&json!({"id": id})), + description: "Read one reviewed record.".to_owned(), + parameters: json!({ + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + "additionalProperties": false + }), + strict: true, + defer_loading: false, + } + } + + fn principal() -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: "capability-user".to_owned(), + session_id: Uuid::from_u128(10), + roles: Vec::new(), + scopes: vec!["jim.read".to_owned()], + session: SessionContext::default(), + token_claims: AccessTokenMetadata { + tenant_id: Some("tenant-1".to_owned()), + ..AccessTokenMetadata::default() + }, + }) + } + + fn index(target_policy: &str) -> Arc { + let operation_catalogue = GraphqlOperationCatalog::compose(Vec::<( + &'static [GeneratedGraphqlOperationDescriptor], + bool, + bool, + )>::new()); + let semantics = GraphqlSemanticCatalog::compose(Vec::new(), &operation_catalogue) + .expect("semantic catalogue"); + let descriptor = crate::AiToolDescriptor::new( + "jim.jobs_list", + "List the latest Jim jobs.", + crate::AiToolOperationKind::Query, + "query ReviewedJobs { jobs }", + json!({ + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }), + ) + .expect("descriptor") + .with_maximum_classification(crate::DataClassification::Public); + Arc::new( + AiCapabilityIndex::compile( + crate::GraphqlExecutionTargetId::parse("jim-production").expect("target"), + "schema-v1", + &semantics, + None, + None, + None, + [descriptor], + target_policy, + crate::AiCapabilityIndexLimits::default(), + ) + .expect("capability index"), + ) + } + + fn generated_index(target_policy: &str) -> Arc { + generated_index_for( + "generated-read-application", + "generated-read", + target_policy, + ) + } + + fn generated_index_for( + target_id: &str, + profile_id: &str, + target_policy: &str, + ) -> Arc { + const SDL: &str = r#" + schema { query: Query } + type Query { GeneratedRecord(recordId: ID!): Record! } + type Record { recordId: ID!, subject: String! } + "#; + let scalar = |field_name: &str, scalar_name: &str| GraphqlSemanticFieldMetadata { + field_name: field_name.to_owned(), + description: format!("Reviewed public {field_name}."), + type_ref: GraphqlSemanticTypeRef::named( + scalar_name, + GraphqlSemanticTypeKind::Scalar, + false, + ), + selectable: true, + filter_operators: Vec::new(), + sortable: false, + groupable: false, + aggregate_operators: Vec::new(), + aggregate_value_kind: None, + relationship: None, + classification: GraphqlSemanticClassification::Internal, + export: GraphqlSemanticExport::Exportable, + has_field_policy: false, + }; + let entity = GraphqlEntitySemanticMetadata { + entity_name: "Record".to_owned(), + description: "A reviewed application record.".to_owned(), + default_classification: GraphqlSemanticClassification::Internal, + fields: vec![scalar("recordId", "ID"), scalar("subject", "String")].into_boxed_slice(), + }; + let operation = GraphqlSemanticOperationDescriptor::custom( + GraphqlOperationKind::Query, + "GeneratedRecord", + "Read one reviewed application record.", + vec![GraphqlSemanticArgumentDescriptor { + graphql_name: "recordId".to_owned(), + description: "Reviewed record identifier.".to_owned(), + type_ref: GraphqlSemanticTypeRef::named( + "ID", + GraphqlSemanticTypeKind::Scalar, + false, + ), + }], + GraphqlSemanticTypeRef::named("Record", GraphqlSemanticTypeKind::Object, false), + true, + ) + .expect("test query semantics should validate"); + let semantics = GraphqlSemanticCatalog::compose_with_custom( + [entity], + &GraphqlOperationCatalog::compose(std::iter::empty()), + [operation], + ) + .expect("test query catalogue should validate"); + let target = crate::GraphqlExecutionTargetId::parse(target_id) + .expect("generated target should validate"); + let catalogue = crate::AiGraphqlQueryCapabilityCatalog::compile( + profile_id, + target.clone(), + SDL, + &semantics, + crate::AiGraphqlQueryCapabilityLimits::default(), + ) + .expect("generated query capabilities should compile"); + Arc::new( + AiCapabilityIndex::compile( + target, + catalogue.finished_schema_fingerprint(), + &semantics, + Some(&catalogue), + None, + None, + [], + target_policy, + crate::AiCapabilityIndexLimits::default(), + ) + .expect("generated capability index"), + ) + } + + struct Resolver(AuthPrincipal); + + #[async_trait] + impl CurrentPrincipalResolver for Resolver { + async fn resolve( + &self, + reference: &PrincipalReference, + ) -> agql_auth::AuthResult { + ResolvedPrincipal::new( + reference.clone(), + self.0.clone(), + OffsetDateTime::UNIX_EPOCH, + ) + } + } + + struct CurrentIndex(RwLock>); + + impl AiCurrentCapabilityIndex for CurrentIndex { + fn current_index( + &self, + _run: &AiCapabilityRunBinding, + ) -> Result, AiError> { + self.0 + .read() + .map(|index| Arc::clone(&index)) + .map_err(|_| AiError::PersistenceFailed) + } + } + + struct CurrentIndexes(RwLock>); + + impl AiCurrentCapabilityIndexSet for CurrentIndexes { + fn current_index_set( + &self, + _run: &AiCapabilityRunBinding, + ) -> Result, AiError> { + self.0 + .read() + .map(|indexes| Arc::clone(&indexes)) + .map_err(|_| AiError::PersistenceFailed) + } + } + + struct Authority { + allowed: AtomicBool, + policy_fingerprint: RwLock, + } + + #[async_trait] + impl AiCapabilityAuthorityPolicy for Authority { + async fn authorize( + &self, + _principal: &ResolvedPrincipal, + _owning_index: &AiCapabilityIndex, + _entry: &AiCapabilityIndexEntry, + _run: &AiCapabilityRunBinding, + ) -> Result { + Ok(AiCapabilityAuthorityDecision { + allowed: self.allowed.load(Ordering::SeqCst), + policy_fingerprint: self + .policy_fingerprint + .read() + .map_err(|_| AiError::PersistenceFailed)? + .clone(), + }) + } + } + + fn run_binding() -> AiCapabilityRunBinding { + AiCapabilityRunBinding { + session_id: AiSessionId::new(), + run_id: AiRunId::new(), + attempt_id: Uuid::new_v4(), + lease_generation: 1, + provider_kind: ProviderKind::OpenAi, + provider_session_fingerprint: "provider-session-v1".to_owned(), + } + } + + #[test] + fn frozen_retained_and_large_native_surfaces_select_distinct_modes() { + let mut capabilities = ProviderCapabilities { + custom_tools: true, + ..ProviderCapabilities::default() }; capabilities.capability_delivery_modes = BTreeSet::from([ AiCapabilityDeliveryMode::EagerExact, @@ -937,6 +2396,13 @@ mod tests { .mode(), AiCapabilityDeliveryMode::FixedBroker ); + + capabilities.capability_delivery_modes = + BTreeSet::from([AiCapabilityDeliveryMode::ClientDeferred]); + assert!(matches!( + select_capability_delivery_mode(&capabilities, 55, 4_917_706, true, limits), + Err(AiError::InvalidConfiguration(_)) + )); } #[test] @@ -1012,6 +2478,80 @@ mod tests { ); } + #[test] + fn run_delivery_keeps_exact_static_bootstrap_while_generated_reads_defer() { + let principal = principal(); + let index = index("target-policy-v1"); + let index_set = + AiCapabilityIndexSet::compile([Arc::clone(&index)]).expect("capability index set"); + let broker = Arc::new( + AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + Arc::new(CurrentIndex(RwLock::new(index.clone()))), + Arc::new(Authority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }), + Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + Duration::seconds(30), + ) + .expect("broker"), + ); + let static_tool = definition("jim.jobs_list"); + let binding = AiProviderCapabilitySessionBinding::new( + AiCapabilityDeliveryMode::FixedBroker, + index_set.fingerprint(), + BTreeSet::from([static_tool.fingerprint.clone()]), + "provider-projection-v1", + "gpt-test", + ModelReasoningEffort::Unspecified, + "f".repeat(64), + ) + .expect("session binding"); + let capabilities = ProviderCapabilities { + custom_tools: true, + capability_delivery_modes: BTreeSet::from([AiCapabilityDeliveryMode::FixedBroker]), + ..ProviderCapabilities::default() + }; + let delivery = AiCapabilityDeliveryTurn::select( + &capabilities, + index_set.fingerprint(), + vec![static_tool.clone()], + true, + binding, + broker, + AiCapabilityBrokerSession::new(AiCapabilityDeliveryLimits::default()) + .expect("broker session"), + ) + .expect("fixed delivery"); + let surface = delivery.current_surface(); + assert_eq!(surface.mode(), AiCapabilityDeliveryMode::FixedBroker); + assert_eq!(surface.tools().len(), 4); + assert!(surface.tools().contains(&static_tool)); + assert!( + surface + .tools() + .iter() + .any(|definition| { definition.tool_id == AI_CAPABILITY_DISCOVER_TOOL_ID }) + ); + } + + #[test] + fn application_definition_cannot_claim_a_reserved_broker_id() { + let mut reserved = definition(AI_CAPABILITY_EXECUTE_TOOL_ID); + reserved.provider_name = "application_execute".to_owned(); + assert!(matches!( + prepare_capability_delivery_surface( + AiCapabilityDeliveryDecision { + mode: AiCapabilityDeliveryMode::EagerExact, + }, + &"a".repeat(64), + vec![reserved], + ), + Err(AiError::InvalidConfiguration(_)) + )); + } + #[test] fn retained_binding_changes_for_every_execution_relevant_dimension() { let make = |mode, effort, model: &str| { @@ -1069,11 +2609,12 @@ mod tests { allowed: AtomicBool::new(true), policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), }); + let clock = Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)); let broker = AiCapabilityDiscoveryBroker::new( Arc::new(Resolver(principal)), current_index.clone(), authority.clone(), - Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + clock.clone(), Duration::seconds(30), ) .expect("broker"); @@ -1102,6 +2643,21 @@ mod tests { .await .expect("current binding"); + let mut renewed_run = run.clone(); + renewed_run.lease_generation += 1; + broker + .authorize_execution(&principal_reference, &renewed_run, &loaded) + .await + .expect("a monotonic lease renewal must preserve the exact run binding"); + let mut stale_run = run.clone(); + stale_run.lease_generation -= 1; + assert!(matches!( + broker + .authorize_execution(&principal_reference, &stale_run, &loaded) + .await, + Err(AiError::Forbidden) + )); + let mut substituted_principal = principal_reference.clone(); substituted_principal.subject = "another-user".to_owned(); assert!(matches!( @@ -1147,6 +2703,16 @@ mod tests { Err(AiError::Forbidden) )); + *authority.policy_fingerprint.write().expect("policy write") = + "current-policy-v1".to_owned(); + clock.advance_seconds(31); + assert!(matches!( + broker + .authorize_execution(&principal_reference, &run, &loaded) + .await, + Err(AiError::Forbidden) + )); + let mut kind_substitution = search; kind_substitution.candidates[0].kind = AiCapabilityKind::GeneratedQuery; assert!(matches!( @@ -1156,4 +2722,398 @@ mod tests { Err(AiError::Forbidden) )); } + + #[tokio::test] + async fn broker_searches_and_reauthorizes_across_exact_owning_indexes() { + let first = generated_index_for("first-application", "first-read", "first-policy-v1"); + let second = generated_index_for("second-application", "second-read", "second-policy-v1"); + let first_id = first.entries().next().expect("first entry").id.clone(); + let second_id = second.entries().next().expect("second entry").id.clone(); + assert_ne!(first_id, second_id); + let initial_set = Arc::new( + AiCapabilityIndexSet::compile([Arc::clone(&first), Arc::clone(&second)]) + .expect("multi-target set"), + ); + let current_indexes = Arc::new(CurrentIndexes(RwLock::new(initial_set))); + let principal = principal(); + let principal_reference = principal.reference(); + let broker = AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + current_indexes.clone(), + Arc::new(Authority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }), + Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + Duration::seconds(30), + ) + .expect("broker"); + let run = run_binding(); + let search = broker + .search( + &principal_reference, + &run, + &AiCapabilitySearchQuery { + text: "reviewed record".to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 2, + }, + ) + .await + .expect("cross-target search"); + assert_eq!(search.candidates.len(), 2); + let loaded = broker + .load(&principal_reference, &run, &search, &second_id) + .await + .expect("second-target load"); + broker + .authorize_execution(&principal_reference, &run, &loaded) + .await + .expect("second-target execution authorization"); + + let mut substituted = search.clone(); + let first_fingerprint = substituted + .candidates + .iter() + .find(|candidate| candidate.id == first_id) + .expect("first candidate") + .entry_fingerprint + .clone(); + substituted + .candidates + .iter_mut() + .find(|candidate| candidate.id == second_id) + .expect("second candidate") + .entry_fingerprint = first_fingerprint; + assert!(matches!( + broker + .load(&principal_reference, &run, &substituted, &second_id) + .await, + Err(AiError::Forbidden) + )); + + let drifted_second = + generated_index_for("second-application", "second-read", "second-policy-v2"); + *current_indexes.0.write().expect("set write") = + Arc::new(AiCapabilityIndexSet::compile([first, drifted_second]).expect("drifted set")); + assert!(matches!( + broker + .authorize_execution(&principal_reference, &run, &loaded) + .await, + Err(AiError::Forbidden) + )); + } + + #[tokio::test] + async fn broker_applies_authority_to_each_exact_owning_target() { + struct TargetAuthority; + + #[async_trait] + impl AiCapabilityAuthorityPolicy for TargetAuthority { + async fn authorize( + &self, + _principal: &ResolvedPrincipal, + owning_index: &AiCapabilityIndex, + _entry: &AiCapabilityIndexEntry, + _run: &AiCapabilityRunBinding, + ) -> Result { + Ok(AiCapabilityAuthorityDecision { + allowed: owning_index.target_id().as_str() == "second-application", + policy_fingerprint: format!( + "{}-current-policy", + owning_index.target_id().as_str() + ), + }) + } + } + + let first = generated_index_for("first-application", "first-read", "first-policy-v1"); + let second = generated_index_for("second-application", "second-read", "second-policy-v1"); + let first_id = first.entries().next().expect("first entry").id.clone(); + let second_id = second.entries().next().expect("second entry").id.clone(); + let indexes = Arc::new(CurrentIndexes(RwLock::new(Arc::new( + AiCapabilityIndexSet::compile([first, second]).expect("multi-target set"), + )))); + let principal = principal(); + let principal_reference = principal.reference(); + let broker = AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + indexes, + Arc::new(TargetAuthority), + Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + Duration::seconds(30), + ) + .expect("broker"); + let run = run_binding(); + let search = broker + .search( + &principal_reference, + &run, + &AiCapabilitySearchQuery { + text: "reviewed record".to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 2, + }, + ) + .await + .expect("target-filtered search"); + assert_eq!(search.candidates.len(), 1); + assert_eq!(search.candidates[0].id, second_id); + assert_ne!(search.candidates[0].id, first_id); + let loaded = broker + .load( + &principal_reference, + &run, + &search, + &search.candidates[0].id, + ) + .await + .expect("authorized owning target should load"); + broker + .authorize_execution(&principal_reference, &run, &loaded) + .await + .expect("authorized owning target should reauthorize"); + } + + #[tokio::test] + async fn client_deferred_discovery_loads_only_exact_bounded_generated_queries() { + let principal = principal(); + let principal_reference = principal.reference(); + let current_index = Arc::new(CurrentIndex(RwLock::new(generated_index( + "target-policy-v1", + )))); + let authority = Arc::new(Authority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }); + let broker = Arc::new( + AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + current_index.clone(), + authority.clone(), + Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + Duration::seconds(30), + ) + .expect("broker"), + ); + let limits = AiCapabilityDeliveryLimits::default(); + let session = AiCapabilityBrokerSession::new(limits).expect("session"); + let index_fingerprint = + AiCapabilityIndexSet::compile([current_index.0.read().expect("index read").clone()]) + .expect("index set") + .fingerprint() + .to_owned(); + let binding = AiProviderCapabilitySessionBinding::new( + AiCapabilityDeliveryMode::ClientDeferred, + &index_fingerprint, + BTreeSet::new(), + "provider-projection-v1", + "gpt-test", + ModelReasoningEffort::Unspecified, + "f".repeat(64), + ) + .expect("session binding"); + let delivery = AiCapabilityDeliveryTurn::select( + &ProviderCapabilities { + custom_tools: true, + capability_delivery_modes: BTreeSet::from([ + AiCapabilityDeliveryMode::ClientDeferred, + ]), + ..ProviderCapabilities::default() + }, + &index_fingerprint, + Vec::new(), + false, + binding, + broker.clone(), + session.clone(), + ) + .expect("client-deferred delivery"); + let value = broker + .dispatch_client_deferred_discover( + &principal_reference, + &run_binding(), + &session, + &json!({ + "text": "reviewed record", + "kind": "generated_query", + "maximumResults": 1 + }), + ) + .await + .expect("bounded generated discovery"); + assert_eq!(value["candidates"].as_array().map(Vec::len), Some(1)); + assert_eq!(session.loaded_binding_count(), 1); + assert_eq!(session.amplification().discover_calls, 1); + assert_eq!(session.amplification().describe_calls, 0); + assert!(delivery.requires_deferred_installation()); + let candidate = &value["candidates"][0]; + let mut loaded_definition = definition( + candidate["capabilityId"] + .as_str() + .expect("candidate should expose an ID"), + ); + loaded_definition.fingerprint = session.loaded_bindings()[0] + .capability_fingerprint() + .to_owned(); + delivery + .install_deferred_definitions(vec![loaded_definition]) + .expect("exact loaded definition should install"); + assert_eq!(delivery.current_tools().len(), 2); + assert!(!delivery.requires_deferred_installation()); + + authority.allowed.store(false, Ordering::SeqCst); + let empty = broker + .dispatch_client_deferred_discover( + &principal_reference, + &run_binding(), + &session, + &json!({ + "text": "reviewed record", + "kind": "generated_query", + "maximumResults": 1 + }), + ) + .await + .expect("an empty currently-authorized search is valid"); + assert_eq!(empty["candidates"].as_array().map(Vec::len), Some(0)); + assert!(delivery.requires_deferred_installation()); + delivery + .install_deferred_definitions(Vec::new()) + .expect("empty discovery should clear stale definitions"); + assert_eq!(delivery.current_tools().len(), 1); + assert_eq!( + delivery.current_tools()[0].tool_id, + AI_CAPABILITY_DISCOVER_TOOL_ID + ); + assert!(!delivery.requires_deferred_installation()); + authority.allowed.store(true, Ordering::SeqCst); + + assert!(matches!( + broker + .dispatch_client_deferred_discover( + &principal_reference, + &run_binding(), + &session, + &json!({ + "text": "record", + "kind": "reviewed_static", + "maximumResults": 1 + }), + ) + .await, + Err(AiError::InvalidInput(_)) + )); + assert!(matches!( + broker + .dispatch_client_deferred_discover( + &principal_reference, + &run_binding(), + &session, + &json!({ + "text": "record", + "maximumResults": limits.maximum_deferred_definitions + 1 + }), + ) + .await, + Err(AiError::InvalidInput(_)) + )); + } + + #[test] + fn fixed_broker_argument_paths_build_closed_nested_inputs() { + let mut value = serde_json::Value::Object(serde_json::Map::new()); + let mut seen = BTreeSet::new(); + insert_broker_argument( + &mut value, + "filter.and.0.status.eq", + json!("open"), + &mut seen, + ) + .expect("nested filter should be representable"); + insert_broker_argument(&mut value, "filter.and.1.priority.gte", json!(2), &mut seen) + .expect("second bounded list item should be representable"); + assert_eq!( + value, + json!({ + "filter": { + "and": [ + {"status": {"eq": "open"}}, + {"priority": {"gte": 2}} + ] + } + }) + ); + + assert!(matches!( + insert_broker_argument( + &mut value, + "filter.and.0.status.eq", + json!("closed"), + &mut seen, + ), + Err(AiError::InvalidInput(_)) + )); + assert!(matches!( + insert_broker_argument( + &mut value, + "filter.and.64.status.eq", + json!("closed"), + &mut seen, + ), + Err(AiError::InvalidInput(_)) + )); + assert!(matches!( + insert_broker_argument(&mut value, "filter.raw", json!({"sql": "never"}), &mut seen,), + Err(AiError::InvalidInput(_)) + )); + } + + #[test] + fn describe_bounds_the_complete_planning_contract_without_truncation() { + let description = AiCapabilityDescription { + loaded_reference: "a".repeat(64), + capability_id: AiToolId::parse("jim.query.records.auto").expect("capability ID"), + capability_kind: AiCapabilityKind::GeneratedQuery, + capability_fingerprint: "b".repeat(64), + contract: json!({"loadedReference": "a".repeat(64)}), + }; + let bounded = description + .clone() + .with_plan_schema( + json!({ + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }), + 1_024, + ) + .expect("small complete contract should fit"); + assert_eq!(bounded.contract()["planSchemaAvailable"], true); + assert!(bounded.contract().get("planSchema").is_some()); + + let oversized = description + .with_plan_schema(json!({"description": "x".repeat(2_048)}), 1_024) + .expect("oversized schema should become an explicit unavailable contract"); + assert_eq!(oversized.contract()["planSchemaAvailable"], false); + assert!(oversized.contract().get("planSchema").is_none()); + assert!(serde_json::to_vec(oversized.contract()).is_ok_and(|value| value.len() <= 1_024)); + + let oversized_metadata = AiCapabilityDescription { + loaded_reference: "a".repeat(64), + capability_id: AiToolId::parse("jim.query.records.auto").expect("capability ID"), + capability_kind: AiCapabilityKind::GeneratedQuery, + capability_fingerprint: "b".repeat(64), + contract: json!({"description": "x".repeat(2_048)}), + }; + assert!(matches!( + oversized_metadata.with_plan_schema(json!({"type": "object"}), 1_024), + Err(AiError::InvalidConfiguration(_)) + )); + } } diff --git a/crates/graphql-orm-ai/src/orm_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index 78c63998..7b1cecd4 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -65,6 +65,7 @@ pub struct AiReadOnlyAgentTurnPlan { rules: AiResolvedRuleSet, uses_byok: bool, provider_session: Option, + capability_delivery: Option, } enum AiReadOnlyAgentTurnMode { @@ -104,6 +105,7 @@ impl AiReadOnlyAgentTurnPlan { rules, uses_byok, provider_session: None, + capability_delivery: None, }) } @@ -136,6 +138,7 @@ impl AiReadOnlyAgentTurnPlan { rules, uses_byok, provider_session: None, + capability_delivery: None, }) } @@ -173,6 +176,7 @@ impl AiReadOnlyAgentTurnPlan { rules, uses_byok, provider_session: None, + capability_delivery: None, }) } @@ -197,6 +201,10 @@ impl AiReadOnlyAgentTurnPlan { AiReadOnlyAgentTurnMode::ChatOnly | AiReadOnlyAgentTurnMode::ExperimentalDynamicTools(_) ) + || self.capability_delivery.as_ref().is_some_and(|delivery| { + session.descriptor().registration_fingerprint() + != delivery.session_binding().fingerprint() + }) { return Err(AiError::InvalidInput( "provider-session plan does not match the exact provider call".to_owned(), @@ -206,6 +214,37 @@ impl AiReadOnlyAgentTurnPlan { Ok(self) } + /// Binds this turn to one crate-owned capability delivery surface. + /// + /// The coordinator refuses the turn unless the offered definitions are + /// exactly the definitions the crate minted for the current delivery mode + /// and compact index, so a host installs the surface and never authors it. + /// Broker calls in the resulting turn are dispatched through the ordinary + /// durable application-tool broker. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidInput`] unless the plan offers exactly the + /// crate-owned surface and carries an application-tool result route. + pub fn with_capability_delivery( + mut self, + delivery: crate::AiCapabilityDeliveryTurn, + ) -> Result { + if !delivery.matches_offered_tools(self.provider_call.offered_tools()) + || matches!(&self.mode, AiReadOnlyAgentTurnMode::ChatOnly) + || self.provider_session.as_ref().is_some_and(|session| { + session.descriptor().registration_fingerprint() + != delivery.session_binding().fingerprint() + }) + { + return Err(AiError::InvalidInput( + "capability delivery surface does not match the exact provider call".to_owned(), + )); + } + self.capability_delivery = Some(delivery); + Ok(self) + } + fn into_parts( self, ) -> ( @@ -216,6 +255,7 @@ impl AiReadOnlyAgentTurnPlan { AiResolvedRuleSet, bool, Option, + Option, ) { let scope = self.provider_call.scope().clone(); let correlation_id = self.provider_call.correlation_id().to_owned(); @@ -227,6 +267,7 @@ impl AiReadOnlyAgentTurnPlan { self.rules, self.uses_byok, self.provider_session, + self.capability_delivery, ) } @@ -272,6 +313,32 @@ pub trait AiReadOnlyAgentTurnPlanner: Send + Sync { provider_turns: u32, continuation: AiAgentContinuation, ) -> Result; + + /// Builds the next provider turn with the crate-owned capability delivery + /// state that survived the preceding turn. + /// + /// Implementations adopting deferred capability delivery should override + /// this method, call [`crate::AiCapabilityDeliveryTurn::current_surface`] + /// after any client-deferred installation, and attach a clone of the same + /// delivery turn to the returned plan. The default preserves existing + /// planners that do not use capability delivery. + /// + /// # Errors + /// + /// Returns a safe library error when current configuration, context, + /// budget estimates, egress manifests, or the updated exact capability + /// surface cannot produce a continuation plan. + async fn continuation_plan_with_capability_delivery( + &self, + lease: &AiRunLease, + provider_turns: u32, + continuation: AiAgentContinuation, + capability_delivery: Option<&crate::AiCapabilityDeliveryTurn>, + ) -> Result { + let _ = capability_delivery; + self.continuation_plan(lease, provider_turns, continuation) + .await + } } /// Fresh current-principal hierarchical-rule resolution for one run boundary. @@ -553,6 +620,50 @@ pub trait AiAgentReadOnlyToolExecutor: Send + Sync { "durable safe tool failures are not implemented by this executor".to_owned(), )) } + + /// Executes one frozen capability-broker call through the ordinary durable + /// tool broker. + /// + /// Discovery and describe return bounded authority-neutral metadata; only + /// execute reaches a resolver, through the exact loaded binding. The + /// default remains unavailable so an existing executor does not gain the + /// broker implicitly. + /// + /// # Errors + /// + /// Returns a safe library error for a stale binding/fence, a non-broker or + /// unoffered tool, authorization, disclosure, protection, execution, + /// egress, or persistence failure. + async fn execute_capability_broker( + &self, + _lease: &AiRunLease, + _provider_result: &AiProviderCallResult, + _context: AiApplicationToolCallContext, + _route: AiToolResultEgressRoute, + _delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result { + Err(AiError::InvalidConfiguration( + "capability broker dispatch is not implemented by this executor".to_owned(), + )) + } + + /// Projects the exact registered definitions for the capabilities a + /// client-deferred broker run has already loaded. + /// + /// # Errors + /// + /// Returns a safe library error when a loaded capability is no longer a + /// registered generated read capability. + async fn loaded_capability_definitions( + &self, + _lease: &AiRunLease, + _provider_kind: &crate::ProviderKind, + _delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result, AiError> { + Err(AiError::InvalidConfiguration( + "deferred capability projection is not implemented by this executor".to_owned(), + )) + } } #[async_trait] @@ -579,6 +690,33 @@ impl AiAgentReadOnlyToolExecutor for OrmAiApplicationToolCallService { self.persist_safe_read_failure(lease, provider_result, context, route, code) .await } + + async fn execute_capability_broker( + &self, + lease: &AiRunLease, + provider_result: &AiProviderCallResult, + context: AiApplicationToolCallContext, + route: AiToolResultEgressRoute, + delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result { + self.execute_capability_broker_call(lease, provider_result, context, route, delivery) + .await + } + + async fn loaded_capability_definitions( + &self, + lease: &AiRunLease, + provider_kind: &crate::ProviderKind, + delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result, AiError> { + OrmAiApplicationToolCallService::loaded_capability_definitions( + self, + lease, + provider_kind, + delivery, + ) + .await + } } struct DynamicToolExecutionState { @@ -596,6 +734,7 @@ struct ReadOnlyDynamicToolExecution { rule_fingerprint: String, provider_turn_index: u32, maximum_calls: u32, + capability_delivery: Option, state: Mutex, } @@ -612,6 +751,7 @@ impl ReadOnlyDynamicToolExecution { provider_turn_index: u32, maximum_calls: u32, rule_usage: AiRuleRunUsage, + capability_delivery: Option, ) -> Self { Self { run_control, @@ -623,6 +763,7 @@ impl ReadOnlyDynamicToolExecution { rule_fingerprint, provider_turn_index, maximum_calls, + capability_delivery, state: Mutex::new(DynamicToolExecutionState { rule_usage, accepted_calls: 0, @@ -676,10 +817,28 @@ impl AiProviderDynamicToolExecution for ReadOnlyDynamicToolExecution { self.correlation_id.clone(), provider_result.budget_reservation_id().0.to_string(), )?; - let persisted = self - .tool_executor - .execute_tool(lease, provider_result, context, self.route.clone()) - .await?; + let persisted = match ( + crate::AiCapabilityBrokerOperation::from_tool_id(call.tool_id()), + self.capability_delivery.as_ref(), + ) { + (None, _) => { + self.tool_executor + .execute_tool(lease, provider_result, context, self.route.clone()) + .await? + } + (Some(_), Some(delivery)) => { + self.tool_executor + .execute_capability_broker( + lease, + provider_result, + context, + self.route.clone(), + delivery, + ) + .await? + } + (Some(_), None) => return Err(AiError::Forbidden), + }; if self .run_control .cancellation(persisted.lease()) @@ -1350,6 +1509,8 @@ impl AiReadOnlyAgentCoordinator { }; (guard, plan, AiRuleRunUsage::default()) }; + let mut capability_delivery: Option = None; + let mut capability_delivery_initialized = false; loop { if self.run_control.cancellation(&lease).await?.is_some() { @@ -1371,7 +1532,35 @@ impl AiReadOnlyAgentCoordinator { planned_rules, uses_byok, provider_session_plan, + turn_capability_delivery, ) = turn_plan.into_parts(); + match ( + capability_delivery_initialized, + &capability_delivery, + &turn_capability_delivery, + ) { + (false, _, None) => { + capability_delivery_initialized = true; + } + (false, _, Some(delivery)) + if delivery.matches_offered_tools(provider_plan.offered_tools()) => + { + capability_delivery = turn_capability_delivery.clone(); + capability_delivery_initialized = true; + } + (true, None, None) => {} + (true, Some(existing), Some(delivery)) + if delivery.matches_offered_tools(provider_plan.offered_tools()) + && existing.shares_run_state(delivery) => + { + capability_delivery = turn_capability_delivery.clone(); + } + _ => { + return self + .finish_failed(&lease, &guard, "capability_delivery_surface_invalid") + .await; + } + } let resolution = match self.rule_resolver.resolve_rules(&lease, &scope).await { Ok(resolution) if resolution.rules().fingerprint() == planned_rules.fingerprint() => @@ -1414,6 +1603,7 @@ impl AiReadOnlyAgentCoordinator { guard.provider_turns(), guard.remaining_tool_capacity(), rule_usage, + capability_delivery.clone(), ))) } AiReadOnlyAgentTurnMode::ChatOnly @@ -1760,11 +1950,29 @@ impl AiReadOnlyAgentCoordinator { result.budget_reservation_id().0.to_string(), )?; let failure_context = context.clone(); - let persisted = match self - .tool_executor - .execute_tool(&lease, &result, context, route.clone()) - .await - { + let broker_operation = crate::AiCapabilityBrokerOperation::from_tool_id( + result.tool_calls()[tool_call_index].tool_id(), + ); + let dispatch = match (broker_operation, capability_delivery.as_ref()) { + (None, _) => { + self.tool_executor + .execute_tool(&lease, &result, context, route.clone()) + .await + } + (Some(_), Some(delivery)) => { + self.tool_executor + .execute_capability_broker( + &lease, + &result, + context, + route.clone(), + delivery, + ) + .await + } + (Some(_), None) => Err(AiError::Forbidden), + }; + let persisted = match dispatch { Ok(persisted) => persisted, Err(error) => { if self.run_control.cancellation(&lease).await?.is_some() { @@ -1877,9 +2085,30 @@ impl AiReadOnlyAgentCoordinator { .await; } }; + if let Some(delivery) = capability_delivery.as_ref() + && delivery.requires_deferred_installation() + { + let installed = self + .tool_executor + .loaded_capability_definitions(&lease, result.provider_kind(), delivery) + .await + .and_then(|definitions| { + delivery.install_deferred_definitions(definitions) + }); + if installed.is_err() { + return self + .finish_failed(&lease, &guard, "capability_delivery_install_failed") + .await; + } + } turn_plan = match self .planner - .continuation_plan(&lease, guard.provider_turns(), continuation) + .continuation_plan_with_capability_delivery( + &lease, + guard.provider_turns(), + continuation, + capability_delivery.as_ref(), + ) .await { Ok(plan) if plan.is_continuation() => plan, @@ -3554,7 +3783,7 @@ mod tests { false, ) .expect("an exact initial tool-free plan should validate"); - let (_, _, _, mode, _, _, _) = plan.into_parts(); + let (_, _, _, mode, _, _, _, _) = plan.into_parts(); assert!(matches!(mode, AiReadOnlyAgentTurnMode::ChatOnly)); assert!(matches!( diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 3b69d39c..7ef6cfbc 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -982,6 +982,565 @@ impl OrmAiApplicationToolCallService { }) } + /// Executes one frozen capability-broker call as an ordinary durable + /// application tool call. + /// + /// `graphql.capabilities.discover` and `graphql.capabilities.describe` + /// return bounded authority-neutral metadata and never reach a resolver; + /// only `graphql.capabilities.execute` does, through the exact loaded + /// binding and [`AiRuntime::execute_query_capability`]. The call follows + /// the same order as every other read: validate, rehydrate, current + /// policy, fenced row, exact capability, resolver authorization, + /// disclosure/protection, durable outcome, one model result. + /// + /// The offered definition must be exactly the crate-minted broker + /// definition for the current compact index, so a host cannot widen, + /// rename, or re-fingerprint the broker surface, and a model cannot call a + /// broker tool that was not offered for the current index. + /// + /// # Errors + /// + /// Returns an error for a stale result/lease/context binding, a + /// non-broker or unoffered tool, oversized arguments, unavailable + /// protection, a stale fence, or persistence failure. The ordinary and + /// dynamic coordinator loops classify pre-start unknown, stale, expired, + /// unauthorized, and over-budget selections and persist one bounded safe + /// failure through their shared failure boundary; this method never starts + /// a second row for the same call. + pub async fn execute_capability_broker_call( + &self, + lease: &AiRunLease, + provider_result: &AiProviderCallResult, + context: AiApplicationToolCallContext, + route: AiToolResultEgressRoute, + delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result { + self.dispatch_capability_broker(lease, provider_result, context, route, delivery) + .await + } + + /// Projects the exact provider definitions for the capabilities currently + /// loaded by one client-deferred broker run. + /// + /// The definitions are minted from the registered capability catalogue, + /// never from model output, and are the only definitions the internal + /// deferred-definition installer accepts. + /// + /// # Errors + /// + /// Returns [`AiError::Forbidden`] when a loaded capability is no longer a + /// registered generated read capability, and a safe error when its + /// provider projection is invalid. + pub async fn loaded_capability_definitions( + &self, + lease: &AiRunLease, + provider_kind: &crate::ProviderKind, + delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result, AiError> { + let run = crate::AiCapabilityRunBinding::from_lease( + lease, + provider_kind.clone(), + delivery.session_binding(), + ); + let mut definitions = Vec::new(); + for binding in delivery.session().loaded_bindings() { + delivery + .broker() + .authorize_execution(lease.principal_reference(), &run, &binding) + .await?; + let definition = self + .runtime + .tool_catalog() + .query_capability_model_definition( + binding.capability_id(), + crate::capability_provider_alias(binding.capability_id()), + )?; + if definition.fingerprint != binding.capability_fingerprint() { + return Err(AiError::Forbidden); + } + definitions.push(definition); + } + Ok(definitions) + } + + async fn dispatch_capability_broker( + &self, + lease: &AiRunLease, + provider_result: &AiProviderCallResult, + context: AiApplicationToolCallContext, + route: AiToolResultEgressRoute, + delivery: &crate::AiCapabilityDeliveryTurn, + ) -> Result { + self.validate_outer_binding(lease, provider_result, &context, &route)?; + let provider_call = provider_result + .tool_calls() + .get(context.tool_call_index) + .ok_or_else(|| AiError::InvalidInput("tool call index is out of bounds".to_owned()))?; + let operation = crate::AiCapabilityBrokerOperation::from_tool_id(provider_call.tool_id()) + .ok_or(AiError::Forbidden)?; + let expected = crate::capability_broker_definitions(delivery.index_fingerprint())? + .into_iter() + .find(|definition| definition.tool_id == operation.tool_id()) + .ok_or(AiError::Forbidden)?; + let offered = provider_result + .request_snapshot() + .tools + .iter() + .filter(|definition| definition.tool_id == operation.tool_id()) + .collect::>(); + if offered.len() != 1 + || *offered[0] != expected + || provider_call.tool_fingerprint() != expected.fingerprint + { + return Err(AiError::Forbidden); + } + let argument_bytes = serde_json::to_vec(provider_call.arguments()) + .map_err(|_| AiError::InvalidInput("invalid tool arguments".to_owned()))?; + if argument_bytes.len() > self.limits.maximum_argument_bytes { + return Err(AiError::InvalidInput( + "tool arguments exceed deployment limit".to_owned(), + )); + } + + let session = + AiSessionRecord::find_by_id(self.run_service.database(), &lease.session_id().0) + .await + .map_err(|error| map_orm(OrmPublicError::from(error)))? + .ok_or(AiError::NotFound)?; + validate_session_binding(&session, lease, &context.scope)?; + let principal = self.current_access(lease, &context.scope).await?; + let policy = self + .runtime + .content_protection_policy_resolver() + .resolve(principal.principal(), &context.scope) + .await?; + if !policy.ready || policy.scope != context.scope { + return Err(AiError::RuntimeNotReady); + } + + let id = AiToolCallId::new(); + let provider_call_key = provider_call_key(lease, provider_call.call_id()); + let argument_hash = canonical_json_hash(provider_call.arguments())?; + let protected_arguments = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + id.0, + "protected_arguments", + &context.scope, + ), + provider_call.arguments().clone(), + ) + .await?; + let started_event_id = Uuid::new_v4(); + let started_inbox_event_id = Uuid::new_v4(); + let started_payload = json!({ + "toolCallId": id.0, + "runId": lease.run_id().0, + "toolId": provider_call.tool_id().as_str(), + }); + let protected_started_event = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_session_events", + started_event_id, + "protected_payload", + &context.scope, + ), + started_payload.clone(), + ) + .await?; + let protected_started_inbox_event = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_inbox_events", + started_inbox_event_id, + "protected_payload", + &context.scope, + ), + started_payload, + ) + .await?; + let active_lease = self + .run_service + .begin_tool_call( + lease, + PreparedToolCallStart { + id: id.0, + provider_call_key: provider_call_key.clone(), + provider_call_id: provider_call.call_id().to_owned(), + provider_kind: provider_result.provider_kind().as_str().to_owned(), + provider_model: provider_result.provider_model().to_owned(), + provider_response_id: provider_result.provider_response_id().map(str::to_owned), + budget_reservation_id: provider_result.budget_reservation_id().0, + provider_turn_index: i64::from(context.provider_turn_index), + tool_call_index: i64::try_from(context.tool_call_index) + .map_err(|_| AiError::InvalidInput("invalid tool index".to_owned()))?, + tool_id: provider_call.tool_id().as_str().to_owned(), + tool_fingerprint: provider_call.tool_fingerprint().to_owned(), + protected_arguments, + argument_hash, + risk: "read_only".to_owned(), + idempotency_key: Some(format!("ai-tool:{provider_call_key}")), + correlation_id: context.correlation_id.clone(), + causation_id: context.causation_id.clone(), + delegation_reference: context.delegation_reference.clone(), + started_event: Some(PreparedToolLifecycleEvent { + event_id: started_event_id, + inbox_event_id: started_inbox_event_id, + protected_event: protected_started_event, + protected_inbox_event: protected_started_inbox_event, + }), + expected_owner_principal_kind: session.owner_principal_kind.clone(), + expected_owner_subject: session.owner_subject.clone(), + expected_scope_kind: context.scope.kind.clone(), + expected_scope_id: context.scope.id.clone(), + expected_tenant_id: context.scope.tenant_id.clone(), + }, + ) + .await?; + let lease = &active_lease; + let outcome = self + .run_capability_broker(lease, &context, id, provider_result, delivery, operation) + .await; + let ( + mut state, + mut model_output, + mut classification, + mut source_trust, + mut authorization_code, + mut policy_version, + mut authorization_state_digest, + mut application_audit_ref, + mut failure_code, + mut disclosure_fingerprint, + ) = match outcome { + Ok(success) => ( + AiApplicationToolCallState::Completed, + success.output, + success.classification, + success.source_trust, + "allowed".to_owned(), + success.policy_version, + success.authorization_state_digest, + success.application_audit_ref, + None, + success.disclosure_fingerprint, + ), + Err(error) => { + let code = crate::classify_safe_application_tool_error(&error) + .unwrap_or(crate::AiApplicationToolFailureCode::ToolUnavailable); + ( + AiApplicationToolCallState::ExecutionFailed, + crate::AiApplicationToolFailureEnvelope::new(code).to_json(), + DataClassification::Public, + AiSourceTrust::TrustedRuntime, + code.as_str().to_owned(), + None, + None, + None, + Some(code), + safe_failure_disclosure_fingerprint(), + ) + } + }; + let mut output_bytes = + serde_json::to_vec(&model_output).map_err(|_| AiError::ToolExecutionFailed)?; + if output_bytes.len() > self.limits.maximum_model_output_bytes { + let code = crate::AiApplicationToolFailureCode::ResultBudgetExceeded; + state = AiApplicationToolCallState::ExecutionFailed; + model_output = crate::AiApplicationToolFailureEnvelope::new(code).to_json(); + classification = DataClassification::Public; + source_trust = AiSourceTrust::TrustedRuntime; + authorization_code = code.as_str().to_owned(); + policy_version = None; + authorization_state_digest = None; + application_audit_ref = None; + failure_code = Some(code); + disclosure_fingerprint = safe_failure_disclosure_fingerprint(); + output_bytes = + serde_json::to_vec(&model_output).map_err(|_| AiError::ToolExecutionFailed)?; + } + let outbound_bytes = output_bytes + .len() + .checked_add(provider_call.call_id().len()) + .and_then(|bytes| bytes.checked_add(provider_call.tool_id().as_str().len())) + .ok_or_else(|| AiError::InvalidInput("tool result is too large".to_owned()))?; + self.current_access(lease, &context.scope).await?; + let manifest = AiEgressManifest { + provider_profile_id: route.provider_profile_id, + provider_kind: provider_result.provider_kind().as_str().to_owned(), + model: provider_result.provider_model().to_owned(), + destination: route.destination, + destination_trust: route.destination_trust, + capability: AiEgressCapability::ToolResult, + scope: context.scope.clone(), + session_id: Some(lease.session_id()), + run_id: Some(lease.run_id()), + sources: vec![AiDataSourceRef { + kind: "application_tool_result".to_owned(), + reference: id.0.to_string(), + classification, + trust: source_trust, + }], + estimated_bytes: u64::try_from(outbound_bytes) + .map_err(|_| AiError::InvalidInput("tool result is too large".to_owned()))?, + estimated_tokens: 0, + attachment_count: 0, + purpose: route.purpose, + retention: route.retention, + residency: route.residency, + policy_version: route.policy_version, + consent_reference: route.consent_reference, + }; + let decision = self + .runtime + .authorize_egress(lease.principal_reference(), &manifest) + .await?; + let audit_result = self.egress_audit.record(&manifest, &decision).await; + let (final_state, model_input, decision_id, manifest_hash, final_authorization_code) = + if audit_result.is_err() { + ( + AiApplicationToolCallState::EgressAuditFailed, + None, + None, + None, + "egress_audit_failed".to_owned(), + ) + } else if decision.authorize(&manifest).is_err() { + ( + AiApplicationToolCallState::EgressDenied, + None, + Some(decision.id.0), + Some(decision.manifest_hash.clone()), + "egress_denied".to_owned(), + ) + } else { + ( + state, + Some(ModelInputBlock::ToolResult { + call_id: provider_call.call_id().to_owned(), + tool_id: provider_call.tool_id().as_str().to_owned(), + output: model_output.clone(), + }), + Some(decision.id.0), + Some(decision.manifest_hash.clone()), + authorization_code, + ) + }; + let protected_result = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + id.0, + "protected_result", + &context.scope, + ), + model_output, + ) + .await?; + let event_id = Uuid::new_v4(); + let inbox_event_id = Uuid::new_v4(); + let terminal_payload = json!({ + "toolCallId": id.0, + "runId": lease.run_id().0, + "toolId": provider_call.tool_id().as_str(), + "state": final_state.as_str(), + }); + let protected_event = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_session_events", + event_id, + "protected_payload", + &context.scope, + ), + terminal_payload.clone(), + ) + .await?; + let protected_inbox_event = self + .protect( + &policy, + protection_context( + "graphql_orm_ai_inbox_events", + inbox_event_id, + "protected_payload", + &context.scope, + ), + terminal_payload, + ) + .await?; + let renewed = self + .run_service + .finish_tool_call( + lease, + PreparedToolCallFinish { + id: id.0, + state: final_state.as_str().to_owned(), + protected_result, + authorization_code: final_authorization_code, + authorization_policy_version: policy_version, + authorization_state_digest, + disclosure_schema_fingerprint: disclosure_fingerprint, + result_classification: classification_value(classification).to_owned(), + result_egress_decision_id: decision_id, + result_egress_manifest_hash: manifest_hash, + application_audit_ref, + event_id, + inbox_event_id, + protected_event, + protected_inbox_event, + correlation_id: context.correlation_id, + expected_provider_call_key: provider_call_key, + expected_tool_fingerprint: provider_call.tool_fingerprint().to_owned(), + expected_owner_principal_kind: session.owner_principal_kind, + expected_owner_subject: session.owner_subject, + expected_scope_kind: context.scope.kind, + expected_scope_id: context.scope.id, + expected_tenant_id: context.scope.tenant_id, + }, + ) + .await?; + Ok(AiPersistedApplicationToolCall { + id, + provider_call_id: provider_call.call_id().to_owned(), + state: final_state, + model_input, + egress_manifest: decision_id.map(|_| manifest), + failure_code, + lease: renewed, + }) + } + + async fn run_capability_broker( + &self, + lease: &AiRunLease, + context: &AiApplicationToolCallContext, + tool_call_id: AiToolCallId, + provider_result: &AiProviderCallResult, + delivery: &crate::AiCapabilityDeliveryTurn, + operation: crate::AiCapabilityBrokerOperation, + ) -> Result { + let provider_call = provider_result + .tool_calls() + .get(context.tool_call_index) + .ok_or(AiError::Conflict)?; + let arguments = provider_call.arguments(); + let run = crate::AiCapabilityRunBinding::from_lease( + lease, + provider_result.provider_kind().clone(), + delivery.session_binding(), + ); + let broker = delivery.broker(); + let session = delivery.session(); + match operation { + crate::AiCapabilityBrokerOperation::Discover => { + let output = if delivery.mode() == crate::AiCapabilityDeliveryMode::ClientDeferred { + broker + .dispatch_client_deferred_discover( + lease.principal_reference(), + &run, + session, + arguments, + ) + .await? + } else { + broker + .dispatch_discover(lease.principal_reference(), &run, session, arguments) + .await? + }; + BrokerCallOutput::metadata(output, broker_disclosure_fingerprint(operation)) + } + crate::AiCapabilityBrokerOperation::Describe => { + let description = broker + .dispatch_describe(lease.principal_reference(), &run, session, arguments) + .await?; + let definition = self + .runtime + .tool_catalog() + .query_capability_model_definition( + description.capability_id(), + crate::capability_provider_alias(description.capability_id()), + )?; + if definition.fingerprint != description.capability_fingerprint() { + return Err(AiError::Forbidden); + } + let description = description.with_plan_schema( + definition.parameters, + session.limits().maximum_describe_bytes, + )?; + BrokerCallOutput::metadata( + description.into_model_result(), + broker_disclosure_fingerprint(operation), + ) + } + crate::AiCapabilityBrokerOperation::Execute => { + let execution = broker + .authorize_broker_execution( + lease.principal_reference(), + &run, + session, + arguments, + ) + .await?; + let compiled = self.runtime.tool_catalog().compile_query_capability( + execution.capability_id(), + execution.capability_fingerprint(), + execution.plan().clone(), + )?; + let (_, disclosure, _) = compiled.into_parts(); + let disclosure_fingerprint = disclosure.fingerprint.clone(); + let invocation = GraphqlInvocationContext { + run_id: lease.run_id(), + tool_call_id, + scope: context.scope.clone(), + correlation_id: context.correlation_id.clone(), + causation_id: context.causation_id.clone(), + delegation_reference: context.delegation_reference.clone(), + idempotency_key: Some(format!( + "ai-tool:{}", + provider_call_key(lease, provider_call.call_id()) + )), + }; + let capability_id = execution.capability_id().clone(); + let capability_fingerprint = execution.capability_fingerprint().to_owned(); + let plan = execution.into_plan(); + let result = tokio::time::timeout( + self.limits.maximum_execution_time.unsigned_abs(), + async { + self.runtime + .execute_query_capability( + lease.principal_reference(), + &capability_id, + &capability_fingerprint, + plan, + invocation, + ) + .await + }, + ) + .await + .unwrap_or(Err(AiError::ToolExecutionFailed))?; + Ok(BrokerCallOutput { + output: result.model_output(), + classification: result.disclosure().maximum_classification, + source_trust: AiSourceTrust::ResolverResult, + disclosure_fingerprint, + policy_version: Some(result.policy_version().to_owned()), + authorization_state_digest: Some( + result.authorization_state_digest().to_owned(), + ), + application_audit_ref: result.response().application_audit_ref.clone(), + }) + } + } + } + /// Executes one exact generated mutation classified `Automatic`. /// /// The service compiles and freshly preauthorizes the closed plan, then @@ -2884,6 +3443,52 @@ fn valid_audit_reference(value: &str) -> bool { && value.bytes().all(|byte| !byte.is_ascii_control()) } +struct BrokerCallOutput { + output: serde_json::Value, + classification: DataClassification, + source_trust: AiSourceTrust, + disclosure_fingerprint: String, + policy_version: Option, + authorization_state_digest: Option, + application_audit_ref: Option, +} + +impl BrokerCallOutput { + fn metadata( + output: serde_json::Value, + disclosure_fingerprint: String, + ) -> Result { + let authorization_state_digest = canonical_json_hash(&json!({ + "contract": "graphql-orm-ai/capability-broker-metadata-authorization/v1", + "disclosureFingerprint": &disclosure_fingerprint, + "output": &output, + }))?; + Ok(Self { + output, + classification: DataClassification::Public, + source_trust: AiSourceTrust::TrustedRuntime, + disclosure_fingerprint, + policy_version: Some("capability-broker-metadata-v1".to_owned()), + authorization_state_digest: Some(authorization_state_digest), + application_audit_ref: None, + }) + } +} + +fn broker_disclosure_fingerprint(operation: crate::AiCapabilityBrokerOperation) -> String { + hex::encode(Sha256::digest(match operation { + crate::AiCapabilityBrokerOperation::Discover => { + b"graphql-orm-ai/capability-broker-discover/v1".as_slice() + } + crate::AiCapabilityBrokerOperation::Describe => { + b"graphql-orm-ai/capability-broker-describe/v1".as_slice() + } + crate::AiCapabilityBrokerOperation::Execute => { + b"graphql-orm-ai/capability-broker-execute/v1".as_slice() + } + })) +} + fn safe_failure_disclosure_fingerprint() -> String { hex::encode(Sha256::digest( b"graphql-orm-ai/application-tool-safe-failure/v1", diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index b0fd9b3d..f0568621 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -586,6 +586,144 @@ impl AiProviderCallPlan { ) } + /// Creates an initial provider call exposing exactly one crate-owned + /// capability delivery surface. + /// + /// The surface is minted by + /// [`crate::prepare_capability_delivery_surface`] and its fields are + /// private, so a host cannot author, widen, rename, or re-fingerprint the + /// offered definitions. Frozen broker definitions are crate-owned and need + /// no catalogue entry; every other definition must still be one uniquely + /// registered, currently admitted read capability. + /// + /// # Errors + /// + /// Returns a safe error for ordinary initial-plan binding failures, an + /// offered set that differs from the crate-owned surface, or any unknown, + /// colliding, disabled, stale, mutation, or subscription definition. + #[allow(clippy::too_many_arguments)] + pub fn new_with_capability_surface( + provider_kind: ProviderKind, + request: ModelRequest, + budget: AiBudgetReservationRequest, + transfers: Vec, + correlation_id: impl Into, + surface: &crate::AiCapabilityDeliverySurface, + catalog: &crate::AiToolCatalog, + static_policy: &AiToolPolicySet, + generated_targets: &crate::AiGeneratedGraphqlTargetPolicySet, + ) -> Result { + if request.tools.is_empty() + || request.continuation.is_some() + || request + .input + .iter() + .any(|block| matches!(block, crate::ModelInputBlock::ToolResult { .. })) + { + return Err(AiError::InvalidInput( + "initial capability-surface provider plan is invalid".to_owned(), + )); + } + Self::new_with_bound_capability_surface( + provider_kind, + request, + budget, + transfers, + correlation_id, + surface, + catalog, + static_policy, + generated_targets, + ) + } + + /// Creates a subsequent turn that retains one exact crate-owned capability + /// delivery surface and installs one opaque bounded-loop continuation. + /// + /// A client-deferred run supplies the surface returned by the internal + /// deferred-definition installer; every other mode supplies its unchanged + /// initial surface. + /// + /// # Errors + /// + /// Returns a safe error for malformed continuation bindings, an offered + /// set that differs from the crate-owned surface, or any unknown, + /// colliding, disabled, stale, mutation, or subscription definition. + #[allow(clippy::too_many_arguments)] + pub fn new_continuation_with_capability_surface( + provider_kind: ProviderKind, + mut request: ModelRequest, + budget: AiBudgetReservationRequest, + mut transfers: Vec, + correlation_id: impl Into, + continuation: crate::AiAgentContinuation, + surface: &crate::AiCapabilityDeliverySurface, + catalog: &crate::AiToolCatalog, + static_policy: &AiToolPolicySet, + generated_targets: &crate::AiGeneratedGraphqlTargetPolicySet, + ) -> Result { + let tool_transfers = continuation.apply_with_transfers(&mut request)?; + transfers.extend(tool_transfers); + Self::new_with_bound_capability_surface( + provider_kind, + request, + budget, + transfers, + correlation_id, + surface, + catalog, + static_policy, + generated_targets, + ) + } + + #[allow(clippy::too_many_arguments)] + fn new_with_bound_capability_surface( + provider_kind: ProviderKind, + request: ModelRequest, + budget: AiBudgetReservationRequest, + transfers: Vec, + correlation_id: impl Into, + surface: &crate::AiCapabilityDeliverySurface, + catalog: &crate::AiToolCatalog, + static_policy: &AiToolPolicySet, + generated_targets: &crate::AiGeneratedGraphqlTargetPolicySet, + ) -> Result { + if request.tools.is_empty() || request.tools.as_slice() != surface.tools() { + return Err(AiError::Forbidden); + } + for definition in &request.tools { + let id = crate::AiToolId::parse(definition.tool_id.clone())?; + if crate::AiCapabilityBrokerOperation::from_tool_id(&id).is_some() { + continue; + } + catalog.validate_read_capability_model_definition( + definition, + static_policy, + generated_targets, + )?; + } + let bindings = request + .tools + .iter() + .map(|definition| AiPlanToolRuleBinding { + fingerprint: definition.fingerprint.clone(), + maturity: ToolMaturity::ReadOnly, + approval: AiApprovalRule::None, + }) + .collect(); + let mut plan = Self::new_internal( + provider_kind, + request, + budget, + transfers, + correlation_id.into(), + true, + )?; + plan.tool_rule_bindings = bindings; + Ok(plan) + } + fn new_with_bound_tools( provider_kind: ProviderKind, request: ModelRequest, @@ -967,6 +1105,10 @@ impl AiProviderCallPlan { !self.request.tools.is_empty() } + pub(crate) fn offered_tools(&self) -> &[crate::ModelToolDefinition] { + &self.request.tools + } + pub(crate) fn is_tool_free_initial(&self) -> bool { self.request.tools.is_empty() && self.request.builtin_tools.is_empty() @@ -3808,7 +3950,10 @@ fn valid_provider_call_id(value: &str) -> bool { #[cfg(all(test, feature = "sqlite"))] mod tests { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{ + RwLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }; use super::*; #[cfg(feature = "provider-openai")] @@ -3906,6 +4051,42 @@ mod tests { struct Resolver(AuthPrincipal); + struct BrokerCurrentIndex(Arc); + + impl AiCurrentCapabilityIndex for BrokerCurrentIndex { + fn current_index( + &self, + _run: &AiCapabilityRunBinding, + ) -> Result, AiError> { + Ok(Arc::clone(&self.0)) + } + } + + struct BrokerAuthority { + allowed: AtomicBool, + policy_fingerprint: RwLock, + } + + #[async_trait] + impl AiCapabilityAuthorityPolicy for BrokerAuthority { + async fn authorize( + &self, + _principal: &ResolvedPrincipal, + _owning_index: &AiCapabilityIndex, + _entry: &AiCapabilityIndexEntry, + _run: &AiCapabilityRunBinding, + ) -> Result { + Ok(AiCapabilityAuthorityDecision { + allowed: self.allowed.load(Ordering::SeqCst), + policy_fingerprint: self + .policy_fingerprint + .read() + .map_err(|_| AiError::PersistenceFailed)? + .clone(), + }) + } + } + struct CountingResolver { principal: AuthPrincipal, resolutions: Arc, @@ -9690,6 +9871,445 @@ mod tests { assert_eq!(reservation_state(&fixture.database).await, "committed"); } + fn broker_provider_result( + lease: &AiRunLease, + previous_response_id: Option, + response_id: &str, + call_id: &str, + definition: &ModelToolDefinition, + arguments: serde_json::Value, + ) -> AiProviderCallResult { + let mut result = AiProviderCallResult::test_result( + lease, + previous_response_id, + response_id, + vec![(call_id, definition.tool_id.as_str(), arguments)], + ); + result.request_snapshot.tools = vec![definition.clone()]; + result.tool_calls[0].provider_name = definition.provider_name.clone(); + result.tool_calls[0].tool_fingerprint = definition.fingerprint.clone(); + result + } + + async fn commit_broker_provider_budget( + fixture: &Fixture, + result: AiProviderCallResult, + ) -> AiProviderCallResult { + let now = OffsetDateTime::now_utc(); + let reservation = AiBudgetReservationRecord::insert( + &fixture.database, + CreateAiBudgetReservationRecordInput { + budget_counter_ids: json!([]), + scope_kind: fixture.scope.kind.clone(), + scope_id: fixture.scope.id.clone(), + tenant_id: fixture.scope.tenant_id.clone(), + principal_kind: "user".to_owned(), + principal_subject: fixture.principal.subject().to_owned(), + session_id: fixture.lease.session_id().0, + run_id: fixture.lease.run_id().0, + attempt_id: fixture.lease.attempt_id(), + lease_generation: fixture.lease.lease_generation(), + provider_kind: ProviderKind::OpenAi.as_str().to_owned(), + provider_model: "coordinator-test-model".to_owned(), + reasoning_effort: ModelReasoningEffort::Unspecified.as_str().to_owned(), + pricing_policy_version: "broker-test-pricing-v1".to_owned(), + reserved_input_tokens: 1, + reserved_output_tokens: 1, + reserved_tool_units: 0, + reserved_image_units: 0, + reserved_cost_microunits: 1, + reserved_runs: 1, + actual_input_tokens: Some(1), + actual_cached_input_tokens: Some(0), + actual_output_tokens: Some(1), + actual_tool_units: Some(0), + actual_image_units: Some(0), + actual_cost_microunits: Some(1), + actual_runs: Some(1), + idempotency_key: format!( + "broker-test-provider-turn-{}", + result + .provider_response_id() + .expect("broker result should have a response ID") + ), + state: "committed".to_owned(), + expires_at: (now + Duration::hours(1)).unix_timestamp(), + reconciled_at: Some(now.unix_timestamp()), + }, + ) + .await + .expect("broker test provider budget should commit"); + result.test_with_budget_reservation(AiBudgetReservationId(reservation.id)) + } + + struct BrokerTurnTestHarness<'a> { + fixture: &'a Fixture, + checkpoint_service: &'a OrmAiCoordinatorCheckpointService, + tool_service: &'a OrmAiApplicationToolCallService, + route: &'a AiToolResultEgressRoute, + delivery: &'a AiCapabilityDeliveryTurn, + } + + impl BrokerTurnTestHarness<'_> { + async fn persist_and_execute( + &self, + guard: &mut AiAgentLoopGuard, + lease: &AiRunLease, + provider_result: &AiProviderCallResult, + provider_results: &[&AiProviderCallResult], + ) -> (AiPersistedApplicationToolCall, AiRunLease) { + let AiAgentLoopTurn::ToolCalls { + provider_turn_index, + call_count, + } = guard + .observe_provider_turn(provider_result) + .expect("broker provider turn should bind to the loop") + else { + panic!("broker provider turn must request one tool") + }; + assert_eq!(call_count, 1); + let total_tool_calls = usize::try_from(guard.total_tool_calls()) + .expect("bounded tool-call count should fit usize"); + let tool_calls_before_turn = total_tool_calls + .checked_sub(call_count) + .expect("observed broker calls should include the current turn"); + let (rules, usage) = test_rule_checkpoint( + &self.fixture.scope, + provider_results, + tool_calls_before_turn, + ); + let checkpointed_lease = self + .checkpoint_service + .persist_provider_turn( + lease, + provider_result, + &self.fixture.scope, + "capability-broker-test", + self.route, + &rules, + usage, + guard.provider_turns(), + guard.total_tool_calls(), + ) + .await + .expect("broker provider result should checkpoint"); + let context = AiApplicationToolCallContext::new( + provider_turn_index, + 0, + self.fixture.scope.clone(), + "capability-broker-test", + provider_result + .provider_response_id() + .expect("broker response should have an ID"), + ) + .expect("broker context should validate"); + let persisted = self + .tool_service + .execute_capability_broker_call( + &checkpointed_lease, + provider_result, + context, + self.route.clone(), + self.delivery, + ) + .await + .expect("broker call should have one durable outcome"); + guard + .observe_tool_result(&persisted) + .expect("durable broker result should bind to the provider call"); + let continuation = guard + .continuation() + .expect("one durable broker result should permit continuation"); + let total_tool_calls = usize::try_from(guard.total_tool_calls()) + .expect("bounded tool-call count should fit usize"); + let (_, usage) = + test_rule_checkpoint(&self.fixture.scope, provider_results, total_tool_calls); + let batch_lease = self + .checkpoint_service + .persist_tool_batch( + persisted.lease(), + provider_result, + std::slice::from_ref(&persisted), + &continuation, + &self.fixture.scope, + "capability-broker-test", + self.route, + &rules, + usage, + guard.provider_turns(), + guard.total_tool_calls(), + ) + .await + .expect("broker tool batch should checkpoint"); + let adopted = self + .checkpoint_service + .adopt_tool_batch(&batch_lease) + .await + .expect("broker tool batch should reauthorize") + .expect("broker tool batch should remain adoptable"); + let ready_lease = self + .checkpoint_service + .consume_before_provider(&batch_lease, adopted.checkpoint_id()) + .await + .expect("broker checkpoint should consume exactly once"); + (persisted, ready_lease) + } + } + + fn broker_output(call: &AiPersistedApplicationToolCall) -> &serde_json::Value { + match call + .model_input() + .expect("broker result should be model-visible") + { + ModelInputBlock::ToolResult { output, .. } => output, + _ => panic!("broker result must be a tool result"), + } + } + + #[tokio::test] + async fn fixed_broker_round_trip_is_durable_and_executes_through_the_ordinary_resolver() { + let fixture = fixture(Vec::new()).await; + let (semantics, query_catalog) = generated_query_catalog(); + let capability_index = Arc::new( + AiCapabilityIndex::compile( + GraphqlExecutionTargetId::parse("generated-read-application") + .expect("generated target should validate"), + query_catalog.finished_schema_fingerprint(), + &semantics, + Some(&query_catalog), + None, + None, + [], + "target-policy-v1", + AiCapabilityIndexLimits::default(), + ) + .expect("generated capability index should compile"), + ); + let generated_id = fixture + .generated_query_id + .as_ref() + .expect("fixture should register the generated query"); + let generated_definition = fixture + .runtime + .tool_catalog() + .query_capability_model_definition( + generated_id, + capability_provider_alias(generated_id), + ) + .expect("registered generated query should project"); + let broker = Arc::new( + AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(BrokerCurrentIndex(Arc::clone(&capability_index))), + Arc::new(BrokerAuthority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }), + Arc::new(SystemClock), + Duration::seconds(30), + ) + .expect("capability broker should validate"), + ); + let capability_index_set = AiCapabilityIndexSet::compile([Arc::clone(&capability_index)]) + .expect("capability index set should compile"); + let binding = AiProviderCapabilitySessionBinding::new( + AiCapabilityDeliveryMode::FixedBroker, + capability_index_set.fingerprint(), + BTreeSet::new(), + "test-provider-projection-v1", + "coordinator-test-model", + ModelReasoningEffort::Unspecified, + "f".repeat(64), + ) + .expect("provider capability binding should validate"); + let capabilities = ProviderCapabilities { + custom_tools: true, + capability_delivery_modes: BTreeSet::from([AiCapabilityDeliveryMode::FixedBroker]), + ..ProviderCapabilities::default() + }; + let delivery = AiCapabilityDeliveryTurn::select( + &capabilities, + capability_index_set.fingerprint(), + vec![generated_definition], + true, + binding, + broker, + AiCapabilityBrokerSession::new(AiCapabilityDeliveryLimits::default()) + .expect("broker session should validate"), + ) + .expect("fixed broker delivery should select"); + let definitions = delivery.current_tools(); + let discover_definition = definitions + .iter() + .find(|definition| definition.tool_id == AI_CAPABILITY_DISCOVER_TOOL_ID) + .expect("fixed broker should offer discovery"); + let describe_definition = definitions + .iter() + .find(|definition| definition.tool_id == AI_CAPABILITY_DESCRIBE_TOOL_ID) + .expect("fixed broker should offer describe"); + let execute_definition = definitions + .iter() + .find(|definition| definition.tool_id == AI_CAPABILITY_EXECUTE_TOOL_ID) + .expect("fixed broker should offer execute"); + let route = AiToolResultEgressRoute::new( + "mock-profile", + "local-mock", + AiDestinationTrust::Local, + "continue_capability_broker_result", + "none", + "egress-v1", + ) + .expect("broker result route should validate"); + let checkpoint_service = OrmAiCoordinatorCheckpointService::new( + fixture.run_service.clone(), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(TestRuleResolver::default()), + Arc::new(SystemClock), + AiCoordinatorCheckpointLimits::new(256 * 1_024, Duration::seconds(30)) + .expect("checkpoint limits should validate"), + ); + let tool_service = OrmAiApplicationToolCallService::new( + fixture.run_service.clone(), + fixture.runtime.clone(), + fixture.audit.clone(), + Arc::new(SystemClock), + AiApplicationToolCallLimits::new( + 16_384, + 256 * 1_024, + 8, + 8, + Duration::seconds(30), + Duration::seconds(10), + ) + .expect("tool limits should validate"), + ); + let mut guard = AiAgentLoopGuard::new( + &fixture.lease, + AiAgentLoopLimits::new(8, 8).expect("broker loop limits should validate"), + ); + let broker_turns = BrokerTurnTestHarness { + fixture: &fixture, + checkpoint_service: &checkpoint_service, + tool_service: &tool_service, + route: &route, + delivery: &delivery, + }; + + let discover = commit_broker_provider_budget( + &fixture, + broker_provider_result( + &fixture.lease, + None, + "broker-response-1", + "broker-discover-call", + discover_definition, + json!({ + "text": "reviewed generated record", + "namespace": "generated-read", + "kind": "generated_query", + "entityOrClass": null, + "maximumResults": 1 + }), + ), + ) + .await; + let (discover_call, lease) = broker_turns + .persist_and_execute(&mut guard, &fixture.lease, &discover, &[&discover]) + .await; + let candidate = &broker_output(&discover_call)["candidates"][0]; + assert_eq!(candidate["capabilityId"], generated_id.as_str()); + + let describe = commit_broker_provider_budget( + &fixture, + broker_provider_result( + &lease, + Some("broker-response-1".to_owned()), + "broker-response-2", + "broker-describe-call", + describe_definition, + json!({ + "capabilityId": candidate["capabilityId"], + "candidateFingerprint": candidate["candidateFingerprint"] + }), + ), + ) + .await; + let (describe_call, lease) = broker_turns + .persist_and_execute(&mut guard, &lease, &describe, &[&discover, &describe]) + .await; + let loaded_reference = broker_output(&describe_call)["loadedReference"] + .as_str() + .expect("description should carry an opaque loaded reference") + .to_owned(); + + let execute = commit_broker_provider_budget( + &fixture, + broker_provider_result( + &lease, + Some("broker-response-2".to_owned()), + "broker-response-3", + "broker-execute-call", + execute_definition, + json!({ + "loadedReference": loaded_reference, + "arguments": [{"name": "recordId", "value": "record-42"}], + "selections": ["recordId", "subject"], + "relationshipArguments": [], + "relationshipMaximumItems": [], + "maximumItems": null + }), + ), + ) + .await; + let (execute_call, _lease) = broker_turns + .persist_and_execute( + &mut guard, + &lease, + &execute, + &[&discover, &describe, &execute], + ) + .await; + let execute_output = broker_output(&execute_call); + assert_eq!( + execute_output["data"]["GeneratedRecord"]["recordId"], "record-42", + "unexpected broker execute output: {execute_output}" + ); + assert_eq!(fixture.completed_executions.load(Ordering::SeqCst), 1); + let amplification = delivery.amplification(); + assert_eq!(amplification.discover_calls, 1); + assert_eq!(amplification.describe_calls, 1); + assert_eq!(amplification.execute_calls, 1); + assert_eq!(amplification.total_calls(), 3); + + let rows = AiToolCallRecord::query(fixture.database.pool()) + .filter(AiToolCallRecordWhereInput { + run_id: Some(UuidFilter { + eq: Some(fixture.lease.run_id().0), + ..Default::default() + }), + ..Default::default() + }) + .limit(10) + .fetch_all() + .await + .expect("broker tool rows should load"); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.state == "completed")); + assert_eq!( + rows.iter() + .map(|row| row.tool_id.as_str()) + .collect::>(), + BTreeSet::from([ + AI_CAPABILITY_DISCOVER_TOOL_ID, + AI_CAPABILITY_DESCRIBE_TOOL_ID, + AI_CAPABILITY_EXECUTE_TOOL_ID, + ]) + ); + } + #[tokio::test] async fn mixed_static_and_generated_reads_execute_through_one_fresh_principal_path() { let (_, generated_catalog) = generated_query_catalog(); diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index be9ff6d9..2064c687 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.84.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | -| `graphql-orm-ai-tool-profiles` | `0.7.0` | `crates/graphql-orm-ai-tool-profiles` | none | `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-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) |