diff --git a/Cargo.lock b/Cargo.lock index a730c1a0..f6f372b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,6 +1269,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -2728,6 +2734,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -3179,7 +3194,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.106.1" +version = "0.107.0" dependencies = [ "agql-auth", "async-graphql", @@ -3193,6 +3208,7 @@ dependencies = [ "graphql-orm-storage", "hex", "hmac 0.12.1", + "image", "jsonschema 0.47.0", "reqwest 0.13.4", "rust_decimal", @@ -3211,7 +3227,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.15.0" +version = "0.16.0" dependencies = [ "async-graphql", "async-graphql-parser", @@ -4246,6 +4262,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -5390,6 +5421,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -6772,6 +6813,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -7096,6 +7150,12 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.39.4" @@ -11287,3 +11347,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index fa3625f1..45fbd26f 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.43.1", default-features = false } -graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } +graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.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 e3c769de..926a18b4 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -10,6 +10,22 @@ supersedes: [] # Changelog +## [0.16.0] - 2026-10-13 + +### Changed + +- This pre-1.0 minor is source-breaking for public `AiToolDescriptor` literals: + initialize the new optional `result_media` field or use existing constructors. + Missing media preserves serialized descriptors and their fingerprints. + +### Added + +- Immutable result-media policies bind selected image-reference fields, exact + object paths, logical resolver, confidentiality and finite limits to tool + fingerprints. Secret/NeverExport ancestors and URL/path resolver IDs fail. +- `PreTransportToolSourceUnavailable` retains safe no-dispatch classification + for unavailable protected history after an unstarted reservation is released. + ## [Unreleased] ### Added diff --git a/crates/graphql-orm-ai-tool-profiles/Cargo.toml b/crates/graphql-orm-ai-tool-profiles/Cargo.toml index 9b817e81..75f024f7 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.15.0" +version = "0.16.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 2458a035..4817a865 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -10,6 +10,26 @@ supersedes: [] # Migration Guide +## 0.15.0 to 0.16.0 + +This is a source-breaking pre-1.0 minor release: the public +`AiToolDescriptor` gains the optional `result_media` field. Recompile consumers; +old struct literals must initialize it to `None` or use the existing +constructors. Serialized descriptors without media retain their previous +representation and fingerprints. + +Profiles and descriptors gain an optional `AiToolResultMediaPolicy`. Construct +it from validated field bindings, exact selected object paths and finite limits; +use a logical owner resolver ID, never a URL or path. The declaration is included +in the immutable fingerprint and can only tighten result classification. Missing +policy preserves previous serialization/fingerprints. Consumers using public +struct literals must initialize `result_media` or use existing constructors. + +Media metadata is descriptive and grants neither execution nor disclosure. +Adopt AI 0.107.0 from the same workspace release for the trusted byte resolver, +current source hooks, atomic budgets and typed provider transport. This package +requires no data migration; the owning AI package does require module 0.72.0. + ## 0.14.0 to 0.15.0 Adopt the AI and tool-profile packages from one reviewed workspace release. diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index a5798028..81019c36 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.15.0" } +graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.16.0" } serde_json = "1" ``` @@ -252,3 +252,8 @@ registered or remote binding helpers after independently verifying the owner's authenticated evidence. The binding hash correlates one request/attempt; it never proves remote authenticity or grants authority. Generic transport and resolver failures remain uncertain for consequential operations. + +`AiToolResultMediaPolicy` binds a logical owner resolver, exact selected opaque +image metadata, classification and finite limits to the tool fingerprint. See +the [AI media contract](../graphql-orm-ai/docs/tool-result-media.md); reference +JSON never supplies image bytes or resource authorization. diff --git a/crates/graphql-orm-ai-tool-profiles/src/error.rs b/crates/graphql-orm-ai-tool-profiles/src/error.rs index 4a794832..12b12258 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/error.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/error.rs @@ -48,6 +48,12 @@ pub enum AiError { /// [`Self::ProviderFailed`] and remain recovery-required. #[error("AI provider operation failed before dispatch")] PreTransportProviderFailed, + /// Retained protected tool content became unavailable before provider dispatch. The + /// unstarted reservation was durably released. A coordinator may reset the old cursor and + /// send only independently reopened current human input with a truthful withholding notice; + /// this never authorizes replay of the original effects or the unavailable history. + #[error("retained tool content is unavailable before dispatch")] + PreTransportToolSourceUnavailable, /// Input failed a public schema contract. #[error("invalid AI input: {0}")] InvalidInput(String), @@ -119,6 +125,7 @@ impl AiError { Self::BudgetDenied => "AI_BUDGET_DENIED", Self::PreTransportBudgetDenied => "AI_BUDGET_DENIED", Self::PreTransportProviderFailed => "AI_PROVIDER_FAILED", + Self::PreTransportToolSourceUnavailable => "AI_TOOL_SOURCE_UNAVAILABLE", Self::InvalidInput(_) => "AI_INVALID_INPUT", Self::ResultBudgetExceeded => "AI_RESULT_BUDGET_EXCEEDED", Self::ReauthorizationFailed => "AI_REAUTHORIZATION_FAILED", diff --git a/crates/graphql-orm-ai-tool-profiles/src/lib.rs b/crates/graphql-orm-ai-tool-profiles/src/lib.rs index 867abcd5..1249051d 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/lib.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/lib.rs @@ -16,6 +16,7 @@ mod execution; mod execution_rejection; mod profiles; mod query_plans; +mod tool_media; mod tools; pub use capability_index::*; @@ -26,4 +27,5 @@ pub use execution::*; pub use execution_rejection::*; pub use profiles::*; pub use query_plans::*; +pub use tool_media::*; pub use tools::*; diff --git a/crates/graphql-orm-ai-tool-profiles/src/profiles.rs b/crates/graphql-orm-ai-tool-profiles/src/profiles.rs index 2787e0e2..139eeb5c 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/profiles.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/profiles.rs @@ -15,8 +15,8 @@ use sha2::{Digest, Sha256}; use crate::{ AiApprovalRule, AiBrowserResultPreviewPolicy, AiDisclosureDisposition, AiDisclosureSchema, AiDisclosureShape, AiError, AiGeneratedGraphqlOperationPolicy, AiToolDescriptor, - AiToolOperationDomain, AiToolOperationKind, AiToolRisk, DataClassification, - GraphqlExecutionTargetId, GraphqlOperationContract, ToolMaturity, + AiToolOperationDomain, AiToolOperationKind, AiToolResultMediaPolicy, AiToolRisk, + DataClassification, GraphqlExecutionTargetId, GraphqlOperationContract, ToolMaturity, canonical_json::canonical_json_bytes, }; @@ -416,6 +416,8 @@ pub struct AiGraphqlToolProfile { idempotent: bool, #[serde(default, skip_serializing_if = "Option::is_none")] browser_result_preview: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result_media: Option, } impl AiGraphqlToolProfile { @@ -448,6 +450,7 @@ impl AiGraphqlToolProfile { approval: AiApprovalRule::None, idempotent: true, browser_result_preview: None, + result_media: None, } } @@ -492,6 +495,7 @@ impl AiGraphqlToolProfile { approval: AiApprovalRule::OneShot, idempotent, browser_result_preview: None, + result_media: None, }) } @@ -579,6 +583,14 @@ impl AiGraphqlToolProfile { self } + /// Declares exact selected output-image metadata and one deployment-owned resolver. The + /// compiler validates static disclosure paths and includes this policy in the tool fingerprint. + #[must_use] + pub fn with_result_media(mut self, policy: AiToolResultMediaPolicy) -> Self { + self.result_media = Some(policy); + self + } + /// Returns the stable host-authored profile identifier. pub fn profile_id(&self) -> &str { &self.profile_id @@ -1000,6 +1012,9 @@ impl AiGraphqlToolManifestBuilder { projected_shape, )])); validate_disclosure_shape(&profile.disclosure_schema.root, &response_shape)?; + if let Some(media) = &profile.result_media { + media.validate_disclosure(&profile.disclosure_schema.root)?; + } let operation_name = operation_name( &self.subgraph_id, @@ -1089,6 +1104,10 @@ impl AiGraphqlToolManifestBuilder { Some(policy) => descriptor.with_browser_result_preview(policy), None => descriptor, }; + let descriptor = match profile.result_media { + Some(policy) => descriptor.with_result_media(policy)?, + None => descriptor, + }; Ok(AiGraphqlToolManifestEntry { root_type: profile.root_type, field_name: profile.field_name, @@ -1524,6 +1543,9 @@ fn wrap_list(shape: ProjectedShape, maximum_items: Option) -> ProjectedShap } fn validate_profile_shape(profile: &AiGraphqlToolProfile) -> Result<(), AiError> { + if let Some(media) = &profile.result_media { + media.validate_disclosure(&profile.disclosure_schema.root)?; + } validate_public_token(&profile.profile_id, "profile ID")?; validate_graphql_name(&profile.field_name, "root field")?; validate_model_description(&profile.description, "tool description")?; @@ -1619,6 +1641,9 @@ fn validate_profile_shape(profile: &AiGraphqlToolProfile) -> Result<(), AiError> fn validate_compiled_descriptor(entry: &AiGraphqlToolManifestEntry) -> Result<(), AiError> { let descriptor = &entry.descriptor; + if let Some(media) = &descriptor.result_media { + media.validate_disclosure(&entry.disclosure_schema.root)?; + } let safe_read = entry.root_type == AiGraphqlRootType::Query && descriptor.maturity == ToolMaturity::ReadOnly && descriptor.risk == AiToolRisk::ReadOnly @@ -1643,7 +1668,12 @@ fn validate_compiled_descriptor(entry: &AiGraphqlToolManifestEntry) -> Result<() || descriptor.maximum_result_bytes == 0 || descriptor.maximum_result_records == 0 || descriptor.maximum_classification - != maximum_classification(&entry.disclosure_schema.root)? + != maximum_classification(&entry.disclosure_schema.root)?.max( + descriptor + .result_media + .as_ref() + .map_or(DataClassification::Public, |media| media.classification()), + ) || descriptor.browser_result_preview.is_some_and(|preview| { preview.maximum_bytes > descriptor.maximum_result_bytes || preview.maximum_records > descriptor.maximum_result_records diff --git a/crates/graphql-orm-ai-tool-profiles/src/tool_media.rs b/crates/graphql-orm-ai-tool-profiles/src/tool_media.rs new file mode 100644 index 00000000..221c8d1e --- /dev/null +++ b/crates/graphql-orm-ai-tool-profiles/src/tool_media.rs @@ -0,0 +1,327 @@ +//! Immutable output-image references in reviewed tool projections. These types grant no access. + +use crate::{AiDisclosureDisposition, AiDisclosureShape, AiError, DataClassification}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Closed field mappings in one selected output object. Values are descriptive hints only; +/// the trusted owning resolver must independently match the original protected capture record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiToolImageFieldBindings { + reference: String, + sha256: String, + mime: String, + width: String, + height: String, + captured_at_ms: String, + retained_until_ms: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + action_expires_at_ms: Option, +} + +impl AiToolImageFieldBindings { + /// Binds exact selected scalar field names, with timestamps expressed in Unix milliseconds. + /// + /// # Errors + /// Returns a configuration error for unknown, duplicate or unsafe field names. + #[allow(clippy::too_many_arguments)] + pub fn new( + reference: impl Into, + sha256: impl Into, + mime: impl Into, + width: impl Into, + height: impl Into, + captured_at_ms: impl Into, + retained_until_ms: impl Into, + ) -> Result { + let fields = Self { + reference: reference.into(), + sha256: sha256.into(), + mime: mime.into(), + width: width.into(), + height: height.into(), + captured_at_ms: captured_at_ms.into(), + retained_until_ms: retained_until_ms.into(), + action_expires_at_ms: None, + }; + fields.validate()?; + Ok(fields) + } + /// Adds the original action deadline as a historical label. It never grants image access, + /// renews action freshness or replaces the independently retained image deadline. + /// + /// # Errors + /// Returns a configuration error for an unsafe or duplicate field name. + pub fn with_action_expires_at_ms(mut self, field: impl Into) -> Result { + self.action_expires_at_ms = Some(field.into()); + self.validate()?; + Ok(self) + } + /// Opaque image reference field. + pub fn reference(&self) -> &str { + &self.reference + } + /// Exact content digest field. + pub fn sha256(&self) -> &str { + &self.sha256 + } + /// Detected image MIME field. + pub fn mime(&self) -> &str { + &self.mime + } + /// Pixel width field. + pub fn width(&self) -> &str { + &self.width + } + /// Pixel height field. + pub fn height(&self) -> &str { + &self.height + } + /// Original capture timestamp field, in Unix milliseconds. + pub fn captured_at_ms(&self) -> &str { + &self.captured_at_ms + } + /// Original retained-content deadline field, in Unix milliseconds. + pub fn retained_until_ms(&self) -> &str { + &self.retained_until_ms + } + /// Optional original action deadline field, in Unix milliseconds. + pub fn action_expires_at_ms(&self) -> Option<&str> { + self.action_expires_at_ms.as_deref() + } + fn names(&self) -> Vec<&str> { + let mut fields = vec![ + self.reference(), + self.sha256(), + self.mime(), + self.width(), + self.height(), + self.captured_at_ms(), + self.retained_until_ms(), + ]; + fields.extend(self.action_expires_at_ms()); + fields + } + fn validate(&self) -> Result<(), AiError> { + let fields = self.names(); + if fields.iter().any(|field| !valid_name(field)) + || fields.iter().copied().collect::>().len() != fields.len() + { + return Err(invalid()); + } + Ok(()) + } +} + +/// Independently bounded media limits. A host can narrow these ceilings but not enlarge them +/// through tool arguments, output JSON or an owning resolver's response. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiToolResultMediaLimits { + maximum_images: u32, + maximum_raw_bytes: u64, + maximum_edge: u32, + maximum_pixels: u64, + maximum_retention_ms: u64, + maximum_resolution_ms: u64, +} +impl AiToolResultMediaLimits { + /// Creates limits for one complete result: at most four images, 16 MiB total encoded bytes, + /// 8192 pixels per edge, 16 million total decoded pixels, one-day retention and five-second I/O. + /// + /// # Errors + /// Returns a configuration error for zero or excessive ceilings. + pub fn new( + maximum_images: u32, + maximum_raw_bytes: u64, + maximum_edge: u32, + maximum_pixels: u64, + maximum_retention_ms: u64, + maximum_resolution_ms: u64, + ) -> Result { + let limits = Self { + maximum_images, + maximum_raw_bytes, + maximum_edge, + maximum_pixels, + maximum_retention_ms, + maximum_resolution_ms, + }; + limits.validate()?; + Ok(limits) + } + /// Maximum image count in one complete output. + pub const fn maximum_images(self) -> u32 { + self.maximum_images + } + /// Maximum aggregate encoded image bytes before provider expansion. + pub const fn maximum_raw_bytes(self) -> u64 { + self.maximum_raw_bytes + } + /// Maximum decoded width or height. + pub const fn maximum_edge(self) -> u32 { + self.maximum_edge + } + /// Maximum aggregate decoded pixels. + pub const fn maximum_pixels(self) -> u64 { + self.maximum_pixels + } + /// Maximum original capture-to-retention interval in milliseconds. + pub const fn maximum_retention_ms(self) -> u64 { + self.maximum_retention_ms + } + /// Whole resolution/decode/current-policy deadline in milliseconds. + pub const fn maximum_resolution_ms(self) -> u64 { + self.maximum_resolution_ms + } + fn validate(self) -> Result<(), AiError> { + if !(1..=4).contains(&self.maximum_images) + || !(1..=16 * 1024 * 1024).contains(&self.maximum_raw_bytes) + || !(1..=8192).contains(&self.maximum_edge) + || !(1..=16_000_000).contains(&self.maximum_pixels) + || !(1..=86_400_000).contains(&self.maximum_retention_ms) + || !(1..=5_000).contains(&self.maximum_resolution_ms) + { + return Err(invalid()); + } + Ok(()) + } +} + +/// Static output-media policy fingerprinted with its exact registered tool. Paths contain only +/// selected object fields; no wildcard, URL, filesystem path, storage key or resolver argument. +/// Registration discovers this contract and never authorizes media or provider disclosure. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiToolResultMediaPolicy { + resolver_id: String, + object_paths: Vec>, + fields: AiToolImageFieldBindings, + limits: AiToolResultMediaLimits, + classification: DataClassification, +} +impl AiToolResultMediaPolicy { + /// Binds a deployment-owned logical resolver identity and exact selected output objects. + /// + /// # Errors + /// Returns a configuration error for unsafe identity/path, duplicate paths, excessive limits + /// or Secret classification. Every path must later match the finished disclosure projection. + pub fn new( + resolver_id: impl Into, + object_paths: Vec>, + fields: AiToolImageFieldBindings, + limits: AiToolResultMediaLimits, + classification: DataClassification, + ) -> Result { + let policy = Self { + resolver_id: resolver_id.into(), + object_paths, + fields, + limits, + classification, + }; + policy.validate()?; + Ok(policy) + } + /// Fixed host resolver identity; never a request-selected URL or storage locator. + pub fn resolver_id(&self) -> &str { + &self.resolver_id + } + /// Exact field paths from the disclosure root to selected image metadata objects. + pub fn object_paths(&self) -> &[Vec] { + &self.object_paths + } + /// Immutable selected field mappings. + pub fn fields(&self) -> &AiToolImageFieldBindings { + &self.fields + } + /// Owning immutable complete-result limits. + pub const fn limits(&self) -> AiToolResultMediaLimits { + self.limits + } + /// Static minimum confidentiality of resolved bytes; runtime facts may only tighten it. + pub const fn classification(&self) -> DataClassification { + self.classification + } + /// Validates static shape and hard ceilings, including deserialized metadata. + /// + /// # Errors + /// Returns a configuration error for malformed or excessive policy. + pub fn validate(&self) -> Result<(), AiError> { + self.fields.validate()?; + self.limits.validate()?; + if self.resolver_id.is_empty() + || self.resolver_id.len() > 128 + || !self + .resolver_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || self.object_paths.is_empty() + || self.object_paths.len() > self.limits.maximum_images as usize + || self.object_paths.iter().collect::>().len() != self.object_paths.len() + || self + .object_paths + .iter() + .any(|path| path.len() > 8 || path.iter().any(|name| !valid_name(name))) + || self.classification == DataClassification::Secret + { + return Err(invalid()); + } + Ok(()) + } + /// Requires every metadata field to be an exportable selected scalar beneath exact static + /// object paths. This prevents runtime JSON from inventing a reference projection. + /// + /// # Errors + /// Returns a configuration error for missing paths/fields, lists, non-scalar metadata or a + /// NeverExport/Secret ancestor. Ordinary current source policy is still mandatory. + pub fn validate_disclosure(&self, root: &AiDisclosureShape) -> Result<(), AiError> { + self.validate()?; + for path in &self.object_paths { + let mut node = root; + for name in path { + let AiDisclosureShape::Object { rule, fields } = node else { + return Err(invalid()); + }; + require_exportable(rule.classification, rule.disposition)?; + node = fields.get(name).ok_or_else(invalid)?; + } + let AiDisclosureShape::Object { rule, fields } = node else { + return Err(invalid()); + }; + require_exportable(rule.classification, rule.disposition)?; + for name in self.fields.names() { + let Some(AiDisclosureShape::Scalar { rule }) = fields.get(name) else { + return Err(invalid()); + }; + require_exportable(rule.classification, rule.disposition)?; + } + } + Ok(()) + } +} +fn require_exportable( + classification: DataClassification, + disposition: AiDisclosureDisposition, +) -> Result<(), AiError> { + if classification == DataClassification::Secret + || disposition != AiDisclosureDisposition::Exportable + { + return Err(invalid()); + } + Ok(()) +} +fn valid_name(name: &str) -> bool { + let mut bytes = name.bytes(); + !name.starts_with("__") + && name.len() <= 128 + && matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes.all(|byte| matches!(byte,b'A'..=b'Z'|b'a'..=b'z'|b'0'..=b'9'|b'_')) +} +fn invalid() -> AiError { + AiError::InvalidConfiguration("tool result media policy is invalid".to_owned()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/graphql-orm-ai-tool-profiles/src/tool_media/tests.rs b/crates/graphql-orm-ai-tool-profiles/src/tool_media/tests.rs new file mode 100644 index 00000000..ea87c256 --- /dev/null +++ b/crates/graphql-orm-ai-tool-profiles/src/tool_media/tests.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::{AiDisclosureRule, AiToolDescriptor, AiToolOperationKind}; +use std::collections::BTreeMap; + +fn fields() -> AiToolImageFieldBindings { + AiToolImageFieldBindings::new( + "Reference", + "Sha256", + "Mime", + "Width", + "Height", + "CapturedAtMs", + "RetainedUntilMs", + ) + .unwrap() + .with_action_expires_at_ms("ActionExpiresAtMs") + .unwrap() +} +fn limits() -> AiToolResultMediaLimits { + AiToolResultMediaLimits::new(1, 4 * 1024 * 1024, 1536, 4_000_000, 300_000, 5_000).unwrap() +} +fn policy() -> AiToolResultMediaPolicy { + AiToolResultMediaPolicy::new( + "capture-owner-v1", + vec![vec!["Observation".into()]], + fields(), + limits(), + DataClassification::Restricted, + ) + .unwrap() +} +fn shape() -> AiDisclosureShape { + let rule = AiDisclosureRule::exportable(DataClassification::Restricted); + AiDisclosureShape::Object { + rule, + fields: BTreeMap::from([( + "Observation".into(), + AiDisclosureShape::Object { + rule, + fields: fields() + .names() + .into_iter() + .map(|name| (name.into(), AiDisclosureShape::Scalar { rule })) + .collect(), + }, + )]), + } +} +#[test] +fn exact_exportable_metadata_path_is_required_even_after_deserialization() { + policy().validate_disclosure(&shape()).unwrap(); + for field in fields().names() { + let mut changed = shape(); + let AiDisclosureShape::Object { fields, .. } = &mut changed else { + panic!() + }; + let AiDisclosureShape::Object { fields, .. } = fields.get_mut("Observation").unwrap() + else { + panic!() + }; + fields.remove(field); + assert!(policy().validate_disclosure(&changed).is_err()); + } + let mut wire = serde_json::to_value(policy()).unwrap(); + wire["object_paths"] = serde_json::json!([["Other"]]); + let changed: AiToolResultMediaPolicy = serde_json::from_value(wire).unwrap(); + assert!(changed.validate_disclosure(&shape()).is_err()); +} +#[test] +fn never_export_and_secret_ancestors_cannot_supply_media_metadata() { + for rule in [ + AiDisclosureRule::never_export(DataClassification::Restricted), + AiDisclosureRule::exportable(DataClassification::Secret), + ] { + let mut changed = shape(); + let AiDisclosureShape::Object { rule: root, .. } = &mut changed else { + panic!() + }; + *root = rule; + assert!(policy().validate_disclosure(&changed).is_err()); + } +} +#[test] +fn unsafe_resolvers_duplicate_paths_and_unbounded_limits_are_denied() { + for resolver in ["https://invalid.test", "/private/image", "../image", ""] { + assert!( + AiToolResultMediaPolicy::new( + resolver, + vec![vec!["Observation".into()]], + fields(), + limits(), + DataClassification::Restricted + ) + .is_err() + ); + } + assert!( + AiToolResultMediaPolicy::new( + "owner", + vec![vec![], vec![]], + fields(), + limits(), + DataClassification::Restricted + ) + .is_err() + ); + assert!(AiToolResultMediaLimits::new(5, 1, 1, 1, 1, 1).is_err()); + assert!(AiToolResultMediaLimits::new(1, 16 * 1024 * 1024 + 1, 1, 1, 1, 1).is_err()); + assert!(fields().with_action_expires_at_ms("Reference").is_err()); +} +#[test] +fn static_media_policy_is_bound_to_tool_fingerprint_and_missing_preserves_legacy_bytes() { + let descriptor = AiToolDescriptor::new( + "capture.observe", + "Read one retained observation", + AiToolOperationKind::Query, + "query { Observation { Reference } }", + serde_json::json!({"type":"object"}), + ) + .unwrap(); + assert!( + serde_json::to_value(&descriptor) + .unwrap() + .get("result_media") + .is_none() + ); + let media = descriptor.clone().with_result_media(policy()).unwrap(); + assert_ne!(media.fingerprint, descriptor.fingerprint); + assert!(media.has_valid_fingerprint()); + let mut forged = serde_json::to_value(&media).unwrap(); + forged["result_media"]["resolver_id"] = serde_json::json!("other-owner"); + let forged: AiToolDescriptor = serde_json::from_value(forged).unwrap(); + assert!(!forged.has_valid_fingerprint()); +} diff --git a/crates/graphql-orm-ai-tool-profiles/src/tools.rs b/crates/graphql-orm-ai-tool-profiles/src/tools.rs index 717180a8..9c0d7333 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/tools.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/tools.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - AiError, DataClassification, GraphqlOperationContract, canonical_json::canonical_json_bytes, + AiError, AiToolResultMediaPolicy, DataClassification, GraphqlOperationContract, + canonical_json::canonical_json_bytes, }; /// Stable validated tool identifier. @@ -223,6 +224,10 @@ pub struct AiToolDescriptor { /// stored result to an application frontend. #[serde(default, skip_serializing_if = "Option::is_none")] pub browser_result_preview: Option, + /// Immutable output-image references and trusted host resolver contract. Missing means text + /// only. This is discovery metadata and never current resource or egress authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_media: Option, /// Stable fingerprint over the complete contract. pub fingerprint: String, } @@ -276,6 +281,7 @@ impl AiToolDescriptor { maximum_classification: DataClassification::Internal, idempotent: true, browser_result_preview: None, + result_media: None, fingerprint: String::new(), }; descriptor.refresh_fingerprint(); @@ -350,6 +356,19 @@ impl AiToolDescriptor { self } + /// Declares bounded output images and binds their policy into the complete tool fingerprint. + /// Registration must additionally validate paths against the exact disclosure projection. + /// + /// # Errors + /// Returns a configuration error for invalid static media policy. + pub fn with_result_media(mut self, policy: AiToolResultMediaPolicy) -> Result { + policy.validate()?; + self.maximum_classification = self.maximum_classification.max(policy.classification()); + self.result_media = Some(policy); + self.refresh_fingerprint(); + Ok(self) + } + fn refresh_fingerprint(&mut self) { self.fingerprint.clear(); let encoded = canonical_json_bytes(self); diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 821c0ca9..2cd59dbd 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,53 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.107.0] - 2026-10-13 + +### Changed + +- This pre-1.0 minor is source-breaking: `ProviderRequestContext` loses + `UnwindSafe`/`RefUnwindSafe`, generated provider-session input literals gain + `installed_offering_fingerprint`, and the generated aggregate-field enum + gains a variant while later implicit discriminants shift. Recompile consumers + and follow the explicit migration instructions; current delivery guards and + owned schema module 0.72.0 remain required. + +### Added + +- Keep original static capability subsets in the same broker state, and require + confirmed exact old-thread deletion before a same-run native continuation + installs changed definitions on a fresh thread. The opaque original-task + context is priced and freshly checked; image quota replanning defaults off + and admits at most one proven unstarted reduction without effect replay. + + +- Fingerprint-bound protected tool-result JPEG/PNG media, trusted owner resolvers, + derived original/current disclosure contexts and current source-aware policy. +- Exact tool/profile/destination disclosure boundaries preserve the general + boundary for ordinary/unknown sources and deny Secret. +- Durable protected source retention and bounded native-history admission; + unavailable history can be withheld in a fresh ordinary turn without effects. +- SDK-derived image accounting uses the existing atomic turn reservation; + ambiguous delivery remains uncertain. Codex tool output uses genuine image + provider parts and source capture labels, with final delivery reauthorization. +- Schema module 0.72.0 adds protected source metadata and a private source index. +- Read-only SDK-minted actual effect attempt/generation remain separate from + original provider provenance and current disclosure fencing, including after + approved resume. Missing or changed retained effect evidence is refused. +- Native approved-result wrappers reauthorize their exact protected original source + instead of falling back to the general boundary for Restricted output. +- Retained unapproved source provenance preserves the exact executor authorization + policy/digest alongside its effect fence; current source authorization stays separate. +- Complete inference proofs count retained protected pixels together with ordinary + attachments and their encoded bytes; callback capacity shares the same finite turn. +- Codex protected delivery requires an explicit factory capability and immutable + guarded-delivery registration marker; legacy dynamic launch support cannot opt in. +- Mid-effect scope/session withdrawal preserves the actual bounded protected output + under the live effect fence while refusing source admission and disclosure. +- Unsupported adapters default-deny protected source delivery before Restricted + or media effects. Native no-effect control receipts retain their owning + admission while releasing unused image capacity. + ## [0.106.1] - 2026-10-01 ### Fixed diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 6156495f..1246dc0d 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.106.1" +version = "0.107.0" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" @@ -27,7 +27,7 @@ provider-xai = ["dep:base64", "dep:reqwest"] provider-ollama = ["dep:base64", "dep:reqwest"] provider-openai-compatible = ["dep:base64", "dep:reqwest"] local-harness = [] -provider-codex-app-server = [] +provider-codex-app-server = ["dep:base64"] provider-grok-acp = [] graphql-case-pascal = [ "graphql-orm/resolver-case-pascal", @@ -47,6 +47,7 @@ graphql-orm = { workspace = true } graphql-orm-ai-tool-profiles = { workspace = true } graphql-orm-storage = { workspace = true } hex = "0.4" +image = { version = "=0.25.10", default-features = false, features = ["jpeg", "png"] } hmac = { version = "0.12", optional = true } jsonschema = { version = "0.47", default-features = false } reqwest = { version = "0.13", default-features = false, features = ["http2", "json", "rustls", "stream"], optional = true } diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index bef806aa..c19cfe1e 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,72 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.106.1 to 0.107.0 + +This is a source-breaking pre-1.0 minor release under Cargo's compatibility +rules. Recompile consumers and review these Rust API changes: + +- `ProviderRequestContext` no longer implements `UnwindSafe` or + `RefUnwindSafe`: it retains current delivery guards that can refer to mutable + host policy. Remove assumptions that the complete context can cross a panic + boundary. Any explicit `AssertUnwindSafe` is a caller-reviewed recovery + decision; it does not make the SDK guards panic-safe or authorize delivery. +- Generated `CreateAiProviderSessionBindingRecordInput` and + `UpdateAiProviderSessionBindingRecordInput` literals gain the nullable + `installed_offering_fingerprint` field. Initialize it to `None` in old + literals or use the existing defaults where applicable. The owning lifecycle + service records installed definitions; caller-supplied values prove no + installation, cursor cleanup or permission. +- `AiProviderSessionBindingRecordAggregateField` gains + `InstalledOfferingFingerprint`. Recompile exhaustive matches. Later implicit + discriminants change, so numeric casts from the old enum cannot identify + fields in the new release; use the typed variants and never persist their + integer values as field identities. + +Adopt tool profiles 0.16.0 from the same published workspace release. Existing +non-media tool fingerprints remain unchanged. New media declarations require a +trusted logical resolver, explicit exact provider destination policy and a current +source-aware egress hook. The compatibility defaults deny image delivery and +Restricted-source overrides. Unsupported adapters refuse media-required effects. +The new `AiProvider::supports_tool_result_source_delivery` compatibility default +also denies declared Restricted effects and protected history until an adapter +preserves and awaits the owning guard immediately before delivery. Codex additionally +requires explicit owning-factory attestation and registration opt-in through +`with_guarded_tool_result_source_delivery`, before deriving its immutable capability +binding. Legacy disabled registration fingerprints remain unchanged; old launch +support alone cannot admit protected effects. Completed protected output is saved +under its exact live effect fence before fresh disclosure access is rechecked; +withdrawal denies all source/image delivery without discarding actual completion. + +Original provider provenance getters keep their meaning. Optional effect attempt +and generation getters are minted from the actual executing SDK lease, after +human approval consumption when required. They remain separate from the current +disclosure lease. Absent fields preserve old serialization and fingerprints; +deserialized fields never prove authority. Protected result admission compares +the complete actual effect fence, without stripping it during capture/reopening. + +Apply owned AI schema module 0.72.0 before runtime admission. It adds nullable +`protected_result_source` to tool calls and an internal source-address table; +provider-session bindings additionally gain nullable +`installed_offering_fingerprint`. Metadata and original output remain protected. +Existing rows need no source backfill and gain no additional disclosure +allowance. An unknown installed offering requires verified cursor cleanup +before changed definitions can be admitted. Include the new table in +owned backup/restore and retain the existing closed reconciliation gate. A small +source index is correlation only; reopening always checks protected owner bytes. + +Budget planners must reserve a finite image ceiling for actual offered media +policies. Usage accounting must include the SDK-derived +`tool_result_image_units()` count. A zero image policy remains a truthful denial; +no pricing or user-grant authority is created by this API. Adapters call +`authorize_delivery` immediately before typed image transport. Reopening must +retain original action/capture deadlines and must never repeat effects. + +`PreTransportToolSourceUnavailable` proves an unstarted reservation was released; +it permits a fresh sanitized conversation after cursor cleanup, never replay of +inaccessible output. See the [canonical media contract](docs/tool-result-media.md) +for original/current identity getters, bounds and history disclosure. + ## 0.106.0 to 0.106.1 Provider-session bindings now admit up to 128 static bootstrap fingerprints; @@ -5738,3 +5804,21 @@ content protection and immutable deployment boundaries, and keep the runtime start gate closed until readiness succeeds. PostgreSQL/MSSQL rehearsal must use a disposable Docker-owned database; never point migration commands at a live machine database. + + +### Retained native static offering changes + +Hosts that change an originally bound static subset between native turns must +use the same `AiCapabilityDeliveryTurn`, prepare and price the owning original +task context, and register the exact provider-session deletion adapter with +`with_static_offering_reset_deletion_service`. Installed Codex 0.159.1 requires +confirmed cleanup and a fresh thread to advertise the changed definitions. +Existing fixed offerings retain their prior path. The optional +`replan_unstarted_static_offering` callback defaults to no retry and cannot +change the original provider/model/selection, scope, rules or source proof. +Retain every non-image field from `original_budget_request()` when building its +strictly smaller image quote, and reuse `continuation()` with its already +attached prepared context. The SDK verifies the exact confirmed unstarted +cleanup again before this callback; an error category alone is insufficient. +A host may offer ordinary semantic tools at zero image quota; no retained +screenshot or completed native effect is replayed to manufacture capacity. diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 6a9c65c0..9010f848 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -10,6 +10,13 @@ supersedes: [] # graphql-orm-ai +Version `0.107.0` adds protected JPEG/PNG tool-result content and fenced +static-tool offering changes for retained native runs. Completed effects remain +durable; current source authorization, atomic image budgets and final transport +guards govern redisclosure. Apply AI schema module `0.72.0` before starting the +new runtime. See [protected tool-result media](docs/tool-result-media.md) and +[MIGRATION.md](MIGRATION.md) for host admission and restore requirements. + Version `0.106.1` admits up to 128 exact static bootstrap fingerprints and Grok SDK definitions, matching the existing Codex definition ceiling. Hosts with larger reviewed tool catalogues no longer fail provider-session admission @@ -561,3 +568,7 @@ disabled by default, and expires entries after at most seven days; see [MIGRATIO For concurrent chats with immutable provider/model/effort choices, use the [session execution selection contract](docs/session-execution-selection.md). + +Registered tools can declare protected retained image references with current +source authorization and atomic image budgets. See the [tool-result media +contract](docs/tool-result-media.md) for host resolver and provider integration. diff --git a/crates/graphql-orm-ai/docs/README.md b/crates/graphql-orm-ai/docs/README.md index 90b7e8a3..3d2941d5 100644 --- a/crates/graphql-orm-ai/docs/README.md +++ b/crates/graphql-orm-ai/docs/README.md @@ -34,6 +34,8 @@ tool-free, and network-free; production integration comes later. ## Reference +- [Protected tool-result images and source disclosure](tool-result-media.md). + - [Attachments](attachments.md), [provider files](provider-files.md), [live streaming](live-streaming.md), [context compaction](context-compaction.md), [skills and UI intents](skills-and-ui-intents.md), and [remote GraphQL execution](remote-graphql-execution.md). - [Capability index, compact planning, durable broker and conversation bootstrap](capability-discovery-and-execution.md). - [Immutable session execution selection](session-execution-selection.md) — owner admission, lease-fenced routing, exact legacy pinning and title isolation. diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md new file mode 100644 index 00000000..83d8ce3e --- /dev/null +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -0,0 +1,225 @@ +--- +title: "Protected tool-result images and source disclosure" +kind: reference +status: active +owner: graphql-orm-ai-maintainers +last_reviewed: 2026-10-13 +review_by: 2027-02-01 +supersedes: [] +--- + +# Protected tool-result images and source disclosure + +A registered tool can return an opaque reference to an image held by its owning +application. Reference JSON is descriptive metadata. It does not provide image +content or prove authorization. Provider delivery uses verified JPEG/PNG bytes +as typed image content, separately from the tool's JSON text. + +## Static contract + +Attach `AiToolResultMediaPolicy` to a compiled `AiGraphqlToolProfile` or registered +`AiToolDescriptor`. Its logical resolver ID, exact result-object paths, selected +metadata fields, classification and limits enter the immutable tool fingerprint. +Paths have no URL, filesystem path, wildcard or list traversal. Metadata fields +must be selected exportable scalars; a Secret or NeverExport ancestor cannot +be made exportable by declaring media. + +`AiToolImageFieldBindings` names the opaque UUID, SHA-256, MIME type, width, +height, capture time and original retention deadline. An optional action deadline +is a historical label. Retention and action freshness are independent: reopening +an image never refreshes action evidence. No image requires a null reference and +null digest/MIME/dimensions; mixed absent-reference/non-null-image metadata fails. + +Policy limits bound one through four images, at most 16 MiB total raw bytes, +8192 pixels per edge, 16 million aggregate decoded pixels per complete result, +original retention at most one +day, and one resolution pass at most five seconds. Hosts should choose narrower +limits. Actual digest, codec and decoded dimensions are checked. Asynchronous +decode uses a finite two-worker pool, refuses a busy queue and discards timed-out +results while retaining the permit until bounded work ends. + +## Trusted owner and current source policy + +Hosts use `AiToolResultDisclosureContext::binds_egress_source` to match the SDK's +original text/image source encoding without recreating it. This is correlation, +not an allow proof: actual current source authorization, decoded bytes and atomic +budgets remain mandatory. Ordinary, unknown and mixed unproven sources retain +the host's unchanged general policy. + +Register `AiRuntimeBuilder::tool_result_media_resolver` with an +`AiToolResultMediaResolver`. It receives the freshly resolved principal, exact +registered descriptor, SDK-derived `AiToolResultDisclosureContext` and exact +retained image reference. The host opens its own protected resource and returns +`AiResolvedToolImage::decode_bounded`. It must independently compare its actual +owning records with the original invocation, principal/tenant, session/run/call, +provider profile/model/registration, argument and tool fingerprints, original +effect attempt/generation, digest and resource identity. Caller JSON, provider +URLs and path names cannot select resource authority. + +The context has no public constructor or deserializer. Original effect identity +and current disclosure attempt/generation are separate. It contains safe +principal references, never bearer credentials. A retained provider cursor digest +is captured from the owning admitted binding before effect admission, rather than +being relabelled from a later rebind. Images and original output are reopened +under current protection, access and uncancelled run fences; no effect is rerun. + +`AiToolExecutionProvenance::attempt_id` and `lease_generation` name the original +provider admission. Optional `effect_attempt_id` and `effect_lease_generation` +are minted only by the owning SDK from the actual executing lease immediately +before invocation, after approval consumption where required. Retention also keeps +exact authorization policy/digest returned by the completed trusted runtime, so +its provenance matches the actual admitted executor binding. They remain fixed +in the captured and protected result, and must match the separate retained effect +fence when reopened. Missing or deserialized claims grant no effect authority. +Old absent-field serialization remains unchanged. + +Override `AiEgressPolicy::authorize_tool_sources` to check current grants and +resource policy for ToolResult, ImageAnalysis and later ModelInference/history. +Its default denies images and Restricted sources. A narrow +`AiToolResultEgressBoundary` may admit one exact static tool fingerprint, selected +provider profile and destination. General boundaries still govern ordinary, +unknown and mixed sources. Secret remains denied; scoped policies cannot multiply +aggregate request bytes or image counts. The current source hook intersects the +static boundary on every disclosure. + +## Durable results, provider delivery and budgets + +The application service protects and saves the actual bounded completed-effect +result before fresh scope/session disclosure authorization, parsing descriptive image +metadata or opening resources. Losing disclosure access preserves the original +completed output, while the exact live executing owner lease remains required +for custody; cancelled or obsolete leases cannot revive a result. Invalid +metadata preserves the saved output for owning recovery and denies disclosure; it does +not replace the completed output or authorize effect replay. Source metadata is +then admitted and protected before disclosure. Native approved-result wrappers +reauthorize the original protected source with the current source hook before +checkpointing their truthful completed status; ordinary wrappers keep the general +policy. Owner cancellation also closes +source-retained unfinished calls while preserving their original saved output. +A small internal source-address index bounds retained +native-history lookup to 64 exact cursor sources; it grants no authorization and +is never exposed as public CRUD. Owner loaders reopen the protected call, +arguments and result, compare full provenance and canonical checksums, and +require the current registered disclosure schema. Source-retained results remain +available for checked reopening even when first disclosure fails. No fallback +re-executes an effect. + +`AiProviderCallExecutor::with_tool_result_source_service` binds the owning source +loader to the same runtime. Dynamic execution supplies the same owning service. +Media-required tools fail adapter/policy/capacity preflight before effect +admission. Dynamic dispatch also checks exact registered definition fingerprints and +the adapter's protected-result capability before launching an offered protected tool. +Adapters default-deny source delivery for declared +Restricted/media effects unless they implement the final current source guard; +Codex requires both an explicit factory `supports_tool_result_source_delivery` +attestation and `AiCodexAppServerRegistration::with_guarded_tool_result_source_delivery`. +The enabled-only registration marker invalidates old capability-session bindings; +derive and admit the exact new binding afterward. Legacy text/dynamic-tool factories +remain compatible but cannot admit protected effects. The factory must preserve +both final input and responder guards through every queue. +No-effect native approval/control receipts contain no images and release their +unused media allocation while retaining ordinary protected receipt admission. +The existing atomic provider-turn reservation allocates image units +before callbacks. Callbacks share one count and 16 MiB raw-byte ceiling, including +ordinary attachment capacity already consumed in that provider turn. Reopened +history and ordinary attachments are checked as one complete request against +existing attachment limits and the image quote. ModelInference admission includes +the exact protected image sources, combined attachment count and conservative +base64/frame byte overhead; no separate image proof can multiply those limits. Unused +static capacity is released only after a definite completed result; ambiguous +provider delivery keeps the reservation uncertain. Settlement must include +`AiProviderUsageObservation::tool_result_image_units()`; provider-reported zero +cannot erase SDK-derived image usage. + +Adapters await `ProviderRequestContext::authorize_tool_source_delivery` after +preparation/queueing and immediately before model/history transport. Its owning +callback is privately installed after actual source/body admission and reopens +current grants, resource bytes and run fences. The privately derived +`retained_tool_result_images()` getter contains actual reopened original pixels +for typed continuation input, rather than assuming the provider retained a +previous successful image delivery. Codex turn input carries the same delivery +check through the process queue; hosts await its `authorize_tool_source_delivery` +immediately before `turn/start`. A general/ordinary denial remains +closed rather than being reclassified as unavailable history. + +Adapters await `ProviderDynamicToolResponder::authorize_delivery` immediately +before encoding/sending the exact result. Its compatibility default refuses +images. The owning implementation freshly reopens the retained output, source, +resource and bytes and checks current consent/cancellation. The experimental +Codex transport emits real `inputImage` items from verified inline bytes plus +capture/deadline labels; base64 is never inserted into result JSON/text and no +source URL is fetched. Other adapters fail closed until they explicitly implement +reviewed image-policy and typed-content support. + +Retained history preserves original source classification and current-grant +checks, including after remote thread reuse. Current proof and image reopening +have a finite whole-pass deadline. If historical content expires or loses +admission before dispatch, the unstarted reservation is released, the old cursor +requires cleanup, and a fresh tool-free turn can contain independently reopened +current human input, ordinary owned attachments and a truthful withholding +notice. Prior model text, tree JSON and tool output are removed; saved effect +status remains durable. Restoring or historical reopening does not renew grants, +action freshness or media retention. + +## Compatibility and schema + +Use AI 0.107.0 and tool profiles 0.16.0 from one reviewed workspace release. +Absent media policy serializes identically to the previous descriptor format; +media declarations change its fingerprint. Schema module 0.72.0 adds protected +source metadata and the internal source-address table. Existing results without +source metadata retain the general disclosure path and gain no scoped allowance. +Apply the owned AI schema migration and restore reconciliation before opening the +runtime. See the [migration guide](../MIGRATION.md), [attachments](attachments.md) +and [recovery contract](recovery-and-restore.md). + +## Static offering changes across retained native waits + +`AiCapabilityDeliveryTurn::set_static_bootstrap_offering` selects only static +fingerprints in the original immutable session binding. It keeps the same +shared broker, loaded definitions and index. Obtain the current surface before +freezing the next plan. A changed offering is refused while any plan clone still +holds that surface; restoring the same definitions does not validate an older +surface generation. Deferred reinstall preserves the selected static subset. + +Codex app-server 0.159.1 advertises dynamic tools at `thread/start`. Its retained +`thread/resume` and `turn/start` do not offer a replacement definition set. +Consequently the SDK requires its registered exact deletion adapter through +`with_static_offering_reset_deletion_service`, confirmed absence of the old +cursor, and a dedicated fenced rebind before starting a new thread. This keeps +the same live run, lease, provider, model, selection, registration, settled +budgets and actual saved effect. Cleanup uncertainty requires recovery; it +admits neither another cursor nor input. Generic rebind reasons are unchanged. + +The owning application service prepares an opaque original-task continuation +context with actual original message/event identity and saved invocation +statuses. A later queued user message cannot substitute for that original task. +The host prices `conservative_input_bytes`/`conservative_input_tokens` and +consumes the same prepared context through `with_prepared_task_context` before +constructing the final plan. User goal text stays untrusted user context. +Reopening does not append a human event, renew consent or action freshness, +synthesize a result, or repeat an effect. Every newly disclosed byte, token and +image remains subject to the fresh atomic quote and current ordinary/source +policy; task mutation, purge, withdrawal or cancellation refuses disclosure. + +`AiSupervisedAgentTurnPlanner::replan_unstarted_static_offering` defaults to no +retry. Only an SDK-proven unstarted native image reservation denial can invoke +it, after the exact empty thread is confirmed absent and SDK plan clones have +been released. The callback receives the same opaque continuation/context and +shared delivery state. Its replacement must be a strict subset with a smaller +image quote, unchanged source, provider/model/selection, scope, rules and +registration, and a fresh content-bound budget key. The SDK accepts at most one +such retry. Its `original_budget_request()` is the SDK-owned failed quote: keep +all non-image monetary, input, output, time, tool, rule, resource and destination +fields unchanged, reduce image units, and use a new content-bound idempotency +key. A pricing error alone cannot invoke the callback; the owning service must +freshly verify the exact SDK-marked confirmed deletion record before exposing +this opportunity. Another denial remains terminal. + +Saved historical content that is no longer admitted is withheld through another +confirmed deletion and a fresh tool-free cursor. That turn sends only the same +prepared original task and actual saved statuses under current ordinary policy; +it sends no old tool payload, tree, image or approved-outcome JSON. Actual newly +admitted retained pixels determine allocation and settlement even when the +replacement cursor has no prior history rows. They must still fit the atomic +image quote before transport. An old screenshot cannot renew an observation or +authorize input. diff --git a/crates/graphql-orm-ai/src/agent_loop.rs b/crates/graphql-orm-ai/src/agent_loop.rs index efd3b1e9..76fba9f8 100644 --- a/crates/graphql-orm-ai/src/agent_loop.rs +++ b/crates/graphql-orm-ai/src/agent_loop.rs @@ -66,6 +66,50 @@ pub struct AiAgentContinuation { transfers: Vec, replay_transfers: Vec, native_outcome: bool, + pub(crate) native_reset_evidence: + Option>, + pub(crate) task_context: Option>, +} + +/// Read-only owning projection of an original task and actual saved invocation statuses. +/// +/// Fields, original message/event identities and source proof are opaque. It has no public +/// constructor/deserializer and grants no reset, cursor, approval, input effect or resource +/// authority. The host prices its contribution before consuming this same value into an +/// independently verified continuation; all content is freshly checked before dispatch. +#[derive(Clone)] +pub struct AiPreparedTaskContinuationContext { + pub(crate) session_id: AiSessionId, + pub(crate) run_id: crate::AiRunId, + pub(crate) attempt_id: uuid::Uuid, + pub(crate) lease_generation: i64, + pub(crate) frame: ModelInputBlock, + pub(crate) sources: Vec, + pub(crate) source_fingerprint: String, + pub(crate) bytes: u64, +} + +impl std::fmt::Debug for AiPreparedTaskContinuationContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AiPreparedTaskContinuationContext") + .field("bytes", &self.bytes) + .field("source", &"[REDACTED]") + .finish() + } +} + +impl AiPreparedTaskContinuationContext { + /// Conservative serialized contribution of the SDK-owned context block to input bytes. + pub const fn conservative_input_bytes(&self) -> u64 { + self.bytes + } + + /// Conservative byte-sized token quote for the complete context block. + /// The host must also price the remainder of its actual final inference request. + pub const fn conservative_input_tokens(&self) -> u64 { + self.bytes + } } pub(crate) struct AiStatelessToolEvidence { @@ -86,6 +130,60 @@ pub(crate) struct AiStatelessConversationEvidence { } impl AiAgentContinuation { + /// Conservative serialized bytes of all saved continuation input plus prepared task context. + /// + /// This is a read-only contribution for host pricing; the complete request must additionally + /// include provider framing, instructions, offered definitions and newly admitted images. + /// + /// # Errors + /// Returns [`AiError::Conflict`] when serialization or checked size accumulation fails. + pub fn conservative_input_bytes(&self) -> Result { + self.input + .iter() + .chain(self.task_context.iter().map(|context| &context.frame)) + .try_fold(0_u64, |sum, block| { + let bytes = serde_json::to_vec(block).map_err(|_| AiError::Conflict)?; + sum.checked_add(u64::try_from(bytes.len()).map_err(|_| AiError::Conflict)?) + .ok_or(AiError::Conflict) + }) + } + + /// Conservative byte-sized token contribution for host pricing of the saved input/context. + /// + /// # Errors + /// Returns the same bounded serialization errors as [`Self::conservative_input_bytes`]. + pub fn conservative_input_tokens(&self) -> Result { + self.conservative_input_bytes() + } + + /// Consumes the exact prepared owning context into one verified native continuation. + /// + /// Call before pricing and freezing the next provider plan. The original task remains + /// untrusted user content; completed statuses cannot become execution instructions. + /// A changed retained offering still requires verified cleanup and dedicated rebind. + /// + /// # Errors + /// Returns [`AiError::Conflict`] for absent verified checkpoint evidence, stale or swapped + /// session/run/attempt/generation, or an already attached context. + pub fn with_prepared_task_context( + mut self, + context: AiPreparedTaskContinuationContext, + ) -> Result { + let evidence = self + .native_reset_evidence + .as_ref() + .ok_or(AiError::Conflict)?; + if self.task_context.is_some() + || evidence.session_id != context.session_id + || evidence.run_id != context.run_id + || evidence.attempt_id != context.attempt_id + || evidence.lease_generation != context.lease_generation + { + return Err(AiError::Conflict); + } + self.task_context = Some(std::sync::Arc::new(context)); + Ok(self) + } pub(crate) fn from_native_approved_outcome( continuation: ModelContinuation, reasoning_effort: ModelReasoningEffort, @@ -99,6 +197,8 @@ impl AiAgentContinuation { transfers: vec![transfer], replay_transfers: Vec::new(), native_outcome: true, + native_reset_evidence: None, + task_context: None, }; Self::from_checkpoint_value(candidate.checkpoint_value()) } @@ -123,6 +223,8 @@ impl AiAgentContinuation { transfers: vec![transfer], replay_transfers, native_outcome: false, + native_reset_evidence: None, + task_context: None, }; Self::from_checkpoint_value(candidate.checkpoint_value()) } @@ -163,6 +265,8 @@ impl AiAgentContinuation { transfers, replay_transfers, native_outcome: false, + native_reset_evidence: None, + task_context: None, }; Self::from_checkpoint_value(candidate.checkpoint_value()) } @@ -279,6 +383,8 @@ impl AiAgentContinuation { transfers: snapshot.transfers, replay_transfers: snapshot.replay_transfers, native_outcome: snapshot.format_version == 4, + native_reset_evidence: None, + task_context: None, }; if let Some(evidence) = continuation.stateless_evidence()? { let historical_count = evidence @@ -462,6 +568,18 @@ impl AiAgentContinuation { pub(crate) fn apply_with_transfers( self, request: &mut ModelRequest, + ) -> Result, AiError> { + if self.task_context.is_some() { + return Err(AiError::Conflict); + } + self.apply_with_bound_task_context(request) + } + + // Only the owning capability-bound plan builder retains task-context provenance and its + // final source guard. Other public continuation constructors cannot discard that evidence. + pub(crate) fn apply_with_bound_task_context( + self, + request: &mut ModelRequest, ) -> Result, AiError> { if !request.input.is_empty() || request.continuation.is_some() { return Err(AiError::Conflict); @@ -483,6 +601,9 @@ impl AiAgentContinuation { } request.continuation = Some(self.continuation); request.input = self.input; + if let Some(context) = self.task_context { + request.input.push(context.frame.clone()); + } let mut transfers = self.replay_transfers; transfers.extend(self.transfers); Ok(transfers) @@ -613,8 +734,10 @@ impl AiAgentLoopGuard { || result.run_id() != self.run_id || result.attempt_id() != self.attempt_id || result.lease_generation() != self.lease_generation - || result.previous_continuation_reference() + || (result.previous_continuation_reference() != self.expected_previous_reference.as_deref() + && !result + .proves_static_offering_reset_from(self.expected_previous_reference.as_deref())) || self.provider_turns >= self.limits.maximum_provider_turns { return Err(AiError::Conflict); @@ -759,6 +882,8 @@ impl AiAgentLoopGuard { transfers, replay_transfers: std::mem::take(&mut self.replay_transfers), native_outcome: false, + native_reset_evidence: None, + task_context: None, }; self.expected_previous_reference = Some(next.chain_reference()?); Ok(next) diff --git a/crates/graphql-orm-ai/src/capability_delivery.rs b/crates/graphql-orm-ai/src/capability_delivery.rs index e3b12ada..24b77010 100644 --- a/crates/graphql-orm-ai/src/capability_delivery.rs +++ b/crates/graphql-orm-ai/src/capability_delivery.rs @@ -130,10 +130,58 @@ impl AiCapabilityDeliveryDecision { } /// Exact initial tool surface produced for one coordinator-selected mode. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct AiCapabilityDeliverySurface { mode: AiCapabilityDeliveryMode, tools: Vec, + offering: Option, +} + +#[derive(Debug)] +struct AiCapabilityOfferingState { + tools: Vec, + static_fingerprints: BTreeSet, + generation: u64, + frozen_plans: usize, +} + +#[derive(Clone, Debug)] +struct AiCapabilityOfferingSnapshot { + state: Arc>, + generation: u64, +} + +impl PartialEq for AiCapabilityDeliverySurface { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode && self.tools == other.tools + } +} + +#[derive(Clone, Debug)] +pub(crate) struct AiCapabilityOfferingPlanGuard(Arc); + +#[derive(Debug)] +struct AiCapabilityOfferingPlanFreeze { + state: Arc>, + generation: u64, +} + +impl Drop for AiCapabilityOfferingPlanFreeze { + fn drop(&mut self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.frozen_plans = state.frozen_plans.saturating_sub(1); + } +} + +impl AiCapabilityOfferingPlanGuard { + #[cfg_attr(feature = "mssql", allow(dead_code))] + pub(crate) fn validate(&self) -> Result<(), AiError> { + let state = self.0.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.generation != self.0.generation { + return Err(AiError::Conflict); + } + Ok(()) + } } impl AiCapabilityDeliverySurface { @@ -150,6 +198,26 @@ impl AiCapabilityDeliverySurface { pub fn tools(&self) -> &[ModelToolDefinition] { &self.tools } + + pub(crate) fn freeze_offering(&self) -> Result, AiError> { + let Some(snapshot) = &self.offering else { + return Ok(None); + }; + let mut state = snapshot + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + if state.generation != snapshot.generation || state.tools != self.tools { + return Err(AiError::Conflict); + } + state.frozen_plans = state.frozen_plans.checked_add(1).ok_or(AiError::Conflict)?; + Ok(Some(AiCapabilityOfferingPlanGuard(Arc::new( + AiCapabilityOfferingPlanFreeze { + state: snapshot.state.clone(), + generation: snapshot.generation, + }, + )))) + } } /// Builds the exact initial capability surface after host filtering. @@ -218,6 +286,7 @@ pub fn prepare_capability_delivery_surface( Ok(AiCapabilityDeliverySurface { mode: decision.mode, tools, + offering: None, }) } @@ -2019,7 +2088,7 @@ pub struct AiCapabilityDeliveryTurn { session: AiCapabilityBrokerSession, #[cfg_attr(feature = "mssql", allow(dead_code))] static_bootstrap_tools: Arc>, - tools: Arc>>, + tools: Arc>, } impl std::fmt::Debug for AiCapabilityDeliveryTurn { @@ -2088,6 +2157,10 @@ impl AiCapabilityDeliveryTurn { tools.extend(static_bootstrap_tools.iter().cloned()); tools.sort_by(|left, right| left.tool_id.cmp(&right.tool_id)); } + let static_fingerprints = static_bootstrap_tools + .iter() + .map(|definition| definition.fingerprint.clone()) + .collect(); Ok(Self { mode: surface.mode(), index_fingerprint: index_fingerprint.to_owned(), @@ -2095,7 +2168,12 @@ impl AiCapabilityDeliveryTurn { broker, session, static_bootstrap_tools: Arc::new(static_bootstrap_tools), - tools: Arc::new(Mutex::new(tools)), + tools: Arc::new(Mutex::new(AiCapabilityOfferingState { + tools, + static_fingerprints, + generation: 0, + frozen_plans: 0, + })), }) } @@ -2134,6 +2212,7 @@ impl AiCapabilityDeliveryTurn { self.tools .lock() .unwrap_or_else(PoisonError::into_inner) + .tools .clone() } @@ -2145,12 +2224,75 @@ impl AiCapabilityDeliveryTurn { /// turn passed by the coordinator rather than retaining an earlier surface /// or reconstructing one from public fields. pub fn current_surface(&self) -> AiCapabilityDeliverySurface { + let state = self.tools.lock().unwrap_or_else(PoisonError::into_inner); AiCapabilityDeliverySurface { mode: self.mode, - tools: self.current_tools(), + tools: state.tools.clone(), + offering: Some(AiCapabilityOfferingSnapshot { + state: self.tools.clone(), + generation: state.generation, + }), } } + pub(crate) fn require_unfrozen_static_offering(&self) -> Result<(), AiError> { + let state = self.tools.lock().unwrap_or_else(PoisonError::into_inner); + if state.frozen_plans != 0 { + return Err(AiError::Conflict); + } + Ok(()) + } + + /// Selects the static bootstrap definitions offered on the next provider turn. + /// + /// Only fingerprints in the original immutable session binding may be selected. The + /// same broker, loaded definitions and shared run state survive reduction or reoffering. + /// This is disclosure configuration, not execution authority. Call before constructing + /// the next plan; existing plans freeze their offered set until all plan clones are dropped. + /// Retained providers require the SDK's confirmed cleanup and fresh-thread transition + /// before a changed set can be installed remotely. + /// + /// # Errors + /// Returns [`AiError::Forbidden`] for unsupported modes or unknown fingerprints and + /// [`AiError::Conflict`] when a changed set would alter a frozen plan. + pub fn set_static_bootstrap_offering( + &self, + selected_fingerprints: &BTreeSet, + ) -> Result<(), AiError> { + if !matches!( + self.mode, + AiCapabilityDeliveryMode::FixedBroker | AiCapabilityDeliveryMode::ClientDeferred + ) || !selected_fingerprints + .is_subset(self.session_binding.static_bootstrap_tool_fingerprints()) + { + return Err(AiError::Forbidden); + } + let mut state = self.tools.lock().unwrap_or_else(PoisonError::into_inner); + if state.static_fingerprints == *selected_fingerprints { + return Ok(()); + } + if state.frozen_plans != 0 { + return Err(AiError::Conflict); + } + let generation = state.generation.checked_add(1).ok_or(AiError::Conflict)?; + let all_static = self.session_binding.static_bootstrap_tool_fingerprints(); + state + .tools + .retain(|definition| !all_static.contains(&definition.fingerprint)); + state.tools.extend( + self.static_bootstrap_tools + .iter() + .filter(|definition| selected_fingerprints.contains(&definition.fingerprint)) + .cloned(), + ); + state + .tools + .sort_by(|left, right| left.tool_id.cmp(&right.tool_id)); + state.static_fingerprints = selected_fingerprints.clone(); + state.generation = generation; + Ok(()) + } + /// 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(); @@ -2210,12 +2352,26 @@ impl AiCapabilityDeliveryTurn { exact_definitions, self.session.limits(), )?; + let mut current = self.tools.lock().unwrap_or_else(PoisonError::into_inner); + if current.frozen_plans != 0 { + return Err(AiError::Conflict); + } + let generation = current.generation.checked_add(1).ok_or(AiError::Conflict)?; let mut tools = vec![discovery_definition(&self.index_fingerprint)]; - tools.extend(self.static_bootstrap_tools.iter().cloned()); + tools.extend( + self.static_bootstrap_tools + .iter() + .filter(|definition| { + current + .static_fingerprints + .contains(&definition.fingerprint) + }) + .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); + current.tools.clone_from(&tools); + current.generation = generation; self.session.complete_deferred_installation(); Ok(tools) } @@ -2771,6 +2927,38 @@ mod tests { .iter() .any(|definition| { definition.tool_id == AI_CAPABILITY_DISCOVER_TOOL_ID }) ); + let clone = delivery.clone(); + assert!(delivery.shares_run_state(&clone)); + assert!(matches!( + clone.set_static_bootstrap_offering(&BTreeSet::from(["0".repeat(64)])), + Err(AiError::Forbidden) + )); + assert!(delivery.matches_offered_tools(surface.tools())); + let frozen = surface.freeze_offering().unwrap().unwrap(); + let frozen_clone = frozen.clone(); + assert!(matches!( + clone.set_static_bootstrap_offering(&BTreeSet::new()), + Err(AiError::Conflict) + )); + drop(frozen); + assert!( + clone + .set_static_bootstrap_offering(&BTreeSet::new()) + .is_err() + ); + drop(frozen_clone); + clone + .set_static_bootstrap_offering(&BTreeSet::new()) + .unwrap(); + assert_eq!(delivery.current_tools().len(), 3); + assert!(delivery.shares_run_state(&clone)); + assert!(matches!(surface.freeze_offering(), Err(AiError::Conflict))); + delivery + .set_static_bootstrap_offering(&BTreeSet::from([static_tool.fingerprint.clone()])) + .unwrap(); + assert_eq!(delivery.current_tools(), surface.tools()); + // Restoring the same definitions does not revive a stale snapshot generation. + assert!(matches!(surface.freeze_offering(), Err(AiError::Conflict))); } #[test] @@ -3168,10 +3356,11 @@ mod tests { .expect("index set") .fingerprint() .to_owned(); + let static_tool = definition("preview.semantic"); let binding = AiProviderCapabilitySessionBinding::new( AiCapabilityDeliveryMode::ClientDeferred, &index_fingerprint, - BTreeSet::new(), + BTreeSet::from([static_tool.fingerprint.clone()]), "provider-projection-v1", "gpt-test", ModelReasoningEffort::Unspecified, @@ -3187,13 +3376,16 @@ mod tests { ..ProviderCapabilities::default() }, &index_fingerprint, - Vec::new(), + vec![static_tool.clone()], false, binding, broker.clone(), session.clone(), ) .expect("client-deferred delivery"); + delivery + .set_static_bootstrap_offering(&BTreeSet::new()) + .unwrap(); let value = broker .dispatch_client_deferred_discover( &principal_reference, @@ -3226,6 +3418,20 @@ mod tests { .expect("exact loaded definition should install"); assert_eq!(delivery.current_tools().len(), 2); assert!(!delivery.requires_deferred_installation()); + let loaded_tools = delivery.current_tools(); + delivery + .set_static_bootstrap_offering(&BTreeSet::from([static_tool.fingerprint.clone()])) + .unwrap(); + assert_eq!(delivery.current_tools().len(), 3); + assert!( + loaded_tools + .iter() + .all(|tool| delivery.current_tools().contains(tool)) + ); + delivery + .set_static_bootstrap_offering(&BTreeSet::new()) + .unwrap(); + assert_eq!(delivery.current_tools(), loaded_tools); authority.allowed.store(false, Ordering::SeqCst); let empty = broker diff --git a/crates/graphql-orm-ai/src/egress.rs b/crates/graphql-orm-ai/src/egress.rs index 420e3336..58dbeaab 100644 --- a/crates/graphql-orm-ai/src/egress.rs +++ b/crates/graphql-orm-ai/src/egress.rs @@ -240,6 +240,33 @@ pub trait AiEgressPolicy: Send + Sync { principal: &ResolvedPrincipal, manifest: &AiEgressManifest, ) -> AiEgressDecision; + + /// Authorizes an exact result/image/history transfer with independently reopened retained + /// source contexts. The owning host must recheck current grant/resource policy for each + /// context, including the original result checksum and current disclosure fence. Provider + /// delivery never converts a retained image into renewed action evidence. + /// + /// The default preserves ordinary existing text policy but refuses image or Restricted + /// source disclosure until the host explicitly implements this current-source boundary. + #[cfg(any(feature = "sqlite", feature = "postgres"))] + async fn authorize_tool_sources( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + sources: &[crate::AiToolResultDisclosureContext], + ) -> AiEgressDecision { + if sources.iter().any(|source| { + !source.images().is_empty() || source.classification() >= DataClassification::Restricted + }) { + return AiEgressDecision::deny( + manifest, + AiEgressReason::PolicyDenied, + "current-tool-source-policy-required", + principal.principal().subject(), + ); + } + self.authorize(principal, manifest).await + } } /// Durable append-only audit boundary for exact egress decisions. diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs index fe6683b7..5e6e342c 100644 --- a/crates/graphql-orm-ai/src/execution.rs +++ b/crates/graphql-orm-ai/src/execution.rs @@ -9,9 +9,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::{ - AiApprovalRule, AiRunId, AiScope, AiToolAuthorizationDecision, AiToolAuthorizationPolicy, - AiToolCallId, AiToolDescriptor, AiToolId, AiToolOperationKind, GraphqlExecutionTargetId, - GraphqlOperationContract, ToolExecutionError, + AiApprovalRule, AiError, AiRunId, AiScope, AiToolAuthorizationDecision, + AiToolAuthorizationPolicy, AiToolCallId, AiToolDescriptor, AiToolId, AiToolOperationKind, + GraphqlExecutionTargetId, GraphqlOperationContract, ToolExecutionError, }; /// Deployment trust/routing class for authenticated GraphQL execution. @@ -159,6 +159,10 @@ pub struct AiToolExecutionProvenance { tool_call_id: AiToolCallId, attempt_id: uuid::Uuid, lease_generation: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + effect_attempt_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + effect_lease_generation: Option, provider_kind: crate::ProviderKind, provider_model: String, provider_profile_id: String, @@ -167,6 +171,10 @@ pub struct AiToolExecutionProvenance { budget_reservation_id: crate::AiBudgetReservationId, execution_selection: Option, provider_registration_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_binding_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_cursor_fingerprint: Option, tool_fingerprint: String, argument_hash: String, approval_id: Option, @@ -203,6 +211,36 @@ impl AiToolExecutionProvenance { self.lease_generation } + /// Actual effect attempt minted by the owning SDK immediately before invocation. It may + /// differ from the originating provider attempt after human approval; absence proves no + /// current effect fence. Deserialization alone grants no authority. + pub const fn effect_attempt_id(&self) -> Option { + self.effect_attempt_id + } + + /// Actual effect generation minted alongside its attempt, never renewed by disclosure. + pub const fn effect_lease_generation(&self) -> Option { + self.effect_lease_generation + } + + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_effect_execution( + mut self, + lease: &crate::AiRunLease, + ) -> Result { + if self.run_id != lease.run_id() + || self.session_id != lease.session_id() + || lease.state() != crate::AiRunState::Running + || self.effect_attempt_id.is_some() + || self.effect_lease_generation.is_some() + { + return Err(AiError::Conflict); + } + self.effect_attempt_id = Some(lease.attempt_id()); + self.effect_lease_generation = Some(lease.lease_generation()); + Ok(self) + } + /// Actual provider family. pub fn provider_kind(&self) -> &crate::ProviderKind { &self.provider_kind @@ -243,6 +281,34 @@ impl AiToolExecutionProvenance { self.provider_registration_fingerprint.as_deref() } + /// Original exact retained provider-session identity, when invocation came from an admitted + /// native thread. This is correlation only, never authority to resume/read a provider cursor. + pub const fn provider_session_binding_id(&self) -> Option { + self.provider_session_binding_id + } + + /// Original admitted provider cursor digest, derived from the owning binding before effect + /// admission. It contains no cursor and permits no provider thread access. + pub fn provider_cursor_fingerprint(&self) -> Option<&str> { + self.provider_cursor_fingerprint.as_deref() + } + + pub(crate) fn with_provider_cursor_fingerprint( + mut self, + fingerprint: String, + ) -> Result { + if self.provider_session_binding_id.is_none() + || fingerprint.len() != 64 + || !fingerprint + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(AiError::Conflict); + } + self.provider_cursor_fingerprint = Some(fingerprint); + Ok(self) + } + /// Exact compiled registered tool fingerprint. pub fn tool_fingerprint(&self) -> &str { &self.tool_fingerprint @@ -313,6 +379,8 @@ impl AiToolExecutionProvenance { tool_call_id: request.invocation.tool_call_id, attempt_id: lease.attempt_id(), lease_generation: lease.lease_generation(), + effect_attempt_id: None, + effect_lease_generation: None, provider_kind: provider.provider_kind().clone(), provider_model: provider.provider_model().to_owned(), provider_profile_id: provider @@ -326,6 +394,10 @@ impl AiToolExecutionProvenance { provider_registration_fingerprint: provider .provider_session_claim() .map(|claim| claim.descriptor().registration_fingerprint().to_owned()), + provider_session_binding_id: provider + .provider_session_claim() + .map(|claim| claim.binding_id()), + provider_cursor_fingerprint: None, tool_fingerprint: descriptor.fingerprint.clone(), argument_hash: crate::tools::canonical_json_digest(&request.variables), approval_id: None, @@ -350,9 +422,36 @@ impl AiToolExecutionProvenance { } self.approval_id = Some(approval.approval_id()); self.approval_binding_hash = Some(approval.binding_hash().to_owned()); + self.policy_version = Some(binding.policy_version.clone()); + self.authorization_state_digest = Some(binding.authorization_state_digest.clone()); Ok(self) } + // Derived only from the completed trusted runtime result. Keep the exact current policy + // fields that the owning bridge placed on its actual executor binding, including for + // unapproved effects; current disclosure policy remains an independent recheck. + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_completed_authorization( + mut self, + policy_version: &str, + state_digest: &str, + ) -> Self { + self.policy_version = Some(policy_version.to_owned()); + self.authorization_state_digest = Some(state_digest.to_owned()); + self + } + + pub(crate) fn original_invocation(&self) -> Self { + let mut original = self.clone(); + original.approval_id = None; + original.approval_binding_hash = None; + original.policy_version = None; + original.authorization_state_digest = None; + original.effect_attempt_id = None; + original.effect_lease_generation = None; + original + } + fn matches_request(&self, fingerprint: &str, request: &ToolGraphqlRequest) -> bool { self.run_id == request.invocation.run_id && self.tool_call_id == request.invocation.tool_call_id diff --git a/crates/graphql-orm-ai/src/lib.rs b/crates/graphql-orm-ai/src/lib.rs index 29ea9b7f..7c534e31 100644 --- a/crates/graphql-orm-ai/src/lib.rs +++ b/crates/graphql-orm-ai/src/lib.rs @@ -143,6 +143,7 @@ mod skills; mod subscription_waits; mod subscriptions; mod telemetry; +mod tool_media; mod tool_result_preview; mod tools; mod ui_intents; @@ -271,6 +272,7 @@ pub use skills::*; pub use subscription_waits::*; pub use subscriptions::*; pub use telemetry::*; +pub use tool_media::*; pub use tool_result_preview::*; pub use tools::*; pub use ui_intents::*; diff --git a/crates/graphql-orm-ai/src/orm_background.rs b/crates/graphql-orm-ai/src/orm_background.rs index 133c8c11..737762fc 100644 --- a/crates/graphql-orm-ai/src/orm_background.rs +++ b/crates/graphql-orm-ai/src/orm_background.rs @@ -4037,6 +4037,7 @@ fn classify_background_retrieval_error( | ProviderError::BudgetDenied | ProviderError::Unsupported | ProviderError::Rejected + | ProviderError::ToolSourceUnavailable | ProviderError::StatelessNativeItemRejected | ProviderError::NewlyBoundTurnRejected(_) | ProviderError::RetainedTurnRejected(_) diff --git a/crates/graphql-orm-ai/src/orm_checkpoints.rs b/crates/graphql-orm-ai/src/orm_checkpoints.rs index 001f80ba..2bd125a7 100644 --- a/crates/graphql-orm-ai/src/orm_checkpoints.rs +++ b/crates/graphql-orm-ai/src/orm_checkpoints.rs @@ -29,6 +29,7 @@ use crate::{ }; mod native; +mod tool_media; #[derive(Clone, Copy)] enum CompletedToolCheckpointClass { @@ -795,7 +796,8 @@ impl OrmAiCoordinatorCheckpointService { &adopted.provider_result.provider_model, ) && manifest.sources.len() == 1 && manifest.sources[0].kind == "application_tool_result" - && manifest.sources[0].reference == completed.id().0.to_string() + && crate::tool_media::source_call_hint(&manifest.sources[0].reference) + == Some(completed.id().0) }) .ok_or(AiError::EgressDenied)?; if completed.provider_call_id() != provider_tool.call_id { @@ -1266,7 +1268,7 @@ impl OrmAiCoordinatorCheckpointService { ) || transfer.sources.len() != 1 || transfer.sources[0].kind != "application_tool_result" - || transfer.sources[0].reference != tool.id.to_string() + || crate::tool_media::source_call_hint(&transfer.sources[0].reference) != Some(tool.id) { return Err(AiError::EgressDenied); } @@ -1274,6 +1276,13 @@ impl OrmAiCoordinatorCheckpointService { .await .map_err(|error| map_orm(OrmPublicError::from(error)))? .ok_or(AiError::NotFound)?; + if transfer.sources[0].reference + != self + .expected_application_source(lease, &scope, &call, output) + .await? + { + return Err(AiError::EgressDenied); + } let step = AiRunStepRecord::find_by_id(self.run_service.database(), &tool.id) .await .map_err(|error| map_orm(OrmPublicError::from(error)))? @@ -1604,7 +1613,8 @@ impl OrmAiCoordinatorCheckpointService { result.provider_model(), ) && manifest.sources.len() == 1 && manifest.sources[0].kind == "application_tool_result" - && manifest.sources[0].reference == persisted.id().0.to_string() + && crate::tool_media::source_call_hint(&manifest.sources[0].reference) + == Some(persisted.id().0) }) .ok_or(AiError::EgressDenied)?; if persisted.provider_call_id() != expected.call_id() @@ -1658,7 +1668,7 @@ impl OrmAiCoordinatorCheckpointService { result.provider_model(), ) || transfer.sources.len() != 1 || transfer.sources[0].kind != "application_tool_result" - || Uuid::parse_str(&transfer.sources[0].reference).is_err() + || crate::tool_media::source_call_hint(&transfer.sources[0].reference).is_none() || !unique_manifest_hashes.insert(transfer.stable_hash()) { return Err(AiError::EgressDenied); @@ -2043,7 +2053,8 @@ impl OrmAiCoordinatorCheckpointService { ) || transfer.sources.len() != 1 || transfer.sources[0].kind != "application_tool_result" - || transfer.sources[0].reference != tool.id.to_string() + || crate::tool_media::source_call_hint(&transfer.sources[0].reference) + != Some(tool.id) { return Err(AiError::EgressDenied); } @@ -2051,6 +2062,13 @@ impl OrmAiCoordinatorCheckpointService { .await .map_err(|error| map_orm(OrmPublicError::from(error)))? .ok_or(AiError::NotFound)?; + if transfer.sources[0].reference + != self + .expected_application_source(lease, &scope, &call, output) + .await? + { + return Err(AiError::EgressDenied); + } let step = AiRunStepRecord::find_by_id(self.run_service.database(), &tool.id) .await .map_err(|error| map_orm(OrmPublicError::from(error)))? @@ -2158,8 +2176,8 @@ impl OrmAiCoordinatorCheckpointService { { return Err(AiError::EgressDenied); } - let tool_id = Uuid::parse_str(&transfer.sources[0].reference) - .map_err(|_| AiError::Conflict)?; + let tool_id = crate::tool_media::source_call_hint(&transfer.sources[0].reference) + .ok_or(AiError::Conflict)?; if !durable_ids.insert(tool_id) || !provider_call_ids.insert(item.call_id.as_str()) { return Err(AiError::Conflict); @@ -2168,6 +2186,13 @@ impl OrmAiCoordinatorCheckpointService { .await .map_err(|error| map_orm(OrmPublicError::from(error)))? .ok_or(AiError::NotFound)?; + if transfer.sources[0].reference + != self + .expected_application_source(lease, &scope, &call, &item.output) + .await? + { + return Err(AiError::EgressDenied); + } let step = AiRunStepRecord::find_by_id(self.run_service.database(), &tool_id) .await .map_err(|error| map_orm(OrmPublicError::from(error)))? diff --git a/crates/graphql-orm-ai/src/orm_checkpoints/native.rs b/crates/graphql-orm-ai/src/orm_checkpoints/native.rs index 93111837..a449ced5 100644 --- a/crates/graphql-orm-ai/src/orm_checkpoints/native.rs +++ b/crates/graphql-orm-ai/src/orm_checkpoints/native.rs @@ -144,6 +144,9 @@ impl OrmAiCoordinatorCheckpointService { } } let manifest = persisted.egress_manifest().ok_or(AiError::EgressDenied)?; + let expected_source = self + .expected_application_source(lease, scope, &call, output) + .await?; if !route.matches_manifest( manifest, lease, @@ -152,7 +155,7 @@ impl OrmAiCoordinatorCheckpointService { result.provider_model(), ) || manifest.sources.len() != 1 || manifest.sources[0].kind != "application_tool_result" - || manifest.sources[0].reference != call.id.to_string() + || manifest.sources[0].reference != expected_source || manifest.capability != crate::AiEgressCapability::ToolResult || call.result_egress_manifest_hash.as_deref() != Some(manifest.stable_hash().as_str()) @@ -639,7 +642,10 @@ impl OrmAiCoordinatorCheckpointService { ) || (index > source.pending_call_index && call.risk != "read_only") || manifest.sources[0].kind != "application_tool_result" - || manifest.sources[0].reference != call.id.to_string() + || manifest.sources[0].reference + != self + .expected_application_source(lease, &source.scope, &call, output) + .await? || call.result_egress_manifest_hash.as_deref() != Some(manifest.stable_hash().as_str()) { @@ -1341,7 +1347,10 @@ impl OrmAiCoordinatorCheckpointService { &proof.provider.provider_model, ) || original.sources.len() != 1 || original.sources[0].kind != "application_tool_result" - || original.sources[0].reference != call.id.to_string() + || original.sources[0].reference + != self + .expected_application_source(lease, &payload.source.scope, &call, output) + .await? { return Err(AiError::EgressDenied); } @@ -1448,7 +1457,7 @@ impl OrmAiCoordinatorCheckpointService { { return Err(AiError::Conflict); } - let (tool, continuation) = self + let (tool, mut continuation) = self .validate_native_outcome(lease, &payload, &policy) .await?; let call = AiToolCallRecord::find_by_id(self.run_service.database(), &tool.id) @@ -1464,6 +1473,23 @@ impl OrmAiCoordinatorCheckpointService { } self.reauthorize_native_rules(lease, &payload.source) .await?; + let [ModelInputBlock::Json { value }] = continuation.input() else { + return Err(AiError::Conflict); + }; + continuation.native_reset_evidence = Some(std::sync::Arc::new( + crate::provider_session::AiVerifiedNativeContinuation { + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + lease_generation: lease.lease_generation(), + checkpoint_id: row.id, + checkpoint_hash: row.checkpoint_hash, + tool_call_id: call.id, + tool_row_version: call.row_version, + approval_id: payload.approval_id, + outcome_hash: native_receipt_hash(value)?, + }, + )); Ok(AiAdoptedSupervisedToolBatch { checkpoint_id: id, approval_id: crate::AiApprovalId(payload.approval_id), diff --git a/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs b/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs new file mode 100644 index 00000000..782b31d1 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs @@ -0,0 +1,61 @@ +//! Preserve genuine original source metadata in stored coordinator proof checks. +use super::*; + +impl OrmAiCoordinatorCheckpointService { + pub(super) async fn expected_application_source( + &self, + lease: &AiRunLease, + scope: &AiScope, + call: &AiToolCallRecord, + output: &serde_json::Value, + ) -> Result { + let Some(protected_source) = &call.protected_result_source else { + return Ok(call.id.to_string()); + }; + let (_, policy) = self.current_policy(lease, scope).await?; + let opened = self + .open( + &policy, + ContentProtectionContext { + entity: "graphql_orm_ai_tool_calls".into(), + row_id: call.id.to_string(), + field: "protected_result_source".into(), + scope: scope.clone(), + }, + protected_source, + ) + .await?; + let source: crate::tool_media::RetainedToolResultSource = + serde_json::from_value(opened).map_err(|_| AiError::PersistenceFailed)?; + if source.scope != *scope + || source.execution.tool_call_id().0 != call.id + || source.execution.run_id().0 != call.run_id + || source.execution.session_id() != lease.session_id() + || source.effect_lease_generation != call.lease_generation + || source.execution.argument_hash() != call.argument_hash + || source.execution.tool_fingerprint() != call.tool_fingerprint + || source.execution.provider_call_id() != call.provider_call_id + || source.disclosure_fingerprint + != call + .disclosure_schema_fingerprint + .clone() + .ok_or(AiError::Conflict)? + || source.result_sha256 != canonical_json_hash(output)? + || call.result_classification.as_deref() + != Some(classification_value(source.classification)) + || call.execution_provenance.as_ref() + != Some( + &serde_json::to_value(&source.execution) + .map_err(|_| AiError::PersistenceFailed)?, + ) + { + return Err(AiError::Conflict); + } + Ok(crate::AiToolResultDisclosureContext::from_retained( + source, + lease, + crate::AiToolResultDisclosurePurpose::Continuation, + )? + .source_reference()) + } +} diff --git a/crates/graphql-orm-ai/src/orm_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index d2c35d41..7d3e2760 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -598,6 +598,11 @@ impl AiAgentProviderTurnExecutor for AiProviderCallExecutor { /// Protected read-only application-tool execution boundary. #[async_trait] pub trait AiAgentReadOnlyToolExecutor: Send + Sync { + /// Owning protected result loader for typed media/current source disclosure. The default + /// admits no media-required execution; a text-only executor cannot fabricate media authority. + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + None + } /// Executes and durably resolves one exact provider-requested query. /// /// # Errors @@ -674,6 +679,9 @@ pub trait AiAgentReadOnlyToolExecutor: Send + Sync { #[async_trait] impl AiAgentReadOnlyToolExecutor for OrmAiApplicationToolCallService { + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + Some(self) + } async fn execute_tool( &self, lease: &AiRunLease, @@ -820,6 +828,9 @@ impl ReadOnlyDynamicToolExecution { #[async_trait] impl AiProviderDynamicToolExecution for ReadOnlyDynamicToolExecution { + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + self.tool_executor.tool_result_source_service() + } async fn execute_dynamic_tool( &self, lease: &AiRunLease, @@ -3021,6 +3032,7 @@ mod tests { idle_expires_at: time::OffsetDateTime::now_utc() + Duration::minutes(5), absolute_expires_at: time::OffsetDateTime::now_utc() + Duration::hours(1), row_version: 2, + installed_offering_fingerprint: None, }) } @@ -4485,6 +4497,7 @@ mod tests { idle_expires_at: time::OffsetDateTime::now_utc() + Duration::minutes(5), absolute_expires_at: time::OffsetDateTime::now_utc() + Duration::hours(1), row_version: 2, + installed_offering_fingerprint: None, }) } diff --git a/crates/graphql-orm-ai/src/orm_native_approvals.rs b/crates/graphql-orm-ai/src/orm_native_approvals.rs index 44a45d8f..cfb54835 100644 --- a/crates/graphql-orm-ai/src/orm_native_approvals.rs +++ b/crates/graphql-orm-ai/src/orm_native_approvals.rs @@ -580,10 +580,35 @@ impl crate::OrmAiConsequentialToolCallService { // wrapper; the prior tool result estimate did not include this framing. manifest.estimated_tokens = manifest.estimated_bytes; let tools = self.application_tools(); - let decision = tools - .runtime() - .authorize_egress(lease.principal_reference(), &manifest) - .await?; + let decision = if original + .sources + .iter() + .any(|source| source.reference.starts_with("v2:")) + { + let source = tools + .reopen_tool_result_source( + lease, + result.id(), + crate::AiToolResultDisclosurePurpose::Continuation, + ) + .await?; + if original.sources.as_slice() != [source.data_source()] { + return Err(AiError::EgressDenied); + } + tools + .runtime() + .authorize_tool_source_egress( + lease.principal_reference(), + &manifest, + std::slice::from_ref(&source), + ) + .await? + } else { + tools + .runtime() + .authorize_egress(lease.principal_reference(), &manifest) + .await? + }; tools.egress_audit().record(&manifest, &decision).await?; decision.authorize(&manifest)?; Ok(( diff --git a/crates/graphql-orm-ai/src/orm_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index be384871..ca582e03 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session.rs @@ -57,6 +57,9 @@ pub(crate) struct AiProviderSessionBindingRecord { pub registration_fingerprint: String, pub protocol_version: String, pub policy_fingerprint: String, + /// SDK-owned fingerprint of the exact definitions installed at thread creation. Historical + /// unknown values require verified cleanup before a changed static offering is admitted. + pub installed_offering_fingerprint: Option, pub cursor_kind: String, pub cursor_fingerprint: String, /// The cursor is never exposed through generated reads and is redacted @@ -172,6 +175,8 @@ mod service { const CURSOR_FORMAT_VERSION: u16 = 1; const MAXIMUM_SAFE_REASON_CODE_BYTES: usize = 200; + include!("orm_provider_session/static_offering.rs"); + #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct ProtectedProviderSessionCursor { @@ -422,6 +427,39 @@ mod service { #[async_trait] impl AiProviderSessionService for OrmAiProviderSessionService { + async fn prepare_static_offering_reset( + &self, + lease: &AiRunLease, + claim: &AiProviderSessionClaim, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result< + ( + AiProviderSessionCleanupClaim, + AiProviderSessionDeletionRequest, + ), + AiError, + > { + self.prepare_static_offering_reset_inner(lease, claim, request) + .await + } + + async fn authorize_static_offering_rebind( + &self, + lease: &AiRunLease, + cleanup: &AiProviderSessionCleanupClaim, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result { + self.authorize_static_offering_rebind_inner(lease, cleanup, request) + .await + } + async fn authorize_static_offering_budget_rebind( + &self, + lease: &AiRunLease, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result<(AiProviderSessionRebindAuthorization, String), AiError> { + self.authorize_static_offering_budget_rebind_inner(lease, request) + .await + } async fn inspect_for_run( &self, lease: &AiRunLease, @@ -496,6 +534,20 @@ mod service { { return Err(OrmPublicError::new(OrmErrorCode::Conflict)); } + if let Some((evidence, offering)) = &planned.native_context { + validate_static_reset_graph( + tx, + &lease, + &crate::AiProviderStaticOfferingResetRequest { + evidence: evidence.clone(), + planned: planned.clone(), + desired_offering_fingerprint: offering.clone(), + definitely_unstarted_budget_denial: false, + }, + now, + ) + .await?; + } let input = tx .find_by_id::(&lease.input_message_id()) .await @@ -504,7 +556,13 @@ mod service { if input.session_id != session.id || input.message_role != "user" || input.completion_state != "complete" - || input.sequence != session.message_head + || if planned.native_context.is_some() { + input.run_id != Some(lease.run_id().0) + || input.content_purged_at.is_some() + || input.sequence > session.message_head + } else { + input.sequence != session.message_head + } || input.sequence <= 0 { return Err(OrmPublicError::new(OrmErrorCode::Conflict)); @@ -670,6 +728,7 @@ mod service { transcript_fingerprint: planned .transcript_fingerprint() .to_owned(), + static_offering_reset: None, }, ))); } @@ -689,8 +748,13 @@ mod service { let policy = self.protection_policy(current.principal(), &scope).await?; let now = canonical_second(self.clock.now()); let binding_id = Uuid::new_v4(); - let (descriptor, cursor, transcript_fingerprint, provider_expires_at) = - request.into_parts(); + let ( + descriptor, + cursor, + transcript_fingerprint, + provider_expires_at, + installed_offering_fingerprint, + ) = request.into_parts(); let absolute_expires_at = now .checked_add(self.limits.absolute_lifetime()) .ok_or(AiError::PersistenceFailed)?; @@ -812,6 +876,7 @@ mod service { .to_owned(), cursor_kind, cursor_fingerprint, + installed_offering_fingerprint, protected_cursor: Some(protected_cursor), through_message_sequence: input.sequence - 1, transcript_fingerprint, @@ -876,8 +941,13 @@ mod service { let (current, observed_session, scope) = self.load_owned_active_context(lease).await?; let policy = self.protection_policy(current.principal(), &scope).await?; let now = canonical_second(self.clock.now()); - let (descriptor, cursor, transcript_fingerprint, provider_expires_at) = - request.into_parts(); + let ( + descriptor, + cursor, + transcript_fingerprint, + provider_expires_at, + installed_offering_fingerprint, + ) = request.into_parts(); if authorization.session_id != lease.session_id() || authorization.run_id != lease.run_id() || authorization.attempt_id != lease.attempt_id() @@ -892,6 +962,11 @@ mod service { { return Err(AiError::Conflict); } + if let Some(reset) = &authorization.static_offering_reset + && installed_offering_fingerprint.as_deref() != Some(reset.offering_fingerprint()) + { + return Err(AiError::Conflict); + } let expected_owner = principal_identity(current.principal()); let expected_owner_kind = expected_owner.0; let expected_owner_subject = expected_owner.1.to_owned(); @@ -931,6 +1006,9 @@ mod service { if current_run.state != AiRunState::Running.as_str() { return Err(OrmPublicError::new(OrmErrorCode::Conflict)); } + if let Some(reset) = &authorization.static_offering_reset { + validate_static_reset_graph(tx, &lease, reset, now).await?; + } let session = tx .find_by_id::(&lease.session_id().0) .await @@ -953,7 +1031,13 @@ mod service { if input.session_id != session.id || input.message_role != "user" || input.completion_state != "complete" - || input.sequence != session.message_head + || if authorization.static_offering_reset.is_some() { + input.run_id != Some(lease.run_id().0) + || input.content_purged_at.is_some() + || input.sequence > session.message_head + } else { + input.sequence != session.message_head + } || input.sequence <= 0 { return Err(OrmPublicError::new(OrmErrorCode::Conflict)); @@ -1025,6 +1109,9 @@ mod service { ), cursor_kind: Some(cursor_kind), cursor_fingerprint: Some(cursor_fingerprint), + installed_offering_fingerprint: Some( + installed_offering_fingerprint, + ), protected_cursor: Some(Some(protected_cursor)), through_message_sequence: Some(input.sequence - 1), transcript_fingerprint: Some(transcript_fingerprint), @@ -3476,6 +3563,7 @@ mod service { idle_expires_at: parse_required_time(record.idle_expires_at)?, absolute_expires_at: parse_required_time(record.absolute_expires_at)?, row_version: record.row_version, + installed_offering_fingerprint: record.installed_offering_fingerprint.clone(), }) } @@ -3518,6 +3606,10 @@ mod service { && !principal_reference.subject.is_empty() && crate::valid_sha256(&record.cursor_fingerprint) && crate::valid_sha256(&record.transcript_fingerprint) + && record + .installed_offering_fingerprint + .as_deref() + .is_none_or(crate::valid_sha256) && record.through_message_sequence >= 0 && record.claim_generation >= 0 && record.park_generation >= 0 @@ -4443,6 +4535,7 @@ mod service { policy_fingerprint: "b".repeat(64), cursor_kind, cursor_fingerprint, + installed_offering_fingerprint: None, protected_cursor: Some(protected_cursor), through_message_sequence: 0, transcript_fingerprint: "c".repeat(64), @@ -4539,6 +4632,7 @@ mod service { protected_arguments: Some(serde_json::json!({"protected": true})), argument_hash: "f".repeat(64), protected_result: None, + protected_result_source: None, payload_purged_at: None, risk: "high_impact".to_owned(), authorization_code: None, diff --git a/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs b/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs new file mode 100644 index 00000000..966988ef --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs @@ -0,0 +1,350 @@ +// Dedicated native static-offering reset. Generic rebind reasons remain unchanged. + +async fn validate_static_reset_graph( + tx: &mut MutationContext<'_, DefaultWriteBackend>, + lease: &AiRunLease, + request: &crate::AiProviderStaticOfferingResetRequest, + now: OffsetDateTime, +) -> Result<(), OrmPublicError> { + use crate::persistence::{ + AiBudgetReservationRecord, AiBudgetReservationRecordWhereInput, AiRunStepRecord, + }; + let conflict = || OrmPublicError::new(OrmErrorCode::Conflict); + let evidence = &request.evidence; + let run = load_and_validate_active_lease(tx, lease, now).await?; + if run.state != AiRunState::Running.as_str() + || run.latest_checkpoint_id.is_some() + || run.cancellation_request_id.is_some() + || evidence.session_id != lease.session_id() + || evidence.run_id != lease.run_id() + || evidence.attempt_id != lease.attempt_id() + || evidence.lease_generation != lease.lease_generation() + || !crate::valid_sha256(&request.desired_offering_fingerprint) + { + return Err(conflict()); + } + let checkpoint = tx + .query::() + .filter(crate::persistence::AiRunCheckpointRecordWhereInput { + id: Some(UuidFilter { + eq: Some(evidence.checkpoint_id), + ..Default::default() + }), + ..Default::default() + }) + .limit(1) + .fetch_one() + .await + .map_err(OrmPublicError::from)? + .ok_or_else(conflict)?; + let call = tx + .find_by_id::(&evidence.tool_call_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(conflict)?; + let step = tx + .find_by_id::(&evidence.tool_call_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(conflict)?; + let approval = tx + .find_by_id::(&evidence.approval_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(conflict)?; + if checkpoint.run_id != run.id + || checkpoint.attempt_id != lease.attempt_id() + || checkpoint.lease_generation != lease.lease_generation() + || checkpoint.checkpoint_kind != crate::orm_runs::native_checkpoints::OUTCOME_KIND + || checkpoint.checkpoint_hash != evidence.checkpoint_hash + || checkpoint.protected_state.is_none() + || call.run_id != run.id + || call.row_version != evidence.tool_row_version + || call.lease_generation != lease.lease_generation() + || !matches!(call.state.as_str(), "completed" | "execution_failed") + || call.protected_result.is_none() + || call.completed_at.is_none() + || call.payload_purged_at.is_some() + || call.approval_id != Some(approval.id) + || step.run_id != run.id + || step.lease_generation != lease.lease_generation() + || step.state != call.state + || step.finished_at.is_none() + || approval.tool_call_id != call.id + || approval.session_id != lease.session_id().0 + || approval.state != "consumed" + || approval.maximum_uses != 1 + || approval.consumed_uses != 1 + || approval.consumed_at.is_none() + { + return Err(conflict()); + } + // Query for any unsettled row directly: a default page cap must never turn a large + // retained cohort into a falsely settled prefix. + let unsettled_budget = tx + .query::() + .filter(AiBudgetReservationRecordWhereInput { + run_id: Some(UuidFilter { + eq: Some(run.id), + ..Default::default() + }), + state: Some(StringFilter { + not_in: Some(vec!["committed".into(), "released".into()]), + ..Default::default() + }), + ..Default::default() + }) + .limit(1) + .fetch_one() + .await + .map_err(OrmPublicError::from)?; + let unsettled_call = tx + .query::() + .filter(AiToolCallRecordWhereInput { + run_id: Some(UuidFilter { + eq: Some(run.id), + ..Default::default() + }), + state: Some(StringFilter { + not_in: Some(vec!["completed".into(), "execution_failed".into()]), + ..Default::default() + }), + ..Default::default() + }) + .limit(1) + .fetch_one() + .await + .map_err(OrmPublicError::from)?; + if unsettled_budget.is_some() || unsettled_call.is_some() { + return Err(conflict()); + } + Ok(()) +} + +impl OrmAiProviderSessionService { + async fn prepare_static_offering_reset_inner( + &self, + lease: &AiRunLease, + claim: &AiProviderSessionClaim, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result< + ( + AiProviderSessionCleanupClaim, + AiProviderSessionDeletionRequest, + ), + AiError, + > { + let (current, session, scope) = self.load_owned_active_context(lease).await?; + let policy = self.protection_policy(current.principal(), &scope).await?; + let now = canonical_second(self.clock.now()); + let expiry = now + .checked_add(self.limits.cleanup_lease_ttl()) + .map(|expiry| expiry.min(lease.lease_expires_at())) + .filter(|expiry| *expiry > now) + .ok_or(AiError::Conflict)?; + let lease = lease.clone(); + let claim = claim.clone(); + let request = request.clone(); + let record = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + validate_static_reset_graph(tx, &lease, &request, now).await?; + let record = tx + .find_by_id::(&claim.binding_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + validate_active_claim(&record, &claim, &lease, now)?; + if record.session_id != session.id + || record_scope_from_binding(&record) != scope + || descriptor_from_record(&record).map_err(ai_error_to_orm)? + != *request.planned.descriptor() + || record.transcript_fingerprint != request.planned.transcript_fingerprint() + || (!request.definitely_unstarted_budget_denial + && record.installed_offering_fingerprint.as_deref() + == Some(request.offering_fingerprint())) + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let cleanup_generation = record + .cleanup_generation + .checked_add(1) + .ok_or_else(|| OrmPublicError::new(OrmErrorCode::Conflict))?; + let outcome = tx + .compare_and_swap::( + &record.id, + record.row_version, + AiProviderSessionBindingRecordWhereInput::default(), + UpdateAiProviderSessionBindingRecordInput { + state: Some( + AiProviderSessionState::CleanupInProgress.as_str().into(), + ), + last_run_id: Some(Some(lease.run_id().0)), + claimed_run_id: Some(None), + claimed_attempt_id: Some(None), + claimed_run_lease_generation: Some(None), + claim_owner: Some(None), + claim_expires_at: Some(None), + cleanup_owner: Some(Some(lease.worker_id().to_owned())), + cleanup_generation: Some(cleanup_generation), + cleanup_lease_expires_at: Some(Some(expiry.unix_timestamp())), + cleanup_reason_code: Some(Some( + if request.definitely_unstarted_budget_denial { + "native_static_offering_budget_denied" + } else { + "native_static_offering_reset" + } + .into(), + )), + cleanup_next_attempt_at: Some(None), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + let ConditionalUpdateOutcome::Updated(updated) = outcome else { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + }; + append_audit( + tx, + "ai.provider_session.static_offering_cleanup", + updated.id, + "native_static_offering_reset", + lease.run_id().0, + now, + ) + .await?; + Ok(updated) + }) + }) + .await + .map_err(map_transaction)?; + let cleanup = cleanup_claim_from_record(&record)?; + let deletion = self.open_for_cleanup(&cleanup, &policy).await?; + Ok((cleanup, deletion)) + } + + async fn authorize_static_offering_rebind_inner( + &self, + lease: &AiRunLease, + cleanup: &AiProviderSessionCleanupClaim, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result { + let (current, session, scope) = self.load_owned_active_context(lease).await?; + self.protection_policy(current.principal(), &scope).await?; + let now = canonical_second(self.clock.now()); + let expires_at = now + .checked_add(MAXIMUM_PROVIDER_SESSION_REBIND_AUTHORIZATION_TTL) + .map(|expiry| expiry.min(lease.lease_expires_at())) + .filter(|expiry| *expiry > now) + .ok_or(AiError::Conflict)?; + let lease = lease.clone(); + let cleanup = cleanup.clone(); + let request = request.clone(); + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + validate_static_reset_graph(tx, &lease, &request, now).await?; + let record = tx + .find_by_id::(&cleanup.binding_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + validate_binding_record(&record)?; + if record.session_id != session.id + || record_scope_from_binding(&record) != scope + || record.state != AiProviderSessionState::Deleted.as_str() + || record.protected_cursor.is_some() + || record.last_run_id != Some(lease.run_id().0) + || record.cleanup_generation != cleanup.cleanup_generation + || record.cleanup_reason_code.as_deref() + != Some(if request.definitely_unstarted_budget_denial { + "native_static_offering_budget_denied" + } else { + "native_static_offering_reset" + }) + || descriptor_from_record(&record).map_err(ai_error_to_orm)? + != *request.planned.descriptor() + || record.transcript_fingerprint != request.planned.transcript_fingerprint() + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let provider_absence_observed_at = record + .provider_absence_observed_at + .and_then(|value| OffsetDateTime::from_unix_timestamp(value).ok()) + .filter(|observed| { + *observed <= now && *observed >= now - Duration::hours(1) + }) + .ok_or_else(|| OrmPublicError::new(OrmErrorCode::Conflict))?; + Ok(AiProviderSessionRebindAuthorization { + binding_id: record.id, + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + run_lease_generation: lease.lease_generation(), + binding_row_version: record.row_version, + binding_claim_generation: record.claim_generation, + cleanup_generation: record.cleanup_generation, + provider_absence_observed_at, + expires_at, + principal_reference: lease.principal_reference().clone(), + scope, + descriptor: request.planned.descriptor().clone(), + transcript_fingerprint: request.planned.transcript_fingerprint().to_owned(), + static_offering_reset: Some(request), + }) + }) + }) + .await + .map_err(map_transaction) + } + async fn authorize_static_offering_budget_rebind_inner( + &self, + lease: &AiRunLease, + request: &crate::AiProviderStaticOfferingResetRequest, + ) -> Result<(AiProviderSessionRebindAuthorization, String), AiError> { + if !request.definitely_unstarted_budget_denial { + return Err(AiError::Conflict); + } + let (_, session, _) = self.load_owned_active_context(lease).await?; + let records = AiProviderSessionBindingRecord::query(self.database.pool()) + .filter(AiProviderSessionBindingRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(session.id), + ..Default::default() + }), + ..Default::default() + }) + .limit(2) + .fetch_all() + .await + .map_err(|_| AiError::PersistenceFailed)?; + let [record] = records.as_slice() else { + return Err(AiError::Conflict); + }; + if record.state != AiProviderSessionState::Deleted.as_str() + || record.cleanup_reason_code.as_deref() != Some("native_static_offering_budget_denied") + || !crate::valid_sha256(&record.cursor_fingerprint) + { + return Err(AiError::Conflict); + } + // The tombstone retains the exact old cursor fingerprint and cleanup generation. + // It has no opened bytes; confirmed absence and current original graph are checked + // again transactionally by the dedicated authorization method below. + let cleanup = AiProviderSessionCleanupClaim { + binding_id: record.id, + session_id: lease.session_id(), + scope: record_scope_from_binding(record), + descriptor: request.planned.descriptor().clone(), + cleanup_worker_id: lease.worker_id().to_owned(), + cleanup_generation: record.cleanup_generation, + cleanup_expires_at: lease.lease_expires_at(), + row_version: record.row_version, + }; + let authorization = self + .authorize_static_offering_rebind_inner(lease, &cleanup, request) + .await?; + Ok((authorization, record.cursor_fingerprint.clone())) + } +} diff --git a/crates/graphql-orm-ai/src/orm_run_cancellation.rs b/crates/graphql-orm-ai/src/orm_run_cancellation.rs index 71d97a7b..23c94fcc 100644 --- a/crates/graphql-orm-ai/src/orm_run_cancellation.rs +++ b/crates/graphql-orm-ai/src/orm_run_cancellation.rs @@ -547,6 +547,7 @@ async fn cancel_active_tool_calls( state: Some(StringFilter { in_list: Some(vec![ "executing".to_owned(), + "source_retained".to_owned(), "waiting_approval".to_owned(), "waiting_subscription".to_owned(), ]), diff --git a/crates/graphql-orm-ai/src/orm_runs.rs b/crates/graphql-orm-ai/src/orm_runs.rs index 572903d0..6bb1467e 100644 --- a/crates/graphql-orm-ai/src/orm_runs.rs +++ b/crates/graphql-orm-ai/src/orm_runs.rs @@ -31,6 +31,7 @@ use crate::{ }; pub(crate) mod native_checkpoints; +mod tool_media; const MAXIMUM_WORKER_ID_BYTES: usize = 256; const MAXIMUM_SAFE_CODE_BYTES: usize = 200; @@ -3159,6 +3160,7 @@ impl OrmAiRunService { protected_result: blocked .as_ref() .map(|blocked| blocked.receipt.protected_receipt.clone()), + protected_result_source: None, payload_purged_at: None, risk: call.risk, authorization_code: blocked @@ -4121,6 +4123,25 @@ impl OrmAiRunService { &self, lease: &AiRunLease, finish: PreparedToolCallFinish, + ) -> Result { + self.finish_tool_call_inner(lease, finish, None).await + } + + pub(crate) async fn finish_tool_call_with_source( + &self, + lease: &AiRunLease, + finish: PreparedToolCallFinish, + protected_source: Option, + ) -> Result { + self.finish_tool_call_inner(lease, finish, protected_source) + .await + } + + async fn finish_tool_call_inner( + &self, + lease: &AiRunLease, + finish: PreparedToolCallFinish, + retained_source: Option, ) -> Result { let now = canonical_second(self.clock.now()); let lease_ttl = self.limits.lease_ttl; @@ -4149,8 +4170,19 @@ impl OrmAiRunService { || call.lease_generation != lease.lease_generation || call.provider_call_key != finish.expected_provider_call_key || call.tool_fingerprint != finish.expected_tool_fingerprint - || call.state != "executing" - || call.protected_result.is_some() + || match &retained_source { + Some(source) => { + call.state != "source_retained" + || call.protected_result.as_ref() + != Some(&finish.protected_result) + || call.protected_result_source.as_ref() != Some(source) + } + None => { + call.state != "executing" + || call.protected_result.is_some() + || call.protected_result_source.is_some() + } + } { return Err(OrmPublicError::new(OrmErrorCode::Conflict)); } @@ -6738,6 +6770,7 @@ mod tests { protected_arguments: Some(serde_json::json!({"protected": true})), argument_hash: "argument-hash".to_owned(), protected_result: Some(serde_json::json!({"protected": true})), + protected_result_source: None, payload_purged_at: None, risk: "read_only".to_owned(), authorization_code: Some("allowed".to_owned()), diff --git a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs new file mode 100644 index 00000000..ace1323c --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -0,0 +1,242 @@ +//! Fenced retention of actual effect output before source disclosure. + +use super::*; + +impl OrmAiRunService { + // Save the actual bounded completed output before descriptive media parsing or resource IO. + // It is still an executing call without a source admission, so it cannot be disclosed or + // consumed as a completed checkpoint. A failed metadata check never replaces this output + // with a fabricated success/failure body and never makes the effect eligible for replay. + pub(crate) async fn retain_tool_effect_output( + &self, + lease: &AiRunLease, + origin: &crate::AiToolExecutionProvenance, + protected_result: serde_json::Value, + ) -> Result<(), AiError> { + let lease = lease.clone(); + let origin = origin.clone(); + let now = canonical_second(self.clock.now()); + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let current = load_and_validate_active_lease(tx, &lease, now).await?; + let call = tx + .find_by_id::(&origin.tool_call_id().0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + let step = tx + .find_by_id::(&call.id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + let expected = serde_json::to_value(origin.original_invocation()) + .map_err(|_| OrmPublicError::new(OrmErrorCode::InternalError))?; + if persisted_state(¤t)? != AiRunState::Running + || call.run_id != lease.run_id.0 + || origin.run_id() != lease.run_id + || origin.session_id() != lease.session_id + || step.run_id != call.run_id + || call.lease_generation != lease.lease_generation + || step.lease_generation != call.lease_generation + || step.state != "running" + || call.state != "executing" + || call.protected_result.is_some() + || call.protected_result_source.is_some() + || call.execution_provenance.as_ref() != Some(&expected) + || origin.argument_hash() != call.argument_hash + || origin.tool_fingerprint() != call.tool_fingerprint + || origin.effect_attempt_id() != Some(lease.attempt_id) + || origin.effect_lease_generation() != Some(lease.lease_generation) + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + if let Some(approval_id) = origin.approval_id() { + let approval = tx + .find_by_id::(&approval_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if call.approval_id != Some(approval.id) + || approval.tool_call_id != call.id + || approval.state != "consumed" + || approval.consumed_uses != 1 + || approval.consumed_at.is_none() + || origin.approval_binding_hash() + != Some(approval.binding_hash.as_str()) + || origin.policy_version() != Some(approval.policy_version.as_str()) + || origin.authorization_state_digest() + != Some(approval.authorization_state_digest.as_str()) + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + } else if call.approval_id.is_some() + || origin.attempt_id() != lease.attempt_id + || origin.lease_generation() != lease.lease_generation + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + match tx + .compare_and_swap::( + &call.id, + call.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + protected_result: Some(Some(protected_result)), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(_) => Ok(()), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .map_err(map_transaction) + } + + pub(crate) async fn validate_tool_disclosure_fence( + &self, + lease: &AiRunLease, + ) -> Result<(), AiError> { + let lease = lease.clone(); + let now = canonical_second(self.clock.now()); + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let current = load_and_validate_active_lease(tx, &lease, now).await?; + if persisted_state(¤t)? != AiRunState::Running { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + Ok(()) + }) + }) + .await + .map_err(map_transaction) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn retain_tool_result_source( + &self, + lease: &AiRunLease, + call_id: AiToolCallId, + protected_result: serde_json::Value, + protected_source: serde_json::Value, + origin: crate::AiToolExecutionProvenance, + source_reference: String, + classification: String, + image_count: i64, + provider_cursor_fingerprint: Option, + ) -> Result<(), AiError> { + let lease = lease.clone(); + let now = canonical_second(self.clock.now()); + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let current = load_and_validate_active_lease(tx, &lease, now).await?; + if persisted_state(¤t)? != AiRunState::Running { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let call = tx + .find_by_id::(&call_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + let step = tx + .find_by_id::(&call_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + let expected = serde_json::to_value(origin.original_invocation()) + .map_err(|_| OrmPublicError::new(OrmErrorCode::InternalError))?; + if call.run_id != lease.run_id.0 + || step.run_id != call.run_id + || call.lease_generation != lease.lease_generation + || step.lease_generation != call.lease_generation + || step.state != "running" + || call.state != "executing" + || call.protected_result.as_ref() != Some(&protected_result) + || call.protected_result_source.is_some() + || call.execution_provenance.as_ref() != Some(&expected) + || origin.tool_call_id() != call_id + || origin.run_id() != lease.run_id + || origin.session_id() != lease.session_id + || origin.argument_hash() != call.argument_hash + || origin.tool_fingerprint() != call.tool_fingerprint + || origin.effect_attempt_id() != Some(lease.attempt_id) + || origin.effect_lease_generation() != Some(lease.lease_generation) + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + if let Some(approval_id) = origin.approval_id() { + let approval = tx + .find_by_id::(&approval_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if call.approval_id != Some(approval.id) + || approval.tool_call_id != call.id + || approval.state != "consumed" + || approval.consumed_uses != 1 + || approval.consumed_at.is_none() + || origin.approval_binding_hash() + != Some(approval.binding_hash.as_str()) + || origin.policy_version() != Some(approval.policy_version.as_str()) + || origin.authorization_state_digest() + != Some(approval.authorization_state_digest.as_str()) + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + } else if call.approval_id.is_some() + || origin.attempt_id() != lease.attempt_id + || origin.lease_generation() != lease.lease_generation + { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let outcome = tx + .compare_and_swap::( + &call.id, + call.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + protected_result: Some(Some(protected_result)), + protected_result_source: Some(Some(protected_source)), + execution_provenance: Some(Some( + serde_json::to_value(&origin).map_err(|_| { + OrmPublicError::new(OrmErrorCode::InternalError) + })?, + )), + state: Some("source_retained".to_owned()), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + tx.insert::(CreateAiToolResultSourceRecordInput { + id: call_id.0, + session_id: lease.session_id.0, + run_id: lease.run_id.0, + provider_kind: origin.provider_kind().as_str().into(), + provider_profile_id: origin.provider_profile_id().into(), + provider_model: origin.provider_model().into(), + provider_session_binding_id: origin.provider_session_binding_id(), + provider_cursor_fingerprint, + source_reference, + classification, + image_count, + }) + .await + .map_err(OrmPublicError::from)?; + match outcome { + ConditionalUpdateOutcome::Updated(_) => Ok(()), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .map_err(map_transaction) + } +} diff --git a/crates/graphql-orm-ai/src/orm_session_retention.rs b/crates/graphql-orm-ai/src/orm_session_retention.rs index 2647bb84..b7ef38d2 100644 --- a/crates/graphql-orm-ai/src/orm_session_retention.rs +++ b/crates/graphql-orm-ai/src/orm_session_retention.rs @@ -1267,6 +1267,7 @@ impl OrmAiSessionRetentionService { UpdateAiToolCallRecordInput { protected_arguments: Some(None), protected_result: Some(None), + protected_result_source: Some(None), payload_purged_at: Some(Some(now)), ..Default::default() }, @@ -5206,6 +5207,7 @@ mod tests { protected_arguments: None, argument_hash: "argument-hash".to_owned(), protected_result: None, + protected_result_source: None, payload_purged_at: Some(now().unix_timestamp() - 80), risk: "read_only".to_owned(), authorization_code: Some("allowed".to_owned()), @@ -5539,6 +5541,7 @@ mod tests { argument_hash: "argument-hash".to_owned(), protected_result: tool_call_result_required(call_state) .then(|| serde_json::json!({"protected": "result"})), + protected_result_source: None, payload_purged_at: None, risk: if approval_id.is_some() { "high_impact".to_owned() @@ -8339,6 +8342,7 @@ mod tests { })), protected_arguments: Some(None), protected_result: Some(None), + protected_result_source: Some(None), payload_purged_at: Some(Some(now().unix_timestamp())), ..Default::default() }, @@ -8714,6 +8718,7 @@ mod tests { policy_fingerprint: "b".repeat(64), cursor_kind: "test.thread".to_owned(), cursor_fingerprint: "c".repeat(64), + installed_offering_fingerprint: None, protected_cursor: Some(serde_json::json!({"protected":true})), through_message_sequence: 2, transcript_fingerprint: "d".repeat(64), diff --git a/crates/graphql-orm-ai/src/orm_subscription_waits.rs b/crates/graphql-orm-ai/src/orm_subscription_waits.rs index adabe4c2..b1a27daa 100644 --- a/crates/graphql-orm-ai/src/orm_subscription_waits.rs +++ b/crates/graphql-orm-ai/src/orm_subscription_waits.rs @@ -3080,6 +3080,7 @@ impl OrmAiSubscriptionWaitService { protected_arguments: Some(registration.protected_arguments), argument_hash: registration.argument_hash, protected_result: None, + protected_result_source: None, payload_purged_at: None, risk: "read_only".to_owned(), authorization_code: Some("waiting_subscription".to_owned()), diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index d57c0273..1cefcf21 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -280,6 +280,29 @@ pub trait AiSupervisedAgentTurnPlanner: Send + Sync { provider_turns: u32, continuation: crate::AiAgentContinuation, ) -> Result; + + /// Optionally reduces one SDK-proven unstarted image continuation after atomic quota denial. + /// + /// Default is no retry. The SDK releases its failed plan clones and refuses this callback + /// if any other frozen plan survives. A replacement must use the same opaque continuation, + /// prepared task context, shared delivery, provider/model/registration, scope and rules, + /// a strict subset of the original static offering and a strictly smaller image quote. + /// It grants no renewal of an original effect, human consent or observation deadline. + /// Only one retry is admitted; a second denial remains truthful and terminal. + /// The callback is cancelled on owner cancellation and bounded by the remaining + /// current lease, at most thirty seconds, without renewing that lease. + /// + /// # Errors + /// Return a safe refusal when current host configuration cannot produce the exact + /// smaller offering. The SDK separately validates cleanup, source, fencing and quote. + async fn replan_unstarted_static_offering( + &self, + _lease: &AiRunLease, + _provider_turns: u32, + _context: crate::AiUnstartedStaticOfferingReplan, + ) -> Result, AiError> { + Ok(None) + } } /// Durable wait created for one provider-requested supervised mutation. @@ -985,6 +1008,32 @@ impl AiSupervisedAgentCoordinator { #[allow(clippy::too_many_arguments)] async fn execute_turn( + &self, + lease: AiRunLease, + guard: AiAgentLoopGuard, + plan: AiSupervisedAgentTurnPlan, + rule_usage: AiRuleRunUsage, + adopted: Option<&AiAdoptedSupervisedToolBatch>, + adopted_automatic: Option<&AiAdoptedAutomaticMutationBatch>, + adopted_subscription: Option<&crate::AiAdoptedReadOnlyToolBatch>, + ) -> Result { + // The private retry state retains several exact plans and proofs. Keep that + // future off every native continuation caller's inline polling frame. + Box::pin(self.execute_turn_with_retry( + lease, + guard, + plan, + rule_usage, + adopted, + adopted_automatic, + adopted_subscription, + true, + )) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn execute_turn_with_retry( &self, mut lease: AiRunLease, mut guard: AiAgentLoopGuard, @@ -993,6 +1042,7 @@ impl AiSupervisedAgentCoordinator { adopted: Option<&AiAdoptedSupervisedToolBatch>, adopted_automatic: Option<&AiAdoptedAutomaticMutationBatch>, adopted_subscription: Option<&crate::AiAdoptedReadOnlyToolBatch>, + allow_unstarted_retry: bool, ) -> Result { if !guard.can_begin_provider_turn() { return self @@ -1166,6 +1216,20 @@ impl AiSupervisedAgentCoordinator { .await; } } + let unstarted_replan = if native_mode && allow_unstarted_retry { + capability_delivery + .as_ref() + .zip(provider_session.as_ref()) + .and_then(|(delivery, session)| { + crate::AiUnstartedStaticOfferingReplan::prepare( + &provider_plan, + delivery, + session, + ) + }) + } else { + None + }; let classified_bindings = provider_plan.clone(); let resumed_checkpoint = adopted.is_some() || adopted_automatic.is_some() || adopted_subscription.is_some(); @@ -1279,6 +1343,106 @@ impl AiSupervisedAgentCoordinator { .await; } Err(SupervisedProviderTurnFailure::BudgetDenied) => { + if let Some(context) = unstarted_replan { + // The completed execution future owns no transport/effect. Release every + // SDK clone before offering mutable static configuration to the host. + drop(classified_bindings); + drop(native_execution); + if context.ready_for_callback().is_err() { + return self + .finish_recovery( + &lease, + &guard, + AiAgentRecoveryPhase::ProviderTurn, + "unstarted_static_offering_plan_still_frozen", + None, + ) + .await; + } + if self.run_control.cancellation(&lease).await?.is_some() { + return Ok(AiSupervisedAgentRunOutcome::Cancelled { + provider_turns: guard.provider_turns(), + total_tool_calls: guard.total_tool_calls(), + }); + } + let cleanup_is_current = if let Some(service) = &self.provider_session_service { + context + .validate_unstarted_cleanup(&lease, service.as_ref(), self.clock.now()) + .await + .is_ok() + } else { + false + }; + if !cleanup_is_current { + return self + .finish_recovery( + &lease, + &guard, + AiAgentRecoveryPhase::ProviderTurn, + "unstarted_static_offering_cleanup_not_proved", + None, + ) + .await; + } + let replacement = Box::pin(self.replan_unstarted_static_offering_bounded( + &lease, + guard.provider_turns(), + &context, + )) + .await; + if self.run_control.cancellation(&lease).await?.is_some() { + return Ok(AiSupervisedAgentRunOutcome::Cancelled { + provider_turns: guard.provider_turns(), + total_tool_calls: guard.total_tool_calls(), + }); + } + let replacement = match replacement { + Ok(replacement) => replacement, + Err(_) => { + return self + .finish_failed( + &lease, + &guard, + "unstarted_static_offering_replan_refused", + ) + .await; + } + }; + if let Some(next) = replacement { + if !next.native + || next.uses_byok != uses_byok + || next.result_egress_route != route + || next.rules.fingerprint() != planned_rules.fingerprint() + || next + .capability_delivery + .as_ref() + .zip(next.provider_session.as_ref()) + .is_none_or(|(delivery, session)| { + context + .validate_replacement( + &next.provider_call, + delivery, + session, + ) + .is_err() + }) + { + return self + .finish_failed( + &lease, + &guard, + "unstarted_static_offering_replan_invalid", + ) + .await; + } + // The original checkpoint was already consumed; recursive entry only + // prices/discloses the same continuation and never consumes or invokes it again. + return Box::pin(self.execute_turn_with_retry( + lease, guard, next, rule_usage, None, None, None, false, + )) + .await; + } + } if let Some((service, claim)) = &reclaimed { let _ = service .require_cleanup(claim, "provider_session_reclaimed_handoff_failed") @@ -1583,12 +1747,18 @@ impl AiSupervisedAgentCoordinator { } return Err(error); } - if let Some((service, claim, commit)) = provider_session_commit - && service.commit_turn(&lease, &claim, commit).await.is_err() - { - let _ = service - .require_cleanup(&claim, "provider_session_commit_uncertain") - .await; + if let Some((service, claim, commit)) = provider_session_commit { + if result.is_withheld_native_task_context() { + // This tool-free thread is a restricted completed-task projection. + // Do not leave its empty offering as the next ordinary run's thread. + let _ = service + .require_cleanup(&claim, "native_withheld_task_context_completed") + .await; + } else if service.commit_turn(&lease, &claim, commit).await.is_err() { + let _ = service + .require_cleanup(&claim, "provider_session_commit_uncertain") + .await; + } } Ok(AiSupervisedAgentRunOutcome::Completed { message_id, @@ -1870,6 +2040,67 @@ impl AiSupervisedAgentCoordinator { } } + async fn replan_unstarted_static_offering_bounded( + &self, + lease: &AiRunLease, + provider_turns: u32, + context: &crate::AiUnstartedStaticOfferingReplan, + ) -> Result, AiError> { + let remaining = lease.lease_expires_at() - self.clock.now(); + if !remaining.is_positive() { + return Err(AiError::Conflict); + } + let deadline = + tokio::time::Instant::now() + remaining.min(Duration::seconds(30)).unsigned_abs(); + let operation = async { + let callback = self.planner.replan_unstarted_static_offering( + lease, + provider_turns, + context.clone(), + ); + tokio::pin!(callback); + let replacement = loop { + tokio::select! { + result = &mut callback => break result?, + cancellation = self.run_control.wait_for_cancellation( + lease, std::time::Duration::from_millis(250), + ) => { + if cancellation?.is_some() + || self.clock.now() >= lease.lease_expires_at() + { + return Err(AiError::Conflict); + } + } + } + }; + // No renewal occurs while the host plans. A completed callback does not + // authorize a replacement after cancellation, expiry or changed custody. + if self.clock.now() >= lease.lease_expires_at() + || self.run_control.cancellation(lease).await?.is_some() + { + return Err(AiError::Conflict); + } + context + .validate_current_unstarted_cleanup( + lease, + self.provider_session_service + .as_deref() + .ok_or(AiError::Conflict)?, + self.clock.now(), + ) + .await?; + Ok(replacement) + }; + let replacement = tokio::time::timeout_at(deadline, operation) + .await + .map_err(|_| AiError::ProviderFailed)??; + // A non-yielding host callback must also fail the same monotonic bound. + if tokio::time::Instant::now() >= deadline { + return Err(AiError::ProviderFailed); + } + Ok(replacement) + } + async fn execute_provider_with_heartbeats( &self, lease: &mut AiRunLease, @@ -2264,6 +2495,7 @@ mod tests { idle_expires_at: time::OffsetDateTime::now_utc() + Duration::minutes(5), absolute_expires_at: time::OffsetDateTime::now_utc() + Duration::hours(1), row_version: 2, + installed_offering_fingerprint: None, }) } diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs index 21f0d2cb..09278e1d 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs @@ -34,6 +34,9 @@ pub(super) struct NativeExecution { #[async_trait] impl AiProviderDynamicToolExecution for NativeExecution { + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + Some(self.services.applications.as_ref()) + } async fn execute_dynamic_tool( &self, _lease: &AiRunLease, @@ -127,10 +130,13 @@ impl AiProviderDynamicToolExecution for NativeExecution { match (disposition, pending) { (AiApplicationToolDisposition::ReadOnly, _) => { AiProviderDynamicToolOutcome::Application(Box::new( - self.services - .applications - .execute_read_only(lease, result, context, self.route.clone()) - .await?, + Box::pin(self.services.applications.execute_read_only( + lease, + result, + context, + self.route.clone(), + )) + .await?, )) } (_, Some(pending)) => AiProviderDynamicToolOutcome::NativeControl(Box::new( @@ -145,19 +151,23 @@ impl AiProviderDynamicToolExecution for NativeExecution { ) .await?, )), - (AiApplicationToolDisposition::AutomaticMutation, None) => match self - .services - .applications - .execute_automatic_mutation(lease, result, context, self.route.clone()) + (AiApplicationToolDisposition::AutomaticMutation, None) => { + match Box::pin(self.services.applications.execute_automatic_mutation( + lease, + result, + context, + self.route.clone(), + )) .await? - { - crate::AiConsequentialToolCallOutcome::Persisted(result) => { - AiProviderDynamicToolOutcome::Application(result) - } - crate::AiConsequentialToolCallOutcome::RecoveryRequired { .. } => { - return Err(AiError::Conflict); + { + crate::AiConsequentialToolCallOutcome::Persisted(result) => { + AiProviderDynamicToolOutcome::Application(result) + } + crate::AiConsequentialToolCallOutcome::RecoveryRequired { .. } => { + return Err(AiError::Conflict); + } } - }, + } (AiApplicationToolDisposition::ApprovalRequired, None) => { let prepared = self .services diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 64d2ba8b..99bebdc8 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -2,7 +2,9 @@ #![cfg(any(feature = "sqlite", feature = "postgres"))] +mod media; mod native_preflight; +mod task_context; pub(crate) use native_preflight::{ NativePreflightRefusal, NativeToolClassification, native_preflight_failure_code, }; @@ -943,15 +945,14 @@ impl OrmAiApplicationToolCallService { ) -> Result { let fallback_context = context.clone(); let fallback_route = route.clone(); - match self - .execute_unapproved( - lease, - provider_result, - context, - route, - UnapprovedToolMode::ReadOnly, - ) - .await + match Box::pin(self.boxed_unapproved( + lease, + provider_result, + context, + route, + UnapprovedToolMode::ReadOnly, + )) + .await { Ok(AiConsequentialToolCallOutcome::Persisted(call)) => Ok(*call), Ok(AiConsequentialToolCallOutcome::RecoveryRequired { .. }) => Err(AiError::Conflict), @@ -1927,16 +1928,30 @@ impl OrmAiApplicationToolCallService { context: AiApplicationToolCallContext, route: AiToolResultEgressRoute, ) -> Result { - self.execute_unapproved( + Box::pin(self.boxed_unapproved( lease, provider_result, context, route, UnapprovedToolMode::AutomaticMutation, - ) + )) .await } + // Construct the owning effect future in a short-lived frame that returns before polling. + // Boxing inside an async poll keeps large construction temporaries on the nested stack. + #[inline(never)] + fn boxed_unapproved<'a>( + &'a self, + lease: &'a AiRunLease, + provider_result: &'a AiProviderCallResult, + context: AiApplicationToolCallContext, + route: AiToolResultEgressRoute, + mode: UnapprovedToolMode, + ) -> futures::future::BoxFuture<'a, Result> { + Box::pin(self.execute_unapproved(lease, provider_result, context, route, mode)) + } + async fn execute_unapproved( &self, lease: &AiRunLease, @@ -2150,6 +2165,11 @@ impl OrmAiApplicationToolCallService { } else { None }; + let provenance = self + .bind_original_provider_cursor(lease, provenance) + .await?; + self.runtime + .preflight_tool_result_media(&descriptor, provider_result.provider_kind())?; let protected_arguments = self .protect( &policy, @@ -2264,6 +2284,9 @@ impl OrmAiApplicationToolCallService { } } + let provenance = provenance + .map(|origin| origin.with_effect_execution(lease)) + .transpose()?; let execution = tokio::time::timeout(self.limits.maximum_execution_time.unsigned_abs(), async { if generated_query { @@ -2343,6 +2366,15 @@ impl OrmAiApplicationToolCallService { ) } }; + let provenance = provenance.map(|origin| { + match ( + policy_version.as_deref(), + authorization_state_digest.as_deref(), + ) { + (Some(policy), Some(digest)) => origin.with_completed_authorization(policy, digest), + _ => origin, + } + }); #[cfg(test)] if let Err(error) = self.inject_automatic_mutation_fault(mode, AutomaticMutationTestFault::AfterResolver) @@ -2359,7 +2391,7 @@ impl OrmAiApplicationToolCallService { Err(_) => Err(error), }; } - let finalization: Result = async { + let finalization: Result = Box::pin(async { #[cfg(test)] self.inject_automatic_mutation_fault( mode, @@ -2372,6 +2404,40 @@ impl OrmAiApplicationToolCallService { if output_bytes.len() > self.limits.maximum_model_output_bytes { return Err(AiError::ToolExecutionFailed); } + let retained = match (&provenance, failure_code) { + (Some(origin), None) + if descriptor.result_media.is_some() + || classification >= DataClassification::Restricted => + { + Some( + Box::pin(self.retain_disclosure_source( + lease, + &descriptor, + origin, + &context, + &policy, + &model_output, + &disclosure_fingerprint, + classification, + source_trust, + )) + .await?, + ) + } + _ => None, + }; + let retained_context = if retained.is_some() { + Some( + self.reopen_tool_result_source( + lease, + id, + crate::AiToolResultDisclosurePurpose::ToolResult, + ) + .await?, + ) + } else { + None + }; let outbound_bytes = output_bytes .len() .checked_add(provider_call.call_id().len()) @@ -2394,12 +2460,17 @@ impl OrmAiApplicationToolCallService { 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, - }], + sources: vec![ + retained_context + .as_ref() + .map(|source| source.data_source()) + .unwrap_or_else(|| 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, @@ -2410,10 +2481,19 @@ impl OrmAiApplicationToolCallService { policy_version: route.policy_version, consent_reference: route.consent_reference, }; - let decision = self - .runtime - .authorize_egress(lease.principal_reference(), &manifest) - .await?; + let decision = if let Some(source) = &retained_context { + self.runtime + .authorize_tool_source_egress( + lease.principal_reference(), + &manifest, + std::slice::from_ref(source), + ) + .await? + } else { + 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() { @@ -2447,8 +2527,10 @@ impl OrmAiApplicationToolCallService { }; #[cfg(test)] self.inject_automatic_mutation_fault(mode, AutomaticMutationTestFault::ProtectResult)?; - let protected_result = self - .protect( + let protected_result = if let Some(retained) = &retained { + retained.protected_result.clone() + } else { + self.protect( &policy, protection_context( "graphql_orm_ai_tool_calls", @@ -2458,7 +2540,8 @@ impl OrmAiApplicationToolCallService { ), model_output, ) - .await?; + .await? + }; let event_id = Uuid::new_v4(); let inbox_event_id = Uuid::new_v4(); let protected_event = self @@ -2499,7 +2582,7 @@ impl OrmAiApplicationToolCallService { self.inject_automatic_mutation_fault(mode, AutomaticMutationTestFault::FinishResult)?; let renewed = self .run_service - .finish_tool_call( + .finish_tool_call_with_source( lease, PreparedToolCallFinish { id: id.0, @@ -2530,6 +2613,7 @@ impl OrmAiApplicationToolCallService { expected_scope_id: context.scope.id, expected_tenant_id: context.scope.tenant_id, }, + retained.map(|source| source.protected_source), ) .await?; Ok(AiConsequentialToolCallOutcome::Persisted(Box::new( @@ -2543,7 +2627,7 @@ impl OrmAiApplicationToolCallService { lease: renewed, }, ))) - } + }) .await; match finalization { Ok(outcome) => Ok(outcome), @@ -3170,6 +3254,13 @@ impl OrmAiConsequentialToolCallService { } else { None }; + let provenance = self + .application_tools + .bind_original_provider_cursor(lease, provenance) + .await?; + self.application_tools + .runtime + .preflight_tool_result_media(&descriptor, provider_result.provider_kind())?; let call = PreparedToolCallStart { execution_provenance: provenance, native_preflight_refusal: None, @@ -3695,11 +3786,30 @@ impl OrmAiConsequentialToolCallService { }) { return Err(AiError::Conflict); } + if let Some(origin) = &provenance { + self.application_tools + .runtime + .preflight_tool_result_media(&descriptor, origin.provider_kind())?; + } + if descriptor.result_media.is_some() && provenance.is_none() { + return Err(AiError::Forbidden); + } let consumed = self .approval_service .consume_approval(lease, approval_id, &binding, &preview) .await?; let (approval, running_lease) = consumed.into_parts(); + let provenance = provenance + .map(|origin| origin.with_effect_execution(&running_lease)) + .transpose()?; + let retained_origin = provenance + .clone() + .map(|origin| { + origin + .with_consumed_approval(&approval, &binding) + .map_err(|_| AiError::Conflict) + }) + .transpose()?; let execution = if let Some(prepared) = prepared { tokio::time::timeout( self.application_tools @@ -3730,7 +3840,7 @@ impl OrmAiConsequentialToolCallService { request, &approval, &binding, - provenance, + provenance.clone(), ), ) .await @@ -3776,12 +3886,15 @@ impl OrmAiConsequentialToolCallService { } }; let provider_response_id = call.provider_response_id.clone(); - if self - .application_tools - .current_access(&running_lease, &scope) - .await - .is_err() - { + let output_bytes = match serde_json::to_vec(&model_output) { + Ok(output) => output, + Err(_) => { + return self + .mark_consequential_recovery(&running_lease, tool_call_id, provider_response_id) + .await; + } + }; + if output_bytes.len() > self.application_tools.limits.maximum_model_output_bytes { return self .mark_consequential_recovery( &running_lease, @@ -3790,15 +3903,48 @@ impl OrmAiConsequentialToolCallService { ) .await; } - let output_bytes = match serde_json::to_vec(&model_output) { - Ok(output) => output, - Err(_) => { + let retained = if failure_code.is_none() + && (descriptor.result_media.is_some() + || classification >= DataClassification::Restricted) + { + let Some(origin) = retained_origin.as_ref() else { return self .mark_consequential_recovery(&running_lease, tool_call_id, provider_response_id) .await; + }; + let retained = Box::pin(self.application_tools.retain_disclosure_source( + &running_lease, + &descriptor, + origin, + &context, + &policy, + &model_output, + &disclosure_schema_fingerprint, + classification, + source_trust, + )) + .await; + match retained { + Ok(retained) => Some(retained), + Err(_) => { + return self + .mark_consequential_recovery( + &running_lease, + tool_call_id, + provider_response_id, + ) + .await; + } } + } else { + None }; - if output_bytes.len() > self.application_tools.limits.maximum_model_output_bytes { + if self + .application_tools + .current_access(&running_lease, &scope) + .await + .is_err() + { return self .mark_consequential_recovery( &running_lease, @@ -3807,6 +3953,30 @@ impl OrmAiConsequentialToolCallService { ) .await; } + let retained_context = if retained.is_some() { + let reopened = self + .application_tools + .reopen_tool_result_source( + &running_lease, + tool_call_id, + crate::AiToolResultDisclosurePurpose::ToolResult, + ) + .await; + match reopened { + Ok(context) => Some(context), + Err(_) => { + return self + .mark_consequential_recovery( + &running_lease, + tool_call_id, + provider_response_id, + ) + .await; + } + } + } else { + None + }; let Some(outbound_bytes) = output_bytes .len() .checked_add(call.provider_call_id.len()) @@ -3834,12 +4004,17 @@ impl OrmAiConsequentialToolCallService { scope: scope.clone(), session_id: Some(running_lease.session_id()), run_id: Some(running_lease.run_id()), - sources: vec![AiDataSourceRef { - kind: "application_tool_result".to_owned(), - reference: tool_call_id.0.to_string(), - classification, - trust: source_trust, - }], + sources: vec![ + retained_context + .as_ref() + .map(|source| source.data_source()) + .unwrap_or_else(|| AiDataSourceRef { + kind: "application_tool_result".to_owned(), + reference: tool_call_id.0.to_string(), + classification, + trust: source_trust, + }), + ], estimated_bytes, estimated_tokens: 0, attachment_count: 0, @@ -3849,12 +4024,22 @@ impl OrmAiConsequentialToolCallService { policy_version: route.policy_version, consent_reference: route.consent_reference, }; - let decision = match self - .application_tools - .runtime - .authorize_egress(running_lease.principal_reference(), &manifest) - .await - { + let decision_result = if let Some(source) = &retained_context { + self.application_tools + .runtime + .authorize_tool_source_egress( + running_lease.principal_reference(), + &manifest, + std::slice::from_ref(source), + ) + .await + } else { + self.application_tools + .runtime + .authorize_egress(running_lease.principal_reference(), &manifest) + .await + }; + let decision = match decision_result { Ok(decision) => decision, Err(_) => { return self @@ -3902,29 +4087,33 @@ impl OrmAiConsequentialToolCallService { failure_code.map_or_else(|| "allowed".to_owned(), |code| code.as_str().to_owned()), ) }; - let protected_result = match self - .application_tools - .protect( - &policy, - protection_context( - "graphql_orm_ai_tool_calls", - tool_call_id.0, - "protected_result", - &scope, - ), - model_output, - ) - .await - { - Ok(result) => result, - Err(_) => { - return self - .mark_consequential_recovery( - &running_lease, - tool_call_id, - provider_response_id.clone(), - ) - .await; + let protected_result = if let Some(source) = &retained { + source.protected_result.clone() + } else { + match self + .application_tools + .protect( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + tool_call_id.0, + "protected_result", + &scope, + ), + model_output, + ) + .await + { + Ok(result) => result, + Err(_) => { + return self + .mark_consequential_recovery( + &running_lease, + tool_call_id, + provider_response_id.clone(), + ) + .await; + } } }; let event_id = Uuid::new_v4(); @@ -3994,7 +4183,7 @@ impl OrmAiConsequentialToolCallService { let renewed = match self .application_tools .run_service - .finish_tool_call( + .finish_tool_call_with_source( &running_lease, PreparedToolCallFinish { id: tool_call_id.0, @@ -4025,6 +4214,7 @@ impl OrmAiConsequentialToolCallService { expected_scope_id: scope.id, expected_tenant_id: scope.tenant_id, }, + retained.map(|source| source.protected_source), ) .await { diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs new file mode 100644 index 00000000..15fc4b14 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -0,0 +1,738 @@ +//! Current protected source loading. These paths never execute application effects. + +use super::*; +use crate::tool_media::RetainedToolResultSource; +use crate::{ + AiResolvedToolImage, AiToolExecutionProvenance, AiToolImageReference, + AiToolResultDisclosureContext, AiToolResultDisclosurePurpose, +}; + +pub(super) struct RetainedDisclosure { + pub(super) protected_result: serde_json::Value, + pub(super) protected_source: serde_json::Value, +} + +impl OrmAiApplicationToolCallService { + pub(crate) async fn record_current_source_decision( + &self, + manifest: &AiEgressManifest, + decision: &crate::AiEgressDecision, + ) -> Result<(), AiError> { + self.egress_audit.record(manifest, decision).await + } + + pub(crate) async fn validate_delivery_fence(&self, lease: &AiRunLease) -> Result<(), AiError> { + self.run_service.validate_tool_disclosure_fence(lease).await + } + pub(super) async fn bind_original_provider_cursor( + &self, + lease: &AiRunLease, + provenance: Option, + ) -> Result, AiError> { + let Some(provenance) = provenance else { + return Ok(None); + }; + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + let fingerprint = if let Some(binding_id) = provenance.provider_session_binding_id() { + let binding = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + self.run_service.database(), + &binding_id, + ) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::Conflict)?; + if binding.session_id != lease.session_id().0 + || binding.state != "claimed" + || binding.claimed_run_id != Some(lease.run_id().0) + || binding.claimed_attempt_id != Some(lease.attempt_id()) + || binding.claimed_run_lease_generation != Some(lease.lease_generation()) + || binding + .claim_expires_at + .is_none_or(|expiry| expiry <= self.clock.now().unix_timestamp()) + || binding.provider_profile_id != provenance.provider_profile_id() + || binding.registration_fingerprint + != provenance + .provider_registration_fingerprint() + .ok_or(AiError::Conflict)? + { + return Err(AiError::Conflict); + } + Some(binding.cursor_fingerprint) + } else { + None + }; + match fingerprint { + Some(fingerprint) => provenance + .with_provider_cursor_fingerprint(fingerprint) + .map(Some), + None => Ok(Some(provenance)), + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) async fn retain_disclosure_source( + &self, + lease: &AiRunLease, + descriptor: &AiToolDescriptor, + provenance: &AiToolExecutionProvenance, + call_context: &AiApplicationToolCallContext, + policy: &crate::AiContentProtectionPolicy, + output: &serde_json::Value, + disclosure_fingerprint: &str, + classification: DataClassification, + trust: AiSourceTrust, + ) -> Result { + // Custody of the actual completed output is separate from fresh disclosure access. + // The owning write below still requires the exact live executing effect lease. + let protected_result = self + .protect( + policy, + protection_context( + "graphql_orm_ai_tool_calls", + provenance.tool_call_id().0, + "protected_result", + &call_context.scope, + ), + output.clone(), + ) + .await?; + self.run_service + .retain_tool_effect_output(lease, provenance, protected_result.clone()) + .await?; + self.current_access(lease, &call_context.scope).await?; + let images = extract_references( + descriptor, + output, + self.clock.now().unix_timestamp_nanos() / 1_000_000, + )?; + let provider_cursor_fingerprint = + provenance.provider_cursor_fingerprint().map(str::to_owned); + if provenance.provider_session_binding_id().is_some() + != provider_cursor_fingerprint.is_some() + { + return Err(AiError::Conflict); + } + let source = RetainedToolResultSource { + format_version: 1, + execution: provenance.clone(), + principal: lease.principal_reference().clone(), + scope: call_context.scope.clone(), + result_sha256: canonical_json_hash(output)?, + disclosure_fingerprint: disclosure_fingerprint.to_owned(), + classification, + trust, + delegation_reference: call_context.delegation_reference.clone(), + images, + provider_cursor_fingerprint: provider_cursor_fingerprint.clone(), + effect_attempt_id: lease.attempt_id(), + effect_lease_generation: lease.lease_generation(), + }; + let source_context = AiToolResultDisclosureContext::from_retained( + source.clone(), + lease, + AiToolResultDisclosurePurpose::ToolResult, + )?; + let image_count = + i64::try_from(source_context.images().len()).map_err(|_| AiError::PersistenceFailed)?; + let protected_source = self + .protect( + policy, + protection_context( + "graphql_orm_ai_tool_calls", + provenance.tool_call_id().0, + "protected_result_source", + &call_context.scope, + ), + serde_json::to_value(source).map_err(|_| AiError::PersistenceFailed)?, + ) + .await?; + self.run_service + .retain_tool_result_source( + lease, + provenance.tool_call_id(), + protected_result.clone(), + protected_source.clone(), + provenance.clone(), + source_context.source_reference(), + classification_value(classification).to_owned(), + image_count, + provider_cursor_fingerprint, + ) + .await?; + Ok(RetainedDisclosure { + protected_result, + protected_source, + }) + } + + pub(crate) async fn retained_session_sources( + &self, + lease: &AiRunLease, + inference: &AiEgressManifest, + binding_id: Uuid, + cursor_fingerprint: &str, + ) -> Result, AiError> { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(self.retained_session_sources_inner( + lease, + inference, + binding_id, + cursor_fingerprint, + )), + ) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + } + + async fn retained_session_sources_inner( + &self, + lease: &AiRunLease, + inference: &AiEgressManifest, + binding_id: Uuid, + cursor_fingerprint: &str, + ) -> Result, AiError> { + use graphql_orm::graphql::filters::{StringFilter, UuidFilter}; + self.current_access(lease, &inference.scope).await?; + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + // Query only small source-address rows. Never scan entire protected result/argument bodies + // to discover history. An incomplete bounded source list cannot admit a retained cursor. + let rows = AiToolResultSourceRecord::query(self.run_service.database().pool()) + .filter(AiToolResultSourceRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(lease.session_id().0), + ..Default::default() + }), + provider_kind: Some(StringFilter { + eq: Some(inference.provider_kind.clone()), + ..Default::default() + }), + provider_profile_id: Some(StringFilter { + eq: Some(inference.provider_profile_id.clone()), + ..Default::default() + }), + provider_model: Some(StringFilter { + eq: Some(inference.model.clone()), + ..Default::default() + }), + provider_session_binding_id: Some(UuidFilter { + eq: Some(binding_id), + ..Default::default() + }), + provider_cursor_fingerprint: Some(StringFilter { + eq: Some(cursor_fingerprint.to_owned()), + ..Default::default() + }), + ..Default::default() + }) + .limit(65) + .fetch_all() + .await + .map_err(|_| AiError::PersistenceFailed)?; + if rows.len() > 64 { + return Err(AiError::ResultBudgetExceeded); + } + let operation = async { + let mut sources = Vec::with_capacity(rows.len()); + for row in rows { + let source = self + .reopen_tool_result_source( + lease, + AiToolCallId(row.id), + AiToolResultDisclosurePurpose::History, + ) + .await?; + if source.source_reference() != row.source_reference + || source.execution_provenance().run_id().0 != row.run_id + || source.execution_provenance().provider_kind().as_str() != row.provider_kind + || source.execution_provenance().provider_profile_id() + != row.provider_profile_id + || source.execution_provenance().provider_model() != row.provider_model + || classification_value(source.classification()) != row.classification + || i64::try_from(source.images().len()).ok() != Some(row.image_count) + { + return Err(AiError::Conflict); + } + sources.push(source); + } + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + Ok(sources) + }; + tokio::time::timeout(std::time::Duration::from_secs(30), Box::pin(operation)) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + } + + pub(crate) async fn current_user_text( + &self, + lease: &AiRunLease, + scope: &AiScope, + ) -> Result { + use graphql_orm::graphql::filters::UuidFilter; + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + let principal = self.current_access(lease, scope).await?; + let policy = self + .runtime + .content_protection_policy_resolver() + .resolve(principal.principal(), scope) + .await?; + let message = + AiMessageRecord::find_by_id(self.run_service.database(), &lease.input_message_id()) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::NotFound)?; + if message.session_id != lease.session_id().0 + || message.run_id != Some(lease.run_id().0) + || message.message_role != "user" + || message.content_purged_at.is_some() + || message.completion_state != "complete" + || message.block_count != 1 + || message.author_subject.as_deref() + != Some(lease.principal_reference().subject.as_str()) + || !policy.ready + || policy.scope != *scope + { + return Err(AiError::Conflict); + } + let blocks = AiMessageBlockRecord::query(self.run_service.database().pool()) + .filter(AiMessageBlockRecordWhereInput { + message_id: Some(UuidFilter { + eq: Some(message.id), + ..Default::default() + }), + ..Default::default() + }) + .limit(2) + .fetch_all() + .await + .map_err(|_| AiError::PersistenceFailed)?; + if blocks.len() != 1 { + return Err(AiError::Conflict); + } + let block = &blocks[0]; + if block.block_index != 0 + || block.block_kind != "text" + || block.byte_count < 0 + || usize::try_from(block.byte_count) + .map_or(true, |bytes| bytes > self.limits.maximum_model_output_bytes) + { + return Err(AiError::Conflict); + } + let value = self + .open( + &policy, + protection_context( + "graphql_orm_ai_message_blocks", + block.id, + "protected_content", + scope, + ), + &block.protected_content, + ) + .await?; + let text = value + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or(AiError::Conflict)?; + if i64::try_from(text.len()).ok() != Some(block.byte_count) { + return Err(AiError::Conflict); + } + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + Ok(text.to_owned()) + } + + /// Reopens one original protected tool-result source under the current disclosure lease. + /// This derives identity from retained owner records and independently checks arguments, + /// result bytes, immutable profile and selected references. It never repeats the tool. + /// Legacy results without retained source metadata gain no source-specific allowance. + /// + /// # Errors + /// Returns a safe error for a cancelled/stale fence, inaccessible scope, missing/purged result, + /// changed descriptor, mismatched origin/content/resource or invalid protection envelope. + pub async fn reopen_tool_result_source( + &self, + lease: &AiRunLease, + id: AiToolCallId, + purpose: AiToolResultDisclosurePurpose, + ) -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(self.reopen_tool_result_source_inner(lease, id, purpose)), + ) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + } + + async fn reopen_tool_result_source_inner( + &self, + lease: &AiRunLease, + id: AiToolCallId, + purpose: AiToolResultDisclosurePurpose, + ) -> Result { + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + let call = AiToolCallRecord::find_by_id(self.run_service.database(), &id.0) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::NotFound)?; + let step = AiRunStepRecord::find_by_id(self.run_service.database(), &id.0) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::NotFound)?; + let original_run = AiRunRecord::find_by_id(self.run_service.database(), &call.run_id) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::NotFound)?; + let session = + AiSessionRecord::find_by_id(self.run_service.database(), &lease.session_id().0) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::NotFound)?; + let scope = AiScope { + kind: session.scope_kind.clone(), + id: session.scope_id.clone(), + tenant_id: session.tenant_id.clone(), + }; + validate_session_binding(&session, lease, &scope)?; + let principal = self.current_access(lease, &scope).await?; + let policy = self + .runtime + .content_protection_policy_resolver() + .resolve(principal.principal(), &scope) + .await?; + if !policy.ready + || policy.scope != scope + || call.payload_purged_at.is_some() + || original_run.session_id != lease.session_id().0 + || step.run_id != call.run_id + || step.lease_generation != call.lease_generation + || step.step_kind != "application_tool" + || !(matches!( + call.state.as_str(), + "completed" | "execution_failed" | "egress_denied" | "egress_audit_failed" + ) && step.state == call.state + && call.completed_at.is_some() + && step.finished_at.is_some() + || call.state == "source_retained" && step.state == "running") + { + return Err(AiError::Conflict); + } + let source_value = self + .open( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + id.0, + "protected_result_source", + &scope, + ), + call.protected_result_source + .as_ref() + .ok_or(AiError::Forbidden)?, + ) + .await?; + let source: RetainedToolResultSource = + serde_json::from_value(source_value).map_err(|_| AiError::PersistenceFailed)?; + let descriptor = self + .runtime + .tool_catalog() + .descriptor(&AiToolId::parse(call.tool_id.clone())?) + .cloned() + .ok_or(AiError::Forbidden)?; + let origin = &source.execution; + let original_principal: agql_auth::PrincipalReference = + serde_json::from_value(original_run.principal_reference) + .map_err(|_| AiError::PersistenceFailed)?; + if source.scope != scope + || source.principal != original_principal + || origin.run_id().0 != call.run_id + || origin.session_id() != lease.session_id() + || origin.tool_call_id() != id + || source.effect_lease_generation != call.lease_generation + || origin.effect_attempt_id() != Some(source.effect_attempt_id) + || origin.effect_lease_generation() != Some(source.effect_lease_generation) + || source.provider_cursor_fingerprint.as_deref() != origin.provider_cursor_fingerprint() + || origin.approval_id().map(|id| id.0) != call.approval_id + || (call.state != "source_retained" + && (call.result_classification.as_deref() + != Some(classification_value(source.classification)) + || call.disclosure_schema_fingerprint.as_deref() + != Some(source.disclosure_fingerprint.as_str()))) + || origin.provider_call_id() != call.provider_call_id + || origin.tool_fingerprint() != call.tool_fingerprint + || descriptor.fingerprint != call.tool_fingerprint + || origin.argument_hash() != call.argument_hash + || origin.provider_kind().as_str() + != call.provider_kind.as_deref().ok_or(AiError::Conflict)? + || origin.provider_model() != call.provider_model.as_deref().ok_or(AiError::Conflict)? + || origin.provider_response_id() != call.provider_response_id.as_deref() + || Some(origin.budget_reservation_id().0) != call.budget_reservation_id + || source.delegation_reference != call.delegation_reference + || call.execution_provenance.as_ref() + != Some(&serde_json::to_value(origin).map_err(|_| AiError::PersistenceFailed)?) + { + return Err(AiError::Conflict); + } + let arguments = self + .open( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + id.0, + "protected_arguments", + &scope, + ), + call.protected_arguments.as_ref().ok_or(AiError::Conflict)?, + ) + .await?; + let output = self + .open( + &policy, + protection_context( + "graphql_orm_ai_tool_calls", + id.0, + "protected_result", + &scope, + ), + call.protected_result.as_ref().ok_or(AiError::Conflict)?, + ) + .await?; + if canonical_json_hash(&arguments)? != call.argument_hash + || canonical_json_hash(&output)? != source.result_sha256 + || source.images != extract_references_at_capture(&descriptor, &output)? + || descriptor.graphql_contract.as_ref().is_none_or(|contract| { + contract.disclosure_schema_fingerprint != source.disclosure_fingerprint + }) + || !matches!( + source.trust, + AiSourceTrust::ResolverResult | AiSourceTrust::TrustedRuntime + ) + { + return Err(AiError::Conflict); + } + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + AiToolResultDisclosureContext::from_retained(source, lease, purpose) + } + + pub(crate) async fn authorize_tool_images( + &self, + lease: &AiRunLease, + id: AiToolCallId, + tool_manifest: &AiEgressManifest, + ) -> Result, AiError> { + let context = self + .reopen_tool_result_source(lease, id, AiToolResultDisclosurePurpose::ToolResult) + .await?; + if tool_manifest.capability != AiEgressCapability::ToolResult + || tool_manifest.sources != vec![context.data_source()] + || tool_manifest.scope != *context.scope() + || tool_manifest.session_id != Some(lease.session_id()) + || tool_manifest.run_id != Some(lease.run_id()) + { + return Err(AiError::Conflict); + } + // Revalidate text/tree disclosure too: authorization may have changed after effect retention. + let text_decision = self + .runtime + .authorize_tool_source_egress( + lease.principal_reference(), + tool_manifest, + std::slice::from_ref(&context), + ) + .await?; + self.egress_audit + .record(tool_manifest, &text_decision) + .await?; + text_decision.authorize(tool_manifest)?; + self.authorize_retained_image_analysis(lease, &context, tool_manifest) + .await + } + + pub(crate) async fn authorize_retained_image_analysis( + &self, + lease: &AiRunLease, + context: &AiToolResultDisclosureContext, + source_manifest: &AiEgressManifest, + ) -> Result, AiError> { + let images = self.reopen_tool_images(lease, context).await?; + if images.is_empty() { + return Ok(images); + } + let mut manifest = source_manifest.clone(); + manifest.capability = AiEgressCapability::ImageAnalysis; + manifest.attachment_count = + u32::try_from(images.len()).map_err(|_| AiError::EgressDenied)?; + manifest.sources = images + .iter() + .map(|image| image.reference().egress_source(context, image.byte_count())) + .collect(); + // Inline base64 expands exact bytes by 4/3; include MIME/data URI and typed frame overhead. + manifest.estimated_bytes = images + .iter() + .try_fold(0_u64, |sum, image| { + sum.checked_add(image.byte_count().div_ceil(3).checked_mul(4)?)? + .checked_add(image.reference().mime().len() as u64 + 128) + }) + .ok_or(AiError::EgressDenied)?; + let decision = self + .runtime + .authorize_tool_source_egress( + lease.principal_reference(), + &manifest, + std::slice::from_ref(context), + ) + .await?; + self.egress_audit.record(&manifest, &decision).await?; + decision.authorize(&manifest)?; + self.current_access(lease, context.scope()).await?; + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + Ok(images) + } + + pub(crate) async fn reopen_tool_images( + &self, + lease: &AiRunLease, + context: &AiToolResultDisclosureContext, + ) -> Result, AiError> { + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + let descriptor = self + .runtime + .tool_catalog() + .descriptors() + .find(|descriptor| { + descriptor.fingerprint == context.execution_provenance().tool_fingerprint() + }) + .ok_or(AiError::Forbidden)?; + if context.images().is_empty() { + return Ok(Vec::new()); + } + let policy = descriptor.result_media.as_ref().ok_or(AiError::Forbidden)?; + crate::tool_media::validate_image_reference_batch(context.images(), policy.limits())?; + let resolver = self.runtime.tool_media_resolver(policy.resolver_id())?; + let deadline = std::time::Instant::now() + + std::time::Duration::from_millis(policy.limits().maximum_resolution_ms()); + let operation = async { + let mut images = Vec::with_capacity(context.images().len()); + let mut bytes = 0_u64; + for reference in context.images() { + if std::time::Instant::now() >= deadline { + return Err(AiError::ToolExecutionFailed); + } + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + let principal = self.current_access(lease, context.scope()).await?; + let now = i64::try_from(self.clock.now().unix_timestamp_nanos() / 1_000_000) + .map_err(|_| AiError::Conflict)?; + reference.validate_at(policy.limits(), now)?; + let image = resolver + .resolve_for_provider(&principal, descriptor, context, reference) + .await?; + if std::time::Instant::now() >= deadline { + return Err(AiError::ToolExecutionFailed); + } + if image.reference() != reference { + return Err(AiError::Conflict); + } + let now = i64::try_from(self.clock.now().unix_timestamp_nanos() / 1_000_000) + .map_err(|_| AiError::Conflict)?; + image.validate_policy(policy.resolver_id(), policy.limits(), now)?; + bytes = bytes + .checked_add(image.byte_count()) + .ok_or(AiError::Forbidden)?; + if bytes > policy.limits().maximum_raw_bytes() { + return Err(AiError::Forbidden); + } + self.current_access(lease, context.scope()).await?; + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + if std::time::Instant::now() >= deadline { + return Err(AiError::ToolExecutionFailed); + } + images.push(image); + } + Ok(images) + }; + tokio::time::timeout( + std::time::Duration::from_millis(policy.limits().maximum_resolution_ms()), + Box::pin(operation), + ) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + } +} + +fn extract_references( + descriptor: &AiToolDescriptor, + output: &serde_json::Value, + now_ms: i128, +) -> Result, AiError> { + extract_references_inner( + descriptor, + output, + Some(i64::try_from(now_ms).map_err(|_| AiError::Forbidden)?), + ) +} +fn extract_references_at_capture( + descriptor: &AiToolDescriptor, + output: &serde_json::Value, +) -> Result, AiError> { + extract_references_inner(descriptor, output, None) +} +fn extract_references_inner( + descriptor: &AiToolDescriptor, + output: &serde_json::Value, + now_ms: Option, +) -> Result, AiError> { + let Some(policy) = &descriptor.result_media else { + return Ok(Vec::new()); + }; + let mut images = Vec::new(); + for path in policy.object_paths() { + let mut value = output.get("data").ok_or(AiError::Forbidden)?; + for field in path { + value = value.get(field).ok_or(AiError::Forbidden)?; + } + if value.is_null() { + continue; + } + let at = now_ms + .or_else(|| { + value + .get(policy.fields().captured_at_ms()) + .and_then(serde_json::Value::as_i64) + }) + .unwrap_or(0); + if let Some(image) = + AiToolImageReference::from_output(value, policy.fields(), policy.limits(), at)? + { + if images + .iter() + .any(|existing: &AiToolImageReference| existing.reference() == image.reference()) + { + return Err(AiError::Forbidden); + } + images.push(image); + } + } + crate::tool_media::validate_image_reference_batch(&images, policy.limits())?; + Ok(images) +} diff --git a/crates/graphql-orm-ai/src/orm_tools/task_context.rs b/crates/graphql-orm-ai/src/orm_tools/task_context.rs new file mode 100644 index 00000000..bc88bb88 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_tools/task_context.rs @@ -0,0 +1,178 @@ +//! Owning read-only original task projection. It never creates a message or repeats an effect. +use super::*; + +impl OrmAiApplicationToolCallService { + /// Reopens the original task and projects only actual completed invocation statuses. + /// + /// This read-only value keeps original message/event identities opaque and contains no + /// prior result/tree/image content. The host prices its conservative bytes/tokens, then + /// consumes the same value through [`crate::AiAgentContinuation::with_prepared_task_context`] + /// before constructing the complete final plan. User text remains untrusted context. + /// + /// # Errors + /// Rejects cancelled/obsolete leases, inactive owning sessions, current access denial, missing or purged original + /// source, non-terminal invocation rows, or more than 64 retained status records. + pub async fn prepare_task_continuation_context( + &self, + lease: &AiRunLease, + scope: &AiScope, + ) -> Result { + use crate::persistence::{AiSessionEventRecord, AiSessionEventRecordWhereInput}; + use graphql_orm::graphql::filters::{StringFilter, UuidFilter}; + let session = + AiSessionRecord::find_by_id(self.run_service.database(), &lease.session_id().0) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::Conflict)?; + validate_session_binding(&session, lease, scope)?; + let text = self.current_user_text(lease, scope).await?; + let message = + AiMessageRecord::find_by_id(self.run_service.database(), &lease.input_message_id()) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::Conflict)?; + let event = AiSessionEventRecord::query(self.run_service.database().pool()) + .filter(AiSessionEventRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(lease.session_id().0), + ..Default::default() + }), + event_type: Some(StringFilter { + eq: Some("message_queued".into()), + ..Default::default() + }), + run_id: Some(UuidFilter { + eq: Some(lease.run_id().0), + ..Default::default() + }), + correlation_id: Some(StringFilter { + eq: Some( + message + .client_message_id + .ok_or(AiError::Conflict)? + .to_string(), + ), + ..Default::default() + }), + ..Default::default() + }) + .limit(2) + .fetch_all() + .await + .map_err(|_| AiError::PersistenceFailed)?; + let [event] = event.as_slice() else { + return Err(AiError::Conflict); + }; + if event.run_id != Some(lease.run_id().0) + || message + .client_message_id + .map(|id| id.to_string()) + .as_deref() + != Some(event.correlation_id.as_str()) + { + return Err(AiError::Conflict); + } + let mut calls = AiToolCallRecord::query(self.run_service.database().pool()) + .filter(AiToolCallRecordWhereInput { + run_id: Some(UuidFilter { + eq: Some(lease.run_id().0), + ..Default::default() + }), + ..Default::default() + }) + .limit(65) + .fetch_all() + .await + .map_err(|_| AiError::PersistenceFailed)?; + if calls.len() > 64 + || calls.iter().any(|call| { + !matches!(call.state.as_str(), "completed" | "execution_failed") + || call.completed_at.is_none() + || call.payload_purged_at.is_some() + }) + { + return Err(AiError::Conflict); + } + calls.sort_by_key(|call| call.id); + let mut statuses = calls.iter().map(|call| json!({ + "toolCallId":call.id,"toolId":call.tool_id,"state":call.state, + "completedAt":call.completed_at,"invocationAlreadyCompleted":true,"replayAllowed":false + })).collect::>(); + statuses.sort_by_key(|status| status["toolCallId"].as_str().unwrap_or_default().to_owned()); + let source_fingerprint = canonical_json_hash(&json!({ + "message":message,"eventId":event.id,"eventSequence":event.sequence, + "eventSource":event.protected_payload,"goalSha256":canonical_json_hash(&json!(text))?, + "calls":calls.iter().map(|call| (call.id,call.row_version,call.lease_generation,call.state.as_str())).collect::>() + }))?; + let value = json!({ + "formatVersion":1,"kind":"FrameworkTaskContinuationContext","contextReset":true, + "originalMessageId":message.id,"originalEventId":event.id, + "originalClientMessageId":message.client_message_id, + "goalContext":{"trust":"untrusted_user_content","text":text}, + "savedInvocationStatuses":statuses,"effectsMustNotBeReplayed":true, + "priorProtectedContentIncluded":false,"newHumanEventCreated":false + }); + let frame = ModelInputBlock::Text { + text: serde_json::to_string(&value).map_err(|_| AiError::PersistenceFailed)?, + }; + let bytes = u64::try_from( + serde_json::to_vec(&frame) + .map_err(|_| AiError::PersistenceFailed)? + .len(), + ) + .map_err(|_| AiError::Conflict)?; + if bytes > 128 * 1024 { + return Err(AiError::Conflict); + } + self.run_service + .validate_tool_disclosure_fence(lease) + .await?; + Ok(crate::AiPreparedTaskContinuationContext { + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + lease_generation: lease.lease_generation(), + frame, + bytes, + sources: vec![ + AiDataSourceRef { + kind: "original_task_context".into(), + reference: source_fingerprint.clone(), + classification: DataClassification::Internal, + trust: AiSourceTrust::UserProvided, + }, + AiDataSourceRef { + kind: "saved_invocation_statuses".into(), + reference: source_fingerprint.clone(), + classification: DataClassification::Internal, + trust: AiSourceTrust::TrustedRuntime, + }, + ], + source_fingerprint, + }) + } + + pub(crate) async fn validate_task_continuation_context( + &self, + lease: &AiRunLease, + scope: &AiScope, + prepared: &crate::AiPreparedTaskContinuationContext, + ) -> Result<(), AiError> { + if prepared.session_id != lease.session_id() + || prepared.run_id != lease.run_id() + || prepared.attempt_id != lease.attempt_id() + || prepared.lease_generation != lease.lease_generation() + { + return Err(AiError::Conflict); + } + let current = self.prepare_task_continuation_context(lease, scope).await?; + if current.source_fingerprint != prepared.source_fingerprint + || current.frame != prepared.frame + || current.sources != prepared.sources + || current.bytes != prepared.bytes + { + return Err(AiError::Conflict); + } + Ok(()) + } +} diff --git a/crates/graphql-orm-ai/src/persistence.rs b/crates/graphql-orm-ai/src/persistence.rs index 7317e702..456c4e11 100644 --- a/crates/graphql-orm-ai/src/persistence.rs +++ b/crates/graphql-orm-ai/src/persistence.rs @@ -15,6 +15,9 @@ use graphql_orm::graphql::orm::{ }; use graphql_orm::prelude::*; +mod tool_media; +pub(crate) use tool_media::*; + /// Per-scope runtime and maturity policy. #[backend_selected_graphql_entity( table = "graphql_orm_ai_scope_policies", @@ -751,10 +754,12 @@ pub(crate) struct AiSessionEventRecord { #[filterable(type = "string")] pub event_type: String, /// Optional run. + #[filterable(type = "uuid")] pub run_id: Option, /// Causation reference. pub causation_id: Option, /// Correlation reference. + #[filterable(type = "string")] pub correlation_id: String, /// Protected/ciphertext payload envelope. #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] @@ -1730,6 +1735,10 @@ pub(crate) struct AiToolCallRecord { /// Protected result. #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] pub protected_result: Option, + /// Protected immutable origin/result/media correlation, retained before any source disclosure. + /// Null legacy sources cannot supply current Restricted/media authority. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_result_source: Option, /// Timestamp when retention removed protected content. pub payload_purged_at: Option, /// Risk class. @@ -2496,7 +2505,7 @@ pub(crate) struct AiRuntimeRecoveryRecord { /// Stable schema module ID. pub const AI_SCHEMA_MODULE_ID: &str = "com.dastari.graphql-orm-ai"; /// Current AI schema module version. -pub const AI_SCHEMA_MODULE_VERSION: &str = "0.71.0"; +pub const AI_SCHEMA_MODULE_VERSION: &str = "0.72.0"; /// Reserved table namespace. pub const AI_TABLE_NAMESPACE: &str = "graphql_orm_ai_"; @@ -2571,6 +2580,7 @@ impl OrmSchemaModule for AiSchemaModule { AiRunStepRecord::metadata(), AiRunCheckpointRecord::metadata(), AiToolCallRecord::metadata(), + AiToolResultSourceRecord::metadata(), AiNativeApprovalCandidateRecord::metadata(), AiApprovalRecord::metadata(), AiProposalRecord::metadata(), diff --git a/crates/graphql-orm-ai/src/persistence/tool_media.rs b/crates/graphql-orm-ai/src/persistence/tool_media.rs new file mode 100644 index 00000000..63f588fa --- /dev/null +++ b/crates/graphql-orm-ai/src/persistence/tool_media.rs @@ -0,0 +1,41 @@ +//! Small source addresses for bounded provider-history admission. These rows are correlation +//! hints only; the protected owning call/result must be reopened before every positive decision. +use super::*; + +#[backend_selected_graphql_entity( + table = "graphql_orm_ai_tool_result_sources", + plural = "GraphqlOrmAiToolResultSources", + default_sort = "created_at ASC", + index(name = "idx_graphql_orm_ai_tool_sources_cursor", columns = ["session_id", "provider_session_binding_id", "provider_cursor_fingerprint", "provider_kind", "provider_profile_id", "provider_model", "created_at"], directions = ["asc", "asc", "asc", "asc", "asc", "asc", "asc"]) +)] +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +pub(crate) struct AiToolResultSourceRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + #[filterable(type = "string")] + pub provider_kind: String, + #[filterable(type = "string")] + pub provider_profile_id: String, + #[filterable(type = "string")] + pub provider_model: String, + #[filterable(type = "uuid")] + pub provider_session_binding_id: Option, + #[filterable(type = "string")] + pub provider_cursor_fingerprint: Option, + pub source_reference: String, + pub classification: String, + pub image_count: i64, + #[sortable] + pub created_at: i64, +} diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 737041be..63e0b1e8 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -620,9 +620,15 @@ impl ModelRequest { ) { return None; } - let [ModelInputBlock::Json { value }] = self.input.as_slice() else { + let Some(ModelInputBlock::Json { value }) = self.input.first() else { return None; }; + if self.input[1..] + .iter() + .any(|block| !matches!(block, ModelInputBlock::Text { .. })) + { + return None; + } native_approved_outcome_hash(value) } @@ -1360,6 +1366,11 @@ pub struct ProviderRequestContext { budget: AuthorizedBudgetReservation, transfers: Vec, resolved_attachments: Vec, + retained_tool_images: Vec, + static_offering_reset_receipt: + Option>, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_source_delivery: Option>, } impl ProviderRequestContext { @@ -1386,10 +1397,77 @@ impl ProviderRequestContext { budget, transfers: Vec::new(), resolved_attachments: Vec::new(), + retained_tool_images: Vec::new(), + static_offering_reset_receipt: None, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_source_delivery: None, } .with_authorized_transfer(manifest, proof) } + /// Actual protected original image pixels reopened by the owning SDK source loader after + /// atomic budget admission. Adapters send these as genuine image input parts for continuation + /// or history, never base64 result text. The getter grants no resource/execution authority and + /// does not renew capture, action or retention deadlines. Current delivery revalidation remains + /// mandatory immediately before transport. + pub fn retained_tool_result_images(&self) -> &[crate::AiResolvedToolImage] { + &self.retained_tool_images + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_retained_tool_result_images( + mut self, + images: Vec, + ) -> Result { + let bytes = images + .iter() + .try_fold(0_u64, |sum, image| sum.checked_add(image.byte_count())) + .ok_or(AiError::ResultBudgetExceeded)?; + let pixels = images + .iter() + .try_fold(0_u64, |sum, image| { + sum.checked_add( + u64::from(image.reference().width()) + .checked_mul(u64::from(image.reference().height()))?, + ) + }) + .ok_or(AiError::ResultBudgetExceeded)?; + if images.len() > 4 || bytes > 16 * 1024 * 1024 || pixels > 16_000_000 { + return Err(AiError::ResultBudgetExceeded); + } + self.retained_tool_images = images; + Ok(self) + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn tool_source_delivery_check( + &self, + ) -> Option> { + self.tool_source_delivery.clone() + } + + /// Revalidates protected retained sources under the current owning principal/grant/resource + /// and uncancelled fence after adapter preparation/queueing and immediately before transport. + /// Ordinary contexts have no additional source allowance or callback. Protected callbacks + /// are privately derived from actual admitted owner records; JSON/source markers cannot + /// install them. Adapters must await this along with `validate_request` before each disclosure. + /// + /// # Errors + /// Returns a safe provider error for unavailable/revoked/expired/cancelled source content. + pub async fn authorize_tool_source_delivery(&self) -> Result<(), ProviderError> { + #[cfg(any(feature = "sqlite", feature = "postgres"))] + if let Some(delivery) = &self.tool_source_delivery { + delivery.authorize().await?; + } + Ok(()) + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_tool_source_delivery( + mut self, + delivery: Option>, + ) -> Self { + self.tool_source_delivery = delivery; + self + } + /// Adds a separately authorized built-in or attachment transfer. /// /// # Errors @@ -1453,6 +1531,26 @@ impl ProviderRequestContext { Ok(self) } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_static_offering_reset_receipt( + mut self, + receipt: Arc, + ) -> Result { + let binding = self.run_binding.ok_or(AiError::Conflict)?; + if receipt.session_id != binding.session_id() + || receipt.run_id != binding.run_id() + || receipt.attempt_id != binding.attempt_id() + || receipt.lease_generation != binding.lease_generation() + || self.provider_session.as_ref().is_none_or(|session| { + session.cursor().fingerprint() != receipt.replacement_cursor_fingerprint + }) + { + return Err(AiError::Conflict); + } + self.static_offering_reset_receipt = Some(receipt); + Ok(self) + } + /// Installs exact reopened attachment payloads for this provider request. /// /// Callers must obtain these values from a trusted @@ -1572,16 +1670,54 @@ impl ProviderRequestContext { .iter() .filter(|block| matches!(block, ModelInputBlock::Attachment { .. })) .count() as u32; + let attachment_count = attachment_count + .checked_add( + u32::try_from(self.retained_tool_images.len()) + .map_err(|_| ProviderError::EgressDenied)?, + ) + .ok_or(ProviderError::EgressDenied)?; + let image_bytes = self + .retained_tool_images + .iter() + .try_fold(0_u64, |sum, image| { + sum.checked_add(image.byte_count().div_ceil(3).checked_mul(4)?)? + .checked_add(image.reference().mime().len() as u64 + 1024) + }) + .ok_or(ProviderError::EgressDenied)?; let estimated_bytes = request.conservative_provider_egress_bytes(provider_kind)?; + let inference_bytes = estimated_bytes + .checked_add(image_bytes) + .ok_or(ProviderError::EgressDenied)?; self.require_capability( provider_kind, request, AiEgressCapability::ModelInference, attachment_count, - estimated_bytes, + inference_bytes, )?; - if let Some(wrapper_hash) = request.native_approved_outcome_hash() { + let reset_wrapper_hash = self + .static_offering_reset_receipt + .as_ref() + .and_then(|receipt| { + if request.input.is_empty() + || request.continuation.is_some() + || request.continuation_mode != ModelContinuationMode::ProviderRetained + || request.input[1..] + .iter() + .any(|input| !matches!(input, ModelInputBlock::Text { .. })) + { + return None; + } + let Some(ModelInputBlock::Json { value }) = request.input.first() else { + return None; + }; + native_approved_outcome_hash(value).filter(|hash| *hash == receipt.outcome_hash) + }); + if let Some(wrapper_hash) = request + .native_approved_outcome_hash() + .or(reset_wrapper_hash) + { let transfers = self .transfers .iter() @@ -1955,6 +2091,11 @@ pub enum ProviderError { /// Provider rejected safe request metadata. #[error("provider rejected request")] Rejected, + /// Current protected tool source became unavailable at the delivery checkpoint. This proves + /// no dispatch only when carried by `RejectedBeforeDispatch`; after possible transport the + /// owning executor keeps the reservation uncertain and permits no automatic effect replay. + #[error("provider protected tool source is unavailable")] + ToolSourceUnavailable, /// A completed, metered stateless turn contained one provider-native item /// that the adapter's deployment contract proves was contained outside /// the admitted host tool surface. @@ -1996,6 +2137,7 @@ impl ProviderError { | Self::BudgetDenied | Self::Unsupported | Self::Rejected + | Self::ToolSourceUnavailable | Self::StatelessNativeItemRejected | Self::NewlyBoundTurnRejected(_) => AiProviderFailureCategory::ProviderRejection, Self::RetainedTurnRejected(_) => AiProviderFailureCategory::RetainedResumeRejection, @@ -2480,6 +2622,8 @@ pub struct ProviderDynamicToolResult { call_id: String, tool_id: String, output: serde_json::Value, + images: Vec, + requires_source_delivery: bool, } impl std::fmt::Debug for ProviderDynamicToolResult { @@ -2500,6 +2644,8 @@ impl ProviderDynamicToolResult { call_id, tool_id, output: serde_json::json!({"ready": true}), + images: Vec::new(), + requires_source_delivery: false, } } @@ -2519,6 +2665,8 @@ impl ProviderDynamicToolResult { call_id: call.call_id.clone(), tool_id: call.tool_id.clone(), output, + images: Vec::new(), + requires_source_delivery: false, }) } @@ -2537,6 +2685,8 @@ impl ProviderDynamicToolResult { call_id, tool_id, output, + images: Vec::new(), + requires_source_delivery: false, }) } @@ -2554,6 +2704,37 @@ impl ProviderDynamicToolResult { pub fn output(&self) -> &serde_json::Value { &self.output } + + /// Actual currently authorized typed image content for this exact call. Output reference JSON + /// alone supplies no image. Adapters must use real image parts, never serialize these bytes + /// into the result text or fetch a source URL/path. + pub fn images(&self) -> &[crate::AiResolvedToolImage] { + &self.images + } + + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn require_source_delivery(mut self, required: bool) -> Self { + self.requires_source_delivery = required; + self + } + + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn with_images( + mut self, + images: Vec, + ) -> Result { + if images.len() > 4 + || images + .iter() + .map(crate::AiResolvedToolImage::byte_count) + .sum::() + > 16 * 1024 * 1024 + { + return Err(ProviderError::Rejected); + } + self.images = images; + Ok(self) + } } /// Current closed application-tool failure envelope version. @@ -2726,6 +2907,24 @@ pub trait ProviderDynamicToolResponder: Send + Sync { call: ProviderDynamicToolCall, ) -> Result; + /// Revalidates the exact retained result immediately before adapter transport. Adapters must + /// await this after callback completion and before encoding/sending protected image content. + /// The compatibility default admits ordinary text only; images require the owning coordinator. + /// This never executes a tool, renews retention or allocates another effect reservation. + /// + /// # Errors + /// Returns a safe error for revoked/cancelled/stale disclosure or unavailable source evidence. + async fn authorize_delivery( + &self, + result: &ProviderDynamicToolResult, + ) -> Result<(), ProviderError> { + if result.images().is_empty() && !result.requires_source_delivery { + Ok(()) + } else { + Err(ProviderError::Rejected) + } + } + /// Persists a schema-invalid request without executing its tool. /// /// The returned correction envelope must pass current authorization, @@ -3262,6 +3461,28 @@ pub trait AiProvider: Send + Sync { /// Adapter/model capabilities used before routing. fn capabilities(&self) -> ProviderCapabilities; + /// Whether this adapter revalidates protected tool sources after queueing and immediately + /// before every actual text/image delivery. The default refuses declared Restricted/media + /// effects and protected retained history; ordinary text is unchanged. Returning true must + /// include both `ProviderDynamicToolResponder::authorize_delivery` and the owning request + /// context's current source guard, preserved across any host process queue. + fn supports_tool_result_source_delivery(&self) -> bool { + false + } + + /// Whether this adapter has a reviewed typed image tool-result transport. The compatibility + /// default denies media-required effects before application execution; JSON references are + /// never treated as image content. + fn supports_tool_result_images(&self) -> bool { + false + } + + /// Preflights the immutable media/text ceiling against this adapter's actual typed frame + /// contract before an effect is admitted. The compatibility default refuses the policy. + fn supports_tool_result_image_policy(&self, _descriptor: &crate::AiToolDescriptor) -> bool { + false + } + /// Starts a streaming request. The context can only be constructed from an /// allowed exact egress decision. async fn stream( @@ -3296,6 +3517,9 @@ pub trait AiProvider: Send + Sync { if let Err(error) = self.prepare_dispatch(&request, &context).await { return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); } + if let Err(error) = context.authorize_tool_source_delivery().await { + return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } match self.stream(request, context).await { Ok(stream) => AiProviderDispatchOutcome::Dispatched(stream), Err(error) => AiProviderDispatchOutcome::FailedAfterPossibleDispatch(error), @@ -3333,6 +3557,9 @@ pub trait AiProvider: Send + Sync { if let Err(error) = self.prepare_dispatch(&request, &context).await { return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); } + if let Err(error) = context.authorize_tool_source_delivery().await { + return AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } match self .stream_with_dynamic_tools(request, context, responder) .await diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index f540de4b..2ff19988 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -26,6 +26,16 @@ use crate::{ ProviderKind, ProviderRequestContext, ToolMaturity, classify_safe_application_tool_error, }; +mod tool_media; +use tool_media::{ + ordinary_attachment_bytes, ordinary_attachment_count, retained_image_egress_bytes, + validate_combined_media_batch, +}; +mod static_offering; +pub use static_offering::AiUnstartedStaticOfferingReplan; +mod tool_source_delivery; +pub(crate) use tool_source_delivery::ToolSourceDelivery; + #[path = "provider_maintenance.rs"] pub(crate) mod maintenance; @@ -248,6 +258,13 @@ pub struct AiProviderCallPlan { correlation_id: String, tool_rule_bindings: Vec, classified_native: bool, + capability_offering: Option, + native_reset_evidence: Option>, + task_context: Option>, + replan_continuation: Option>, + task_context_only: bool, + static_offering_reset_receipt: + Option>, } #[derive(Clone, Debug)] @@ -355,6 +372,12 @@ impl AiProviderCallPlan { correlation_id, tool_rule_bindings: Vec::new(), classified_native: false, + capability_offering: None, + native_reset_evidence: None, + task_context: None, + replan_continuation: None, + task_context_only: false, + static_offering_reset_receipt: None, }) } @@ -802,8 +825,11 @@ impl AiProviderCallPlan { static_policy: &AiToolPolicySet, generated_targets: &crate::AiGeneratedGraphqlTargetPolicySet, ) -> Result { - transfers.extend(continuation.apply_with_transfers(&mut request)?); - Self::new_with_bound_capability_surface( + let native_reset_evidence = continuation.native_reset_evidence.clone(); + let task_context = continuation.task_context.clone(); + let replan_continuation = Arc::new(continuation.clone()); + transfers.extend(continuation.apply_with_bound_task_context(&mut request)?); + let mut plan = Self::new_with_bound_capability_surface( provider_kind, request, budget, @@ -814,7 +840,43 @@ impl AiProviderCallPlan { static_policy, generated_targets, true, - ) + )?; + plan.native_reset_evidence = native_reset_evidence; + if let Some(context) = &task_context { + // A fresh cursor has no historical source index. Carry the exact SDK-verified + // completed native call into the whole inference proof, so its retained pixels + // are reopened and checked against the atomic image quote before transport. + let native_source = plan.native_reset_evidence.as_ref().and_then(|evidence| { + plan.transfers + .iter() + .filter(|manifest| manifest.capability == AiEgressCapability::ToolResult) + .flat_map(|manifest| &manifest.sources) + .find(|source| { + source.kind == "application_tool_result" + && source.reference.starts_with("v2:") + && crate::tool_media::source_call_hint(&source.reference) + == Some(evidence.tool_call_id) + }) + .cloned() + }); + let inference = plan + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(AiError::EgressDenied)?; + inference.sources.extend(context.sources.iter().cloned()); + if let Some(source) = native_source + && !inference.sources.contains(&source) + { + inference.sources.push(source); + } + if plan.budget.estimate.input_tokens < inference.estimated_tokens { + return Err(AiError::PreTransportBudgetDenied); + } + } + plan.task_context = task_context; + plan.replan_continuation = Some(replan_continuation); + Ok(plan) } /// Creates an initial provider call exposing exactly one crate-owned @@ -969,6 +1031,7 @@ impl AiProviderCallPlan { )?; plan.tool_rule_bindings = bindings; plan.classified_native = classified; + plan.capability_offering = surface.freeze_offering()?; Ok(plan) } @@ -1680,6 +1743,12 @@ impl AiProviderCallPlan { transfers: Vec::new(), correlation_id: uuid::Uuid::new_v4().to_string(), classified_native: false, + capability_offering: None, + native_reset_evidence: None, + task_context: None, + replan_continuation: None, + task_context_only: false, + static_offering_reset_receipt: None, tool_rule_bindings: vec![AiPlanToolRuleBinding { fingerprint: "test-fingerprint".to_owned(), maturity: ToolMaturity::ReadOnly, @@ -1902,6 +1971,8 @@ pub struct AiProviderCallResult { interactive_tool_results: Vec, native_control_receipts: Vec, provider_session_claim: Option, + static_offering_reset_receipt: + Option>, } /// Authoritative completed provider-hosted tool counts for one turn. @@ -1985,6 +2056,7 @@ pub struct AiProviderUsageObservation { output_tokens: u64, cached_input_tokens: u64, builtin_usage: AiProviderBuiltinUsage, + tool_result_image_units: u64, } impl AiProviderUsageObservation { @@ -2007,6 +2079,7 @@ impl AiProviderUsageObservation { output_tokens: usage.output_tokens(), cached_input_tokens: usage.cached_input_tokens(), builtin_usage: AiProviderBuiltinUsage::default(), + tool_result_image_units: 0, } } @@ -2031,6 +2104,7 @@ impl AiProviderUsageObservation { output_tokens, cached_input_tokens, builtin_usage, + tool_result_image_units: 0, } } @@ -2073,6 +2147,14 @@ impl AiProviderUsageObservation { pub const fn builtin_usage(&self) -> AiProviderBuiltinUsage { self.builtin_usage } + + /// SDK-derived number of actual typed tool-result images handed to the reviewed transport. + /// The completed-effect output and this count cannot be supplied by provider/model JSON. + /// Settlement must include these image units; ambiguous transport retains the uncertain + /// turn reservation and never releases them as unused or reports authoritative zero usage. + pub const fn tool_result_image_units(&self) -> u64 { + self.tool_result_image_units + } } /// Deployment-owned immutable pricing and billable-unit settlement. @@ -2140,6 +2222,11 @@ impl AiProviderDynamicToolOutcome { /// A control receipt reports that the requested effect was not executed. #[async_trait] pub trait AiProviderDynamicToolExecution: Send + Sync { + /// Owning retained result service for typed images and fresh source policy. The compatibility + /// default prevents required-media effects before execution; this service never reruns effects. + fn tool_result_source_service(&self) -> Option<&crate::OrmAiApplicationToolCallService> { + None + } /// Executes one exact normalized call from a still-active provider turn. /// /// # Errors @@ -2213,6 +2300,11 @@ pub trait AiProviderDynamicToolExecution: Send + Sync { } struct DynamicToolResponder { + runtime: Arc, + maximum_image_units: u64, + allocated_image_units: Mutex, + allocated_image_bytes: Mutex, + disclosed_image_units: Mutex, lease: Arc>, execution: Arc, session_id: crate::AiSessionId, @@ -2296,6 +2388,74 @@ impl ProviderDynamicToolResponder for DynamicToolResponder { .await } + async fn authorize_delivery( + &self, + result: &ProviderDynamicToolResult, + ) -> Result<(), ProviderError> { + let _callback = self.callbacks.lock().await; + let lease = self.lease.lock().await; + let results = self.results.lock().await; + let matches = results.iter().filter(|saved| matches!(saved.model_input(), Some(ModelInputBlock::ToolResult { call_id, tool_id, output }) if call_id == result.call_id() && tool_id == result.tool_id() && output == result.output())).collect::>(); + let [saved] = matches.as_slice() else { + return Err(ProviderError::Rejected); + }; + let AiProviderDynamicToolOutcome::Application(application) = saved else { + if result.images().is_empty() { + return Ok(()); + } + return Err(ProviderError::Rejected); + }; + let manifest = application + .egress_manifest() + .ok_or(ProviderError::Rejected)?; + let service = self.execution.tool_result_source_service(); + if let Some(service) = service { + if !std::ptr::eq(self.runtime.as_ref(), service.runtime()) { + return Err(ProviderError::Rejected); + } + service + .validate_delivery_fence(&lease) + .await + .map_err(|_| ProviderError::Rejected)?; + if manifest + .sources + .iter() + .any(|source| source.reference.starts_with("v2:")) + { + let images = service + .authorize_tool_images(&lease, application.id(), manifest) + .await + .map_err(|_| ProviderError::Rejected)?; + if images.len() != result.images().len() + || images.iter().zip(result.images()).any(|(fresh, prior)| { + fresh.reference() != prior.reference() + || fresh.resource() != prior.resource() + || fresh.bytes() != prior.bytes() + }) + { + return Err(ProviderError::Rejected); + } + } else if !result.images().is_empty() { + return Err(ProviderError::Rejected); + } + } else if !result.images().is_empty() { + return Err(ProviderError::Rejected); + } + if !manifest + .sources + .iter() + .any(|source| source.reference.starts_with("v2:")) + { + self.runtime + .authorize_egress(lease.principal_reference(), manifest) + .await + .map_err(|_| ProviderError::Rejected)? + .authorize(manifest) + .map_err(|_| ProviderError::Rejected)?; + } + Ok(()) + } + async fn reject_invalid_arguments( &self, call: crate::ProviderInvalidDynamicToolCall, @@ -2336,6 +2496,17 @@ impl DynamicToolResponder { && definition.fingerprint == call.tool_fingerprint() }) .ok_or(ProviderError::Rejected)?; + let media_limits = self + .runtime + .tool_catalog() + .descriptor(call.tool_id()) + .filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint()) + .and_then(|descriptor| descriptor.result_media.as_ref()) + .map(|policy| policy.limits()); + let media_ceiling = media_limits.map_or(0, |limits| u64::from(limits.maximum_images())); + let media_byte_ceiling = media_limits.map_or(0, |limits| limits.maximum_raw_bytes()); + // Reserve from the existing atomic uncertain turn reservation before any effect. Ordered + // callbacks share one ceiling; duplicate call identities below never allocate twice. let (tool_call_index, tool_calls) = { let mut calls = self.calls.lock().await; if calls @@ -2378,7 +2549,16 @@ impl DynamicToolResponder { Some(claim) => Some(claim.lock().await.clone()), None => None, }, + static_offering_reset_receipt: None, }; + let requires_source = self + .runtime + .tool_catalog() + .descriptor(call.tool_id()) + .is_some_and(|descriptor| { + descriptor.result_media.is_some() + || descriptor.maximum_classification >= crate::DataClassification::Restricted + }); // Reserve the final admitted callback for a durable no-execution // response. Do not reinterpret a failed persistence attempt as a // second failure result: its original outcome may be uncertain. @@ -2387,6 +2567,40 @@ impl DynamicToolResponder { } else { rejected_arguments.then_some(crate::AiApplicationToolFailureCode::InvalidArguments) }; + if failure.is_none() + && requires_source + && !self + .execution + .tool_result_source_service() + .is_some_and(|service| std::ptr::eq(self.runtime.as_ref(), service.runtime())) + { + return Err(ProviderError::Rejected); + } + if failure.is_none() && media_ceiling > 0 { + if !self + .execution + .tool_result_source_service() + .is_some_and(|service| std::ptr::eq(self.runtime.as_ref(), service.runtime())) + { + return Err(ProviderError::Rejected); + } + let mut allocated = self.allocated_image_units.lock().await; + let next = allocated + .checked_add(media_ceiling) + .ok_or(ProviderError::BudgetDenied)?; + if next > self.maximum_image_units { + return Err(ProviderError::BudgetDenied); + } + let mut allocated_bytes = self.allocated_image_bytes.lock().await; + let next_bytes = allocated_bytes + .checked_add(media_byte_ceiling) + .ok_or(ProviderError::BudgetDenied)?; + if next_bytes > 16 * 1024 * 1024 { + return Err(ProviderError::BudgetDenied); + } + *allocated = next; + *allocated_bytes = next_bytes; + } let persisted = if let Some(code) = failure { let persisted = self .execution @@ -2443,6 +2657,53 @@ impl DynamicToolResponder { }) if call_id == call.call_id() && tool_id == call.tool_id().as_str() => output.clone(), _ => return Err(ProviderError::Rejected), }; + let application_result = matches!(&persisted, AiProviderDynamicToolOutcome::Application(_)); + let images = if media_ceiling > 0 && failure.is_none() && application_result { + let AiProviderDynamicToolOutcome::Application(application) = &persisted else { + unreachable!() + }; + self.execution + .tool_result_source_service() + .ok_or(ProviderError::Rejected)? + .authorize_tool_images( + &lease, + application.id(), + application + .egress_manifest() + .ok_or(ProviderError::Rejected)?, + ) + .await + .map_err(|_| ProviderError::Rejected)? + } else { + Vec::new() + }; + let actual_bytes = images + .iter() + .try_fold(0_u64, |sum, image| sum.checked_add(image.byte_count())) + .ok_or(ProviderError::BudgetDenied)?; + if actual_bytes > media_byte_ceiling { + return Err(ProviderError::BudgetDenied); + } + let actual_images = u64::try_from(images.len()).map_err(|_| ProviderError::Rejected)?; + if actual_images > media_ceiling { + return Err(ProviderError::BudgetDenied); + } + // Capacity unused by an actual completed no-image result can be allocated to another + // callback; ambiguous errors never reach this release and remain reserved/uncertain. + if media_ceiling > 0 && failure.is_none() { + let mut allocated = self.allocated_image_units.lock().await; + *allocated = allocated + .checked_sub(media_ceiling - actual_images) + .ok_or(ProviderError::BudgetDenied)?; + let mut allocated_bytes = self.allocated_image_bytes.lock().await; + *allocated_bytes = allocated_bytes + .checked_sub(media_byte_ceiling - actual_bytes) + .ok_or(ProviderError::BudgetDenied)?; + let mut disclosed = self.disclosed_image_units.lock().await; + *disclosed = disclosed + .checked_add(actual_images) + .ok_or(ProviderError::BudgetDenied)?; + } drop(lease); self.results.lock().await.push(persisted); if rejected_arguments { @@ -2451,11 +2712,48 @@ impl DynamicToolResponder { .await .insert(call.call_id().to_owned()); } - ProviderDynamicToolResult::persisted(call.call_id, call.tool_id.as_str().to_owned(), output) + ProviderDynamicToolResult::persisted( + call.call_id, + call.tool_id.as_str().to_owned(), + output, + )? + .with_images(images) + .map(|result| { + result + .require_source_delivery(requires_source && failure.is_none() && application_result) + }) } } impl AiProviderCallResult { + pub(crate) fn is_withheld_native_task_context(&self) -> bool { + self.static_offering_reset_receipt + .as_ref() + .is_some_and(|receipt| receipt.task_context_only) + } + + pub(crate) fn proves_static_offering_reset_from(&self, expected: Option<&str>) -> bool { + self.static_offering_reset_receipt + .as_ref() + .is_some_and(|receipt| { + expected == Some(receipt.original_continuation_reference.as_str()) + && self.previous_continuation_reference.is_none() + && receipt.session_id == self.session_id + && receipt.run_id == self.run_id + && receipt.attempt_id == self.attempt_id + && receipt.lease_generation == self.lease_generation + && receipt.cleanup_generation > 0 + && crate::valid_sha256(&receipt.original_cursor_fingerprint) + && crate::valid_sha256(&receipt.replacement_cursor_fingerprint) + && receipt.original_cursor_fingerprint != receipt.replacement_cursor_fingerprint + && crate::valid_sha256(&receipt.offering_fingerprint) + && self + .provider_session_claim + .as_ref() + .is_some_and(|claim| claim.binding_id() == receipt.replacement_binding_id) + && crate::valid_sha256(&receipt.outcome_hash) + }) + } pub(crate) fn checkpoint_value(&self) -> serde_json::Value { serde_json::json!({ "formatVersion": 2, @@ -2944,6 +3242,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } @@ -3052,6 +3351,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } @@ -3106,6 +3406,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } } @@ -3160,6 +3461,8 @@ pub struct AiProviderCallExecutor { attachment_resolver: Option>, attachment_limits: AiProviderAttachmentResolutionLimits, failure_diagnostic_sink: Option>, + tool_result_source_service: Option>, + static_offering_deletion: Option>, } // Transport fragment boundaries carry no semantic identity. Compact only @@ -3239,9 +3542,44 @@ impl AiProviderCallExecutor { attachment_resolver: None, attachment_limits: AiProviderAttachmentResolutionLimits::default(), failure_diagnostic_sink: None, + tool_result_source_service: None, + static_offering_deletion: None, } } + /// Installs the owning current protected-result loader for history and continuation. + /// Required for source-specific allowances: a result UUID/JSON or a prior allow decision + /// never substitutes for current protected origin/resource/grant checks. + /// + /// # Errors + /// Returns a configuration error when the service belongs to a different runtime. + pub fn with_tool_result_source_service( + mut self, + service: Arc, + ) -> Result { + if !std::ptr::eq(self.runtime.as_ref(), service.runtime()) { + return Err(AiError::InvalidConfiguration( + "tool-result source service belongs to another runtime".into(), + )); + } + self.tool_result_source_service = Some(service); + Ok(self) + } + + /// Registers the owning exact deletion/absence adapter for static-offering resets. + /// + /// Defaults unavailable. The adapter receives only an SDK-derived exact old cursor; a + /// successful deletion alone grants no rebind, egress, consent or effect authority. + /// Cleanup ambiguity is recovery, and new input waits for the owning absence CAS. + #[must_use] + pub fn with_static_offering_reset_deletion_service( + mut self, + service: Arc, + ) -> Self { + self.static_offering_deletion = Some(service); + self + } + /// Enables content-free provider-failure operational diagnosis. /// /// The sink receives only a closed category. It cannot affect run state, @@ -3388,8 +3726,15 @@ impl AiProviderCallExecutor { lease: &AiRunLease, plan: AiProviderCallPlan, ) -> Result { - self.execute_inner(Arc::new(Mutex::new(lease.clone())), plan, None, None, None) - .await + Box::pin(self.execute_inner( + Arc::new(Mutex::new(lease.clone())), + plan, + None, + None, + None, + 0, + )) + .await } /// Executes one provider turn whose application tools are answered by an @@ -3418,8 +3763,7 @@ impl AiProviderCallExecutor { { return Err(AiError::Conflict); } - self.execute_inner(lease, plan, Some(execution), None, None) - .await + Box::pin(self.execute_inner(lease, plan, Some(execution), None, None, 0)).await } /// Executes through one exact durable provider-session binding. @@ -3432,6 +3776,31 @@ impl AiProviderCallExecutor { session_service: Arc, dynamic_execution: Option>, ) -> Result { + // The complete retained lifecycle owns several mutually exclusive ORM/transport + // paths. Keep that state on the heap rather than embedding it in each coordinator + // continuation frame; native execution must work on the ordinary thread stack. + Box::pin(self.execute_with_provider_session_inner( + lease_state, + plan, + session_plan, + session_service, + dynamic_execution, + )) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn execute_with_provider_session_inner( + &self, + lease_state: Arc>, + mut plan: AiProviderCallPlan, + mut session_plan: crate::AiProviderSessionTurnPlan, + session_service: Arc, + dynamic_execution: Option>, + ) -> Result { + if let Some(offering) = &plan.capability_offering { + offering.validate()?; + } if !plan.matches_provider_session_descriptor(session_plan.descriptor()) || !plan.uses_provider_retained_continuation() || dynamic_execution.is_some() @@ -3452,7 +3821,19 @@ impl AiProviderCallExecutor { } let lease = lease_state.lock().await.clone(); let binding = crate::AiProviderRunBinding::from_lease(&lease)?; - let (claim, newly_bound_cursor) = match session_service + if let Some(context) = &plan.task_context { + self.tool_result_source_service + .as_ref() + .ok_or(AiError::RuntimeNotReady)? + .validate_task_continuation_context(&lease, &plan.budget.scope, context) + .await?; + } + if let Some(evidence) = &plan.native_reset_evidence + && plan.task_context.is_some() + { + session_plan.native_context = Some((evidence.clone(), plan.offering_fingerprint())); + } + let (mut claim, mut newly_bound_cursor) = match session_service .disposition_for_run(&lease, &session_plan) .await? { @@ -3504,7 +3885,8 @@ impl AiProviderCallExecutor { cursor.clone(), session_plan.transcript_fingerprint(), None, - )?; + )? + .with_installed_offering(plan.offering_fingerprint())?; match session_service.bind_for_run(&lease, request).await { Ok(claim) => (claim, Some(cursor)), Err(error) => { @@ -3547,7 +3929,8 @@ impl AiProviderCallExecutor { cursor.clone(), session_plan.transcript_fingerprint(), None, - )?; + )? + .with_installed_offering(plan.offering_fingerprint())?; match session_service .rebind_for_run(&lease, *authorization, request) .await @@ -3591,6 +3974,23 @@ impl AiProviderCallExecutor { } (claim, None) } + crate::AiProviderSessionRunDisposition::Unavailable( + crate::AiProviderSessionState::Deleted, + ) if plan.capability_offering.is_some() + && plan.native_reset_evidence.is_some() + && plan.task_context.is_some() => + { + let current = lease_state.lock().await; + let (claim, cursor) = Box::pin(self.reset_static_offering( + ¤t, + &mut plan, + &session_plan, + &session_service, + None, + )) + .await?; + (claim, Some(cursor)) + } crate::AiProviderSessionRunDisposition::Unavailable(_) => { // No provider request has been dispatched. Durable stores use // the disposition transaction to fence an incompatible active @@ -3600,6 +4000,73 @@ impl AiProviderCallExecutor { return Err(AiError::ProviderSessionDeferred); } }; + if plan.capability_offering.is_some() { + let view = session_service + .inspect_for_run(&lease) + .await? + .ok_or(AiError::Conflict)?; + if view.installed_offering_fingerprint.as_deref() + != Some(plan.offering_fingerprint().as_str()) + { + // The shared lease lock keeps heartbeats from rotating the row fence while this + // finite cleanup transition holds it; owner cancellation remains independently fenced. + let current = lease_state.lock().await; + let reset = Box::pin(self.reset_static_offering( + ¤t, + &mut plan, + &session_plan, + &session_service, + Some(&claim), + )) + .await; + match reset { + Ok((fresh_claim, cursor)) => { + claim = fresh_claim; + newly_bound_cursor = Some(cursor); + } + Err(error) => return Err(error), + } + } + } + // A replacement thread has no retained prior history. Reopen only the SDK-owned + // input/result projection against its actual new claim, after confirmed old cleanup. + let history = self + .prepare_retained_tool_history(&lease, &mut plan, &claim) + .await; + let historical_image_units = match history { + Ok(units) => units, + Err( + AiError::EgressDenied + | AiError::ResultBudgetExceeded + | AiError::ToolExecutionFailed + | AiError::NotFound + | AiError::Conflict, + ) => { + if plan.native_reset_evidence.is_some() && plan.task_context.is_some() { + return Box::pin(self.execute_withheld_native_task_context( + lease_state, + plan, + &session_plan, + &session_service, + &claim, + )) + .await; + } + session_service + .require_cleanup(&claim, "retained_tool_source_withheld") + .await?; + return Box::pin(self.execute_inner( + lease_state, + self.withheld_tool_history_plan(&lease, plan).await?, + None, + None, + None, + 0, + )) + .await; + } + Err(error) => return Err(error), + }; let opened = match session_service.open_for_run(&lease, &claim).await { Ok(opened) => opened, Err(error) => { @@ -3644,12 +4111,14 @@ impl AiProviderCallExecutor { opened }; let shared_claim = Arc::new(Mutex::new(claim.clone())); + let withheld_plan = plan.clone(); let turn = self.execute_inner( lease_state.clone(), plan, dynamic_execution, Some(opened), Some(shared_claim.clone()), + historical_image_units, ); tokio::pin!(turn); let mut current_claim = claim; @@ -3693,7 +4162,51 @@ impl AiProviderCallExecutor { result.provider_session_claim = Some(current_claim); Ok(result) } - Err(error @ AiError::PreTransportBudgetDenied) => Err(error), + Err(AiError::PreTransportToolSourceUnavailable) => { + if withheld_plan.native_reset_evidence.is_some() + && withheld_plan.task_context.is_some() + { + return Box::pin(self.execute_withheld_native_task_context( + lease_state, + withheld_plan, + &session_plan, + &session_service, + ¤t_claim, + )) + .await; + } + session_service + .require_cleanup( + ¤t_claim, + "retained_tool_source_withheld_before_dispatch", + ) + .await?; + let current_lease = lease_state.lock().await.clone(); + let safe_plan = self + .withheld_tool_history_plan(¤t_lease, withheld_plan) + .await?; + Box::pin(self.execute_inner(lease_state, safe_plan, None, None, None, 0)).await + } + Err(error @ AiError::PreTransportBudgetDenied) => { + if withheld_plan.capability_offering.is_some() + && withheld_plan.native_reset_evidence.is_some() + && withheld_plan.task_context.is_some() + { + let current = lease_state.lock().await; + // No provider input or native callback was admitted. Confirm absence of + // this exact empty thread before exposing a static subset retry. + Box::pin(self.confirm_unstarted_static_offering_cleanup( + ¤t, + &withheld_plan, + &session_plan, + &session_service, + ¤t_claim, + )) + .await + .map_err(|_| AiError::ProviderFailed)?; + } + Err(error) + } Err(AiError::PreTransportProviderFailed) => { session_service .require_cleanup(¤t_claim, "provider_session_pre_transport_failure") @@ -3712,12 +4225,23 @@ impl AiProviderCallExecutor { async fn execute_inner( &self, lease_state: Arc>, - plan: AiProviderCallPlan, + mut plan: AiProviderCallPlan, dynamic_execution: Option>, provider_session: Option, native_session_claim: Option>>, + historical_image_units: u64, ) -> Result { + if let Some(offering) = &plan.capability_offering { + offering.validate()?; + } let lease = lease_state.lock().await.clone(); + if let Some(context) = &plan.task_context { + self.tool_result_source_service + .as_ref() + .ok_or(AiError::RuntimeNotReady)? + .validate_task_continuation_context(&lease, &plan.budget.scope, context) + .await?; + } if !self.runtime.start_gate().is_ready() || lease.state() != AiRunState::Running || plan.budget.session_id != lease.session_id() @@ -3788,8 +4312,13 @@ impl AiProviderCallExecutor { } }; - let mut context = match self - .authorize_and_audit_transfers(&lease, &plan, authorized_budget) + let (mut context, historical_image_bytes, effective_transfers) = match self + .authorize_and_audit_transfers( + &lease, + &plan, + authorized_budget, + native_session_claim.as_ref(), + ) .await { Ok(context) => context, @@ -3802,6 +4331,18 @@ impl AiProviderCallExecutor { } }; + plan.transfers = effective_transfers; + // A fresh cursor has no old history rows, but the exact retained native outcome + // can still disclose its current authorized pixels. Allocation and settlement use + // the actual admitted typed transport, never just the old-cursor preflight count. + let actual_image_units = u64::try_from(context.retained_tool_result_images().len()) + .map_err(|_| AiError::EgressDenied)?; + if actual_image_units < historical_image_units { + self.release_unstarted(&lease, &reservation).await?; + return Err(AiError::PreTransportToolSourceUnavailable); + } + let historical_image_units = actual_image_units; + let mut current = match self .runtime .resolve_current_principal(lease.principal_reference()) @@ -3915,6 +4456,43 @@ impl AiProviderCallExecutor { } }; } + if let Some(receipt) = &plan.static_offering_reset_receipt { + context = match context.with_static_offering_reset_receipt(receipt.clone()) { + Ok(context) => context, + Err(error) => { + self.release_unstarted(&lease, &reservation).await?; + return Err(error); + } + }; + } + if plan.task_context.is_some() + || plan.transfers.iter().any(|manifest| { + manifest.sources.iter().any(|source| { + source.kind == "application_tool_result" && source.reference.starts_with("v2:") + }) + }) + { + let delivery = match self + .tool_result_source_service + .as_ref() + .ok_or(AiError::EgressDenied) + .and_then(|service| { + ToolSourceDelivery::new( + service.clone(), + lease_state.clone(), + &plan.transfers, + reservation.reserved().image_units, + plan.task_context.clone(), + ) + }) { + Ok(delivery) => delivery, + Err(error) => { + self.release_unstarted(&lease, &reservation).await?; + return Err(error); + } + }; + context = context.with_tool_source_delivery(delivery); + } let provider_model = plan.request.model.clone(); let live_scope = plan.budget.scope.clone(); let live_correlation_id = plan.correlation_id.clone(); @@ -3986,6 +4564,18 @@ impl AiProviderCallExecutor { .collect::>(); let dynamic_responder = dynamic_execution.map(|execution| { Arc::new(DynamicToolResponder { + runtime: self.runtime.clone(), + maximum_image_units: reservation.reserved().image_units.min(4), + // Ordinary attachment transport consumes the same finite complete-turn media + // capacity. Only SDK-derived tool images enter tool-result usage settlement. + allocated_image_units: Mutex::new( + historical_image_units + ordinary_attachment_count(&request_snapshot), + ), + allocated_image_bytes: Mutex::new( + historical_image_bytes + .saturating_add(ordinary_attachment_bytes(&request_snapshot)), + ), + disclosed_image_units: Mutex::new(historical_image_units), lease: lease_state.clone(), execution, session_id: lease.session_id(), @@ -4027,7 +4617,11 @@ impl AiProviderCallExecutor { crate::AiProviderDispatchOutcome::RejectedBeforeDispatch(error) => { self.record_provider_error(&error); self.release_unstarted(&lease, &reservation).await?; - return Err(AiError::PreTransportProviderFailed); + return Err(if matches!(error, ProviderError::ToolSourceUnavailable) { + AiError::PreTransportToolSourceUnavailable + } else { + AiError::PreTransportProviderFailed + }); } crate::AiProviderDispatchOutcome::FailedAfterPossibleDispatch(error) => { let error = self.classify_dispatched_provider_error(&error); @@ -4443,11 +5037,16 @@ impl AiProviderCallExecutor { output_tokens, cached_input_tokens, builtin_usage, + tool_result_image_units: match &dynamic_responder { + Some(responder) => *responder.disclosed_image_units.lock().await, + None => historical_image_units, + }, }; let actual = self.usage_accounting.settle(&observation).await?; if actual.input_tokens != input_tokens || actual.output_tokens != output_tokens || actual.runs != 1 + || actual.image_units < observation.tool_result_image_units() { return Err(AiError::ProviderFailed); } @@ -4495,6 +5094,7 @@ impl AiProviderCallExecutor { interactive_tool_results, native_control_receipts, provider_session_claim: None, + static_offering_reset_receipt: plan.static_offering_reset_receipt, }) } @@ -4611,17 +5211,249 @@ impl AiProviderCallExecutor { lease: &AiRunLease, plan: &AiProviderCallPlan, budget: crate::AuthorizedBudgetReservation, - ) -> Result { + native_session_claim: Option<&Arc>>, + ) -> Result<(ProviderRequestContext, u64, Vec), AiError> { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(self.authorize_and_audit_transfers_inner( + lease, + plan, + budget, + native_session_claim, + )), + ) + .await + .map_err(|_| { + if native_session_claim.is_some() { + AiError::PreTransportToolSourceUnavailable + } else { + AiError::ToolExecutionFailed + } + })? + } + + async fn authorize_and_audit_transfers_inner( + &self, + lease: &AiRunLease, + plan: &AiProviderCallPlan, + budget: crate::AuthorizedBudgetReservation, + native_session_claim: Option<&Arc>>, + ) -> Result<(ProviderRequestContext, u64, Vec), AiError> { + let source_failure = |error| { + if native_session_claim.is_some() { + AiError::PreTransportToolSourceUnavailable + } else { + error + } + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut image_bytes = 0_u64; + let mut image_count = 0_u64; + let mut image_pixels = 0_u64; + let mut image_sources = BTreeSet::new(); + let mut retained_images = Vec::new(); let mut authorized = Vec::with_capacity(plan.transfers.len()); for manifest in &plan.transfers { - let decision = self - .runtime - .authorize_egress(lease.principal_reference(), manifest) - .await?; + let mut sources = Vec::new(); + for source in &manifest.sources { + if source.kind == "application_tool_result" && source.reference.starts_with("v2:") { + let service = self + .tool_result_source_service + .as_ref() + .ok_or(AiError::EgressDenied)?; + let id = crate::tool_media::source_call_hint(&source.reference) + .ok_or(AiError::EgressDenied)?; + let context = service + .reopen_tool_result_source( + lease, + crate::AiToolCallId(id), + crate::AiToolResultDisclosurePurpose::History, + ) + .await + .map_err(source_failure)?; + if context.data_source() != *source { + return Err(AiError::EgressDenied); + } + let origin = context.execution_provenance(); + let exact_output = plan.request.input.iter().any(|input| { + matches!(input, ModelInputBlock::ToolResult {call_id,tool_id,output} + if call_id == origin.provider_call_id() + && service.runtime().tool_catalog().descriptors().any(|descriptor| + descriptor.id.as_str() == tool_id && descriptor.fingerprint == origin.tool_fingerprint()) + && crate::orm_tools::canonical_json_hash(output).ok().as_deref() == Some(context.result_sha256())) + }); + let exact_native_outcome = plan.native_reset_evidence.as_ref().is_some_and(|evidence| { + evidence.tool_call_id == id + && evidence.session_id == lease.session_id() + && evidence.run_id == lease.run_id() + && evidence.attempt_id == lease.attempt_id() + && evidence.lease_generation == lease.lease_generation() + && plan.request.input.iter().any(|input| { + matches!(input, ModelInputBlock::Json { value } + if crate::provider::native_approved_outcome_hash(value).as_deref() == Some(evidence.outcome_hash.as_str()) + && crate::orm_tools::canonical_json_hash(&value["output"]).ok().as_deref() == Some(context.result_sha256())) + }) + }); + let exact_retained_cursor = if let Some(claim) = native_session_claim { + let claim = claim.lock().await; + if origin.provider_session_binding_id() == Some(claim.binding_id()) { + let binding = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + service.run_service().database(), &claim.binding_id()).await.map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::Conflict)?; + context.original_provider_cursor_fingerprint() + == Some(binding.cursor_fingerprint.as_str()) + && binding.claimed_run_id == Some(lease.run_id().0) + && binding.claimed_attempt_id == Some(lease.attempt_id()) + && binding.claimed_run_lease_generation + == Some(lease.lease_generation()) + && binding.claim_generation == claim.binding_claim_generation() + && binding.state == "claimed" + } else { + false + } + } else { + false + }; + if !exact_output && !exact_native_outcome && !exact_retained_cursor { + return Err(AiError::EgressDenied); + } + if manifest.capability == AiEgressCapability::ModelInference + && !context.images().is_empty() + && image_sources.insert(context.source_reference()) + { + let images = tokio::time::timeout_at( + tokio::time::Instant::from_std(deadline), + service.authorize_retained_image_analysis(lease, &context, manifest), + ) + .await + .map_err(|_| source_failure(AiError::ToolExecutionFailed))? + .map_err(source_failure)?; + image_count = image_count + .checked_add(images.len() as u64) + .ok_or(AiError::EgressDenied)?; + image_bytes = images + .iter() + .try_fold(image_bytes, |sum, image| { + sum.checked_add(image.byte_count()) + }) + .ok_or(AiError::EgressDenied)?; + image_pixels = images + .iter() + .try_fold(image_pixels, |sum, image| { + let reference = image.reference(); + sum.checked_add( + u64::from(reference.width()) * u64::from(reference.height()), + ) + }) + .ok_or(AiError::EgressDenied)?; + if image_count > 4 + || image_count > plan.budget.estimate.image_units + || image_bytes > 16 * 1024 * 1024 + || image_pixels > 16_000_000 + || std::time::Instant::now() >= deadline + { + return Err(source_failure(AiError::EgressDenied)); + } + retained_images.extend(images); + } + if !sources + .iter() + .any(|prior: &crate::AiToolResultDisclosureContext| { + prior.source_reference() == context.source_reference() + }) + { + sources.push(context); + } + } + } + // One effective inference proof covers the complete actual transport, including + // reopened protected pixels. Separate source policies cannot split count/byte limits. + let mut effective = manifest.clone(); + if manifest.capability == AiEgressCapability::ModelInference + && !retained_images.is_empty() + { + validate_combined_media_batch( + &plan.request, + &retained_images, + self.attachment_limits, + plan.budget.estimate.image_units, + ) + .map_err(source_failure)?; + for source in &sources { + for image in &retained_images { + if source.images().contains(image.reference()) { + let reference = + image.reference().egress_source(source, image.byte_count()); + if !effective.sources.contains(&reference) { + effective.sources.push(reference); + } + } + } + } + effective.attachment_count = u32::try_from( + plan.request + .input + .iter() + .filter(|input| matches!(input, ModelInputBlock::Attachment { .. })) + .count(), + ) + .map_err(|_| AiError::EgressDenied)? + .checked_add( + u32::try_from(retained_images.len()).map_err(|_| AiError::EgressDenied)?, + ) + .ok_or(AiError::EgressDenied)?; + effective.estimated_bytes = plan + .request + .conservative_provider_egress_bytes(&plan.provider_kind) + .map_err(|_| AiError::EgressDenied)? + .checked_add( + retained_image_egress_bytes(&retained_images) + .ok_or(AiError::EgressDenied)?, + ) + .ok_or(AiError::EgressDenied)?; + } + let manifest = &effective; + let decision = if sources.is_empty() { + self.runtime + .authorize_egress(lease.principal_reference(), manifest) + .await? + } else { + self.runtime + .authorize_tool_source_egress(lease.principal_reference(), manifest, &sources) + .await + .map_err(source_failure)? + }; self.egress_audit.record(manifest, &decision).await?; - let proof = decision.authorize(manifest)?; + let proof = match decision.authorize(manifest) { + Ok(proof) => proof, + Err(error) + if !sources.is_empty() + && native_session_claim.is_some() + && manifest.capability == AiEgressCapability::ModelInference => + { + // A normal current source-hook DENY is eligible for withholding only if the + // independently checked ordinary component still admits. General denials + // remain closed rather than being reclassified as unavailable history. + let ordinary = + crate::tool_media::ordinary_source_component(manifest, &sources)?; + let ordinary_decision = self + .runtime + .authorize_egress(lease.principal_reference(), &ordinary) + .await?; + self.egress_audit + .record(&ordinary, &ordinary_decision) + .await?; + ordinary_decision.authorize(&ordinary)?; + return Err(source_failure(error)); + } + Err(error) => return Err(error), + }; authorized.push((manifest.clone(), proof)); } + let effective_transfers = authorized + .iter() + .map(|(manifest, _)| manifest.clone()) + .collect(); let mut authorized = authorized.into_iter(); let (inference_manifest, inference_proof) = authorized.next().ok_or(AiError::EgressDenied)?; @@ -4636,7 +5468,17 @@ impl AiProviderCallExecutor { for (manifest, proof) in authorized { context = context.with_authorized_transfer(manifest, proof)?; } - context.with_run_binding(crate::AiProviderRunBinding::from_lease(lease)?) + if std::time::Instant::now() >= deadline { + return Err(source_failure(AiError::ToolExecutionFailed)); + } + Ok(( + context + .with_run_binding(crate::AiProviderRunBinding::from_lease(lease)?)? + .with_retained_tool_result_images(retained_images) + .map_err(source_failure)?, + image_bytes, + effective_transfers, + )) } async fn release_unstarted( @@ -4676,6 +5518,7 @@ fn valid_provider_call_id(value: &str) -> bool { #[cfg(all(test, feature = "sqlite"))] mod tests { mod native_coordinator_tests; + mod tool_media_tests; use std::sync::{ RwLock, @@ -11128,6 +11971,7 @@ mod tests { scope: fixture.scope.clone(), descriptor: descriptor.clone(), transcript_fingerprint: transcript_fingerprint.clone(), + static_offering_reset: None, }; let session_service: Arc = Arc::new(RejectingRebindService( std::sync::Mutex::new(Some(authorization)), @@ -12557,6 +13401,7 @@ mod tests { result.provider_kind = base.provider_kind.clone(); result.provider_model = base.request.model.clone(); result.request_snapshot = base.request.clone(); + result.model_inference_manifest = base.transfers[0].clone(); result.budget_reservation_id = budget.id(); result.provider_session_claim = Some(claim); let context = |index| { @@ -13366,6 +14211,11 @@ mod tests { Arc::new(inner) }; let responder = DynamicToolResponder { + runtime: fixture.runtime.clone(), + maximum_image_units: 0, + allocated_image_units: Mutex::new(0), + allocated_image_bytes: Mutex::new(0), + disclosed_image_units: Mutex::new(0), lease: lease.clone(), execution, session_id: fixture.lease.session_id(), @@ -13506,6 +14356,11 @@ mod tests { let lease = Arc::new(Mutex::new(fixture.lease.clone())); let executions = Arc::new(AtomicUsize::new(0)); let responder = DynamicToolResponder { + runtime: fixture.runtime.clone(), + maximum_image_units: 0, + allocated_image_units: Mutex::new(0), + allocated_image_bytes: Mutex::new(0), + disclosed_image_units: Mutex::new(0), lease: lease.clone(), execution: Arc::new(PersistingDynamicToolExecution { service: automatic_mutation_service( @@ -14350,6 +15205,7 @@ mod tests { UpdateAiToolCallRecordInput { protected_arguments: Some(None), protected_result: Some(None), + protected_result_source: Some(None), payload_purged_at: Some(Some( OffsetDateTime::now_utc().unix_timestamp(), )), diff --git a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs new file mode 100644 index 00000000..7a090d4b --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs @@ -0,0 +1,555 @@ +//! Verified cleanup and fresh-thread installation of an original static subset. +use super::*; + +impl AiProviderCallPlan { + pub(super) fn offering_fingerprint(&self) -> String { + crate::tools::canonical_json_digest(&serde_json::json!({ + "formatVersion":1,"kind":"installed_provider_definitions","tools":self.request.tools + })) + } +} + +impl AiProviderCallExecutor { + #[allow(clippy::too_many_arguments)] + pub(super) async fn reset_static_offering( + &self, + lease: &AiRunLease, + plan: &mut AiProviderCallPlan, + planned: &crate::AiProviderSessionTurnPlan, + sessions: &Arc, + claim: Option<&crate::AiProviderSessionClaim>, + ) -> Result< + ( + crate::AiProviderSessionClaim, + crate::AiProviderSessionCursor, + ), + AiError, + > { + let evidence = plan + .native_reset_evidence + .clone() + .ok_or(AiError::Conflict)?; + let context = plan.task_context.as_ref().ok_or(AiError::Conflict)?; + let sources = self + .tool_result_source_service + .as_ref() + .ok_or(AiError::RuntimeNotReady)?; + let deletion = self + .static_offering_deletion + .as_ref() + .ok_or(AiError::RuntimeNotReady)?; + sources + .validate_task_continuation_context(lease, &plan.budget.scope, context) + .await?; + let original_outcome_hash = if plan.task_context_only { + plan.replan_continuation + .as_ref() + .and_then(|original| original.input().first()) + .and_then(|block| match block { + ModelInputBlock::Json { value } => { + crate::provider::native_approved_outcome_hash(value) + } + _ => None, + }) + } else { + plan.request.native_approved_outcome_hash() + }; + if original_outcome_hash.as_deref() != Some(evidence.outcome_hash.as_str()) + || evidence.session_id != lease.session_id() + || evidence.run_id != lease.run_id() + || evidence.attempt_id != lease.attempt_id() + || evidence.lease_generation != lease.lease_generation() + || plan.static_offering_reset_receipt.is_some() + { + return Err(AiError::Conflict); + } + let original_continuation_reference = if plan.task_context_only { + plan.replan_continuation + .as_ref() + .ok_or(AiError::Conflict)? + .chain_reference()? + } else { + plan.request + .continuation + .as_ref() + .and_then(|continuation| continuation.chain_reference(&plan.request.input)) + .ok_or(AiError::Conflict)? + }; + // Price the exact fresh projection before cleanup or empty-thread creation. The + // executor may refuse a host quote; it never increases a monetary reservation. + let mut fresh_request = plan.request.clone(); + fresh_request.continuation = None; + let inference_bytes = fresh_request + .conservative_provider_egress_bytes(&plan.provider_kind) + .map_err(|_| AiError::EgressDenied)?; + let inference_tokens = fresh_request + .conservative_provider_egress_tokens(&plan.provider_kind) + .map_err(|_| AiError::EgressDenied)?; + if plan.budget.estimate.input_tokens < inference_tokens { + return Err(AiError::PreTransportBudgetDenied); + } + let desired_offering_fingerprint = plan.offering_fingerprint(); + let request = crate::AiProviderStaticOfferingResetRequest { + evidence: evidence.clone(), + planned: planned.clone(), + desired_offering_fingerprint: desired_offering_fingerprint.clone(), + definitely_unstarted_budget_denial: claim.is_none(), + }; + let binding = crate::AiProviderRunBinding::from_lease(lease)?; + let operation = async { + let (authorization, original_cursor_fingerprint, cleanup_generation) = + if let Some(claim) = claim { + let (cleanup, deletion_request) = sessions + .prepare_static_offering_reset(lease, claim, &request) + .await?; + let original_cursor_fingerprint = deletion_request.cursor().fingerprint(); + self.runtime + .close_all_provider_runs( + &binding, + crate::AiProviderRunCloseReason::Completed, + ) + .await + .map_err(|_| AiError::ProviderFailed)?; + let absence = deletion + .delete_or_confirm_absent(&deletion_request) + .await + .map_err(|_| AiError::ProviderFailed)?; + sessions.complete_cleanup(&cleanup, absence).await?; + let authorization = sessions + .authorize_static_offering_rebind(lease, &cleanup, &request) + .await?; + ( + authorization, + original_cursor_fingerprint, + cleanup.cleanup_generation(), + ) + } else { + // Only the exact SDK-marked unstarted denial tombstone can reach this route. + // Its actual absence and original completed graph are freshly revalidated. + let (authorization, fingerprint) = sessions + .authorize_static_offering_budget_rebind(lease, &request) + .await?; + let generation = authorization.cleanup_generation; + (authorization, fingerprint, generation) + }; + sources + .validate_task_continuation_context(lease, &plan.budget.scope, context) + .await?; + if let Some(offering) = &plan.capability_offering { + offering.validate()?; + } + let cursor = self + .runtime + .create_empty_provider_session( + planned.descriptor().provider_kind(), + &binding, + planned.descriptor(), + &fresh_request, + ) + .await + .map_err(|_| AiError::ProviderFailed)?; + if cursor.fingerprint() == original_cursor_fingerprint { + let _ = self + .runtime + .discard_empty_provider_session( + planned.descriptor().provider_kind(), + &binding, + planned.descriptor(), + &cursor, + ) + .await; + return Err(AiError::Conflict); + } + let bind = crate::AiProviderSessionBindRequest::new( + planned.descriptor().clone(), + cursor.clone(), + planned.transcript_fingerprint(), + None, + )? + .with_installed_offering(desired_offering_fingerprint.clone())?; + let fresh_claim = match sessions.rebind_for_run(lease, authorization, bind).await { + Ok(claim) => claim, + Err(error) => { + let _ = self + .runtime + .discard_empty_provider_session( + planned.descriptor().provider_kind(), + &binding, + planned.descriptor(), + &cursor, + ) + .await; + return Err(error); + } + }; + Ok(( + fresh_claim, + cursor, + cleanup_generation, + original_cursor_fingerprint, + fresh_request, + )) + }; + let remaining = lease.lease_expires_at() - self.clock.now(); + let seconds = + u64::try_from(remaining.whole_seconds().clamp(1, 30)).map_err(|_| AiError::Conflict)?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(seconds); + let (claim, cursor, cleanup_generation, original_cursor_fingerprint, fresh_request) = + tokio::time::timeout_at(deadline, operation) + .await + .map_err(|_| AiError::ProviderFailed)??; + // A non-yielding owning callback cannot outlive the same finite whole reset deadline. + if tokio::time::Instant::now() >= deadline { + return Err(AiError::ProviderFailed); + } + plan.request = fresh_request; + let inference = plan + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(AiError::EgressDenied)?; + inference.estimated_bytes = inference_bytes; + inference.estimated_tokens = inference_tokens; + plan.static_offering_reset_receipt = Some(Arc::new( + crate::provider_session::AiStaticOfferingResetReceipt { + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + lease_generation: lease.lease_generation(), + original_continuation_reference, + original_cursor_fingerprint, + replacement_cursor_fingerprint: cursor.fingerprint(), + replacement_binding_id: claim.binding_id(), + task_context_only: plan.task_context_only, + cleanup_generation, + offering_fingerprint: desired_offering_fingerprint, + outcome_hash: evidence.outcome_hash.clone(), + }, + )); + Ok((claim, cursor)) + } + + pub(super) async fn execute_withheld_native_task_context( + &self, + lease_state: Arc>, + plan: AiProviderCallPlan, + planned: &crate::AiProviderSessionTurnPlan, + sessions: &Arc, + claim: &crate::AiProviderSessionClaim, + ) -> Result { + let current = lease_state.lock().await; + let mut safe = self.withheld_tool_history_plan(¤t, plan).await?; + // A preceding proved empty-thread reset may already carry a bridge. This new + // definitely-unstarted source withholding deletes that actual cursor and mints a + // replacement bridge to the SAME original continuation, never a relabeled response. + safe.static_offering_reset_receipt = None; + if !safe.task_context_only + || !safe.request.tools.is_empty() + || safe.budget.estimate.image_units != 0 + { + return Err(AiError::Conflict); + } + let (fresh_claim, cursor) = self + .reset_static_offering(¤t, &mut safe, planned, sessions, Some(claim)) + .await?; + let binding = crate::AiProviderRunBinding::from_lease(¤t)?; + let opened = sessions + .open_for_run(¤t, &fresh_claim) + .await? + .activate_newly_bound_empty(binding, &cursor)?; + drop(current); + let shared_claim = Arc::new(Mutex::new(fresh_claim.clone())); + let outcome = Box::pin(self.execute_inner( + lease_state, + safe, + None, + Some(opened), + Some(shared_claim), + 0, + )) + .await; + match outcome { + Ok(mut result) => { + result.provider_session_claim = Some(fresh_claim); + Ok(result) + } + Err(error) => { + let _ = sessions + .require_cleanup(&fresh_claim, "native_withheld_task_context_failed") + .await; + // This quote already has zero images; it cannot mint another image reduction. + Err(if matches!(error, AiError::PreTransportBudgetDenied) { + AiError::PreTransportProviderFailed + } else { + error + }) + } + } + } + + pub(super) async fn confirm_unstarted_static_offering_cleanup( + &self, + lease: &AiRunLease, + plan: &AiProviderCallPlan, + planned: &crate::AiProviderSessionTurnPlan, + sessions: &Arc, + claim: &crate::AiProviderSessionClaim, + ) -> Result<(), AiError> { + let evidence = plan + .native_reset_evidence + .clone() + .ok_or(AiError::Conflict)?; + let context = plan.task_context.as_ref().ok_or(AiError::Conflict)?; + let sources = self + .tool_result_source_service + .as_ref() + .ok_or(AiError::RuntimeNotReady)?; + let deletion = self + .static_offering_deletion + .as_ref() + .ok_or(AiError::RuntimeNotReady)?; + let request = crate::AiProviderStaticOfferingResetRequest { + evidence, + planned: planned.clone(), + desired_offering_fingerprint: plan.offering_fingerprint(), + definitely_unstarted_budget_denial: true, + }; + let binding = crate::AiProviderRunBinding::from_lease(lease)?; + let operation = async { + sources + .validate_task_continuation_context(lease, &plan.budget.scope, context) + .await?; + let (cleanup, deletion_request) = sessions + .prepare_static_offering_reset(lease, claim, &request) + .await?; + self.runtime + .close_all_provider_runs(&binding, crate::AiProviderRunCloseReason::Completed) + .await + .map_err(|_| AiError::ProviderFailed)?; + let absence = deletion + .delete_or_confirm_absent(&deletion_request) + .await + .map_err(|_| AiError::ProviderFailed)?; + sessions.complete_cleanup(&cleanup, absence).await?; + Ok::<(), AiError>(()) + }; + let seconds = u64::try_from( + (lease.lease_expires_at() - self.clock.now()) + .whole_seconds() + .clamp(1, 30), + ) + .map_err(|_| AiError::Conflict)?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(seconds); + tokio::time::timeout_at(deadline, operation) + .await + .map_err(|_| AiError::ProviderFailed)??; + if tokio::time::Instant::now() >= deadline { + return Err(AiError::ProviderFailed); + } + Ok(()) + } +} + +/// SDK-minted opportunity to reduce one definitely unstarted native continuation. +/// +/// The value has no public constructor or deserializer. It retains the exact original +/// continuation and prepared task context, plus the same shared capability delivery state. +/// The host may reduce only originally offered static definitions and image capacity; +/// provider/model/selection, scope, source, consent, rules and effect evidence stay fixed. +/// At most one callback is admitted after an atomic reservation denial before transport. +#[derive(Clone)] +pub struct AiUnstartedStaticOfferingReplan { + failed_plan: AiProviderCallPlan, + delivery: crate::AiCapabilityDeliveryTurn, + session: crate::AiProviderSessionTurnPlan, +} + +impl std::fmt::Debug for AiUnstartedStaticOfferingReplan { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AiUnstartedStaticOfferingReplan()") + } +} + +impl AiUnstartedStaticOfferingReplan { + /// Reopens the same SDK-owned original continuation and prepared pricing context. + pub fn continuation(&self) -> crate::AiAgentContinuation { + self.failed_plan + .replan_continuation + .as_ref() + .expect("private mint requires continuation") + .as_ref() + .clone() + } + + /// Same shared broker/static offering state. All failed-plan freezes must be released. + pub fn capability_delivery(&self) -> &crate::AiCapabilityDeliveryTurn { + &self.delivery + } + + /// Original conservative typed quote. Retain every non-image field exactly; only image + /// capacity may decrease and the content-bound unstarted idempotency key must change. + /// This read-only request is pricing evidence, never a reservation or disclosure permit. + pub fn original_budget_request(&self) -> &AiBudgetReservationRequest { + &self.failed_plan.budget + } + + /// Failed finite image quote; the replacement quote must be strictly smaller. + pub fn image_unit_quote(&self) -> u64 { + self.failed_plan.budget.estimate.image_units + } + + pub(crate) fn prepare( + plan: &AiProviderCallPlan, + delivery: &crate::AiCapabilityDeliveryTurn, + session: &crate::AiProviderSessionTurnPlan, + ) -> Option { + if plan.native_reset_evidence.is_none() + || plan.task_context.is_none() + || plan.replan_continuation.is_none() + || plan.capability_offering.is_none() + || plan.budget.estimate.image_units == 0 + || !delivery.matches_offered_tools(plan.offered_tools()) + || !plan.matches_provider_session_descriptor(session.descriptor()) + { + return None; + } + let mut failed_plan = plan.clone(); + // Retain evidence, never a frozen plan. The coordinator additionally refuses the + // callback while any external or in-flight clone still owns the shared freeze. + failed_plan.capability_offering = None; + Some(Self { + failed_plan, + delivery: delivery.clone(), + session: session.clone(), + }) + } + + pub(crate) fn ready_for_callback(&self) -> Result<(), AiError> { + self.delivery.require_unfrozen_static_offering() + } + + pub(crate) async fn validate_unstarted_cleanup( + &self, + lease: &AiRunLease, + sessions: &dyn crate::AiProviderSessionService, + now: time::OffsetDateTime, + ) -> Result<(), AiError> { + self.ready_for_callback()?; + self.validate_current_unstarted_cleanup(lease, sessions, now) + .await + } + + // After a successful host callback the replacement owns its legitimate new + // freeze. Recheck the same durable custody, without reapplying the pre-callback + // requirement that all failed-plan freezes have already been released. + pub(crate) async fn validate_current_unstarted_cleanup( + &self, + lease: &AiRunLease, + sessions: &dyn crate::AiProviderSessionService, + now: time::OffsetDateTime, + ) -> Result<(), AiError> { + let request = crate::AiProviderStaticOfferingResetRequest { + evidence: self + .failed_plan + .native_reset_evidence + .clone() + .ok_or(AiError::Conflict)?, + planned: self.session.clone(), + desired_offering_fingerprint: self.failed_plan.offering_fingerprint(), + definitely_unstarted_budget_denial: true, + }; + // The error category alone proves neither a deletion nor its cause. Read the exact + // SDK-marked Deleted tombstone through the dedicated owning authorization path. + // This performs no rebind or cursor creation and grants the host no deletion proof. + let seconds = u64::try_from( + (lease.lease_expires_at() - now) + .whole_seconds() + .clamp(1, 30), + ) + .map_err(|_| AiError::Conflict)?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(seconds); + tokio::time::timeout_at( + deadline, + sessions.authorize_static_offering_budget_rebind(lease, &request), + ) + .await + .map_err(|_| AiError::ProviderFailed)??; + if tokio::time::Instant::now() >= deadline { + return Err(AiError::ProviderFailed); + } + Ok(()) + } + + pub(crate) fn validate_replacement( + &self, + next: &AiProviderCallPlan, + delivery: &crate::AiCapabilityDeliveryTurn, + session: &crate::AiProviderSessionTurnPlan, + ) -> Result<(), AiError> { + let old = &self.failed_plan; + let mut original = old.request.clone(); + original.tools.clear(); + let mut replacement = next.request.clone(); + replacement.tools.clear(); + let old_tools = old + .request + .tools + .iter() + .map(|tool| tool.fingerprint.as_str()) + .collect::>(); + let new_tools = next + .request + .tools + .iter() + .map(|tool| tool.fingerprint.as_str()) + .collect::>(); + let mut next_budget = next.budget.clone(); + next_budget.estimate.image_units = old.budget.estimate.image_units; + next_budget.idempotency_key = old.budget.idempotency_key.clone(); + let same_transfers = old.transfers.len() == next.transfers.len() + && old + .transfers + .iter() + .zip(&next.transfers) + .all(|(old, next)| { + if next.estimated_bytes > old.estimated_bytes + || next.estimated_tokens > old.estimated_tokens + { + return false; + } + let mut normalized = next.clone(); + normalized.estimated_bytes = old.estimated_bytes; + normalized.estimated_tokens = old.estimated_tokens; + &normalized == old + }); + let same_context = old + .task_context + .as_ref() + .zip(next.task_context.as_ref()) + .is_some_and(|(old, next)| Arc::ptr_eq(old, next)); + let same_evidence = old + .native_reset_evidence + .as_ref() + .zip(next.native_reset_evidence.as_ref()) + .is_some_and(|(old, next)| Arc::ptr_eq(old, next)); + if old.provider_kind != next.provider_kind + || original != replacement + || old.correlation_id != next.correlation_id + || !new_tools.is_subset(&old_tools) + || new_tools == old_tools + || !self.delivery.shares_run_state(delivery) + || !delivery.matches_offered_tools(next.offered_tools()) + || session.descriptor() != self.session.descriptor() + || session.transcript_fingerprint() != self.session.transcript_fingerprint() + || old.budget.idempotency_key == next.budget.idempotency_key + || next.budget.estimate.image_units >= old.budget.estimate.image_units + || next_budget != old.budget + || !same_transfers + || !same_context + || !same_evidence + { + return Err(AiError::Conflict); + } + Ok(()) + } +} diff --git a/crates/graphql-orm-ai/src/provider_calls/tests/native_coordinator_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/native_coordinator_tests.rs index 721fbc2c..ab808a5c 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tests/native_coordinator_tests.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tests/native_coordinator_tests.rs @@ -407,6 +407,7 @@ impl AiContentProtector for CheckpointRaceProtector { protected_arguments: extra.protected_arguments, argument_hash: extra.argument_hash, protected_result: extra.protected_result, + protected_result_source: None, payload_purged_at: extra.payload_purged_at, risk: extra.risk, authorization_code: extra.authorization_code, @@ -664,7 +665,7 @@ impl MixedPlanner { } } -fn mixed_broker_delivery( +pub(super) fn mixed_broker_delivery( fixture: &Fixture, definitions: Vec, ) -> AiCapabilityDeliveryTurn { diff --git a/crates/graphql-orm-ai/src/provider_calls/tests/static_offering_media_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/static_offering_media_tests.rs new file mode 100644 index 00000000..044cfebe --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tests/static_offering_media_tests.rs @@ -0,0 +1,539 @@ +//! Actual approval, SQLite budget denial and same-run native subset retry fixtures. +use super::*; +use crate::{ + AiSupervisedAgentCoordinator, AiSupervisedAgentCoordinatorLimits, AiSupervisedAgentRunOutcome, + AiSupervisedAgentTurnPlan, AiSupervisedAgentTurnPlanner, OrmAiProviderOutputService, +}; + +pub(super) fn extended_task_budget_service(fixture: &Fixture) -> Arc { + Arc::new(OrmAiBudgetService::new( + fixture.database.clone(), + Arc::new(SystemClock), + crate::AiBudgetServiceLimits::new( + AiBudgetAmounts { + input_tokens: 20_000, + output_tokens: 10_000, + tool_units: 100, + image_units: 100, + cost_microunits: 1_000_000, + runs: 1, + }, + Duration::minutes(5), + Duration::seconds(30), + 16, + 8, + ) + .unwrap(), + )) +} + +struct QuotaRetryPlanner { + fixture: Arc, + application: Arc, + delivery: crate::AiCapabilityDeliveryTurn, + mode: usize, + retries: AtomicUsize, + held_plan: Mutex>, +} + +impl QuotaRetryPlanner { + async fn build( + &self, + lease: &AiRunLease, + continuation: crate::AiAgentContinuation, + exact_budget: Option, + ) -> Result { + let descriptor = self + .fixture + .runtime + .tool_catalog() + .descriptor(&AiToolId::parse("records.read")?) + .unwrap(); + let mut policy = AiToolPolicySet::new(ToolMaturity::SupervisedWrite); + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + let mut base = plan(&self.fixture); + base.request.input.clear(); + base.request.tools = self.delivery.current_tools(); + base.budget.attempt_id = lease.attempt_id(); + base.budget.lease_generation = lease.lease_generation(); + base.budget.idempotency_key = "owning-quota-original".into(); + let input_contribution = continuation.conservative_input_tokens()?; + let base_projection = base + .request + .conservative_provider_egress_tokens(&base.provider_kind) + .map_err(|_| AiError::Conflict)?; + let mut complete_projection = base.request.clone(); + // Price the SDK's exact opaque input/context, including its actual reference + // and framing. No guessed allowance substitutes for the complete request. + continuation + .clone() + .apply_with_bound_task_context(&mut complete_projection)?; + let complete_quote = complete_projection + .conservative_provider_egress_tokens(&base.provider_kind) + .map_err(|_| AiError::Conflict)?; + assert!( + complete_quote + >= base_projection + .checked_add(input_contribution) + .ok_or(AiError::Conflict)? + ); + base.budget.estimate.input_tokens = complete_quote; + base.budget.estimate.image_units = 4; + if let Some(mut budget) = exact_budget { + budget.estimate.image_units = 0; + budget.idempotency_key = "owning-quota-reduced".into(); + base.budget = budget; + } + let mut provider = AiProviderCallPlan::new_continuation_with_classified_capability_surface( + base.provider_kind, + base.request, + base.budget, + base.transfers, + "approved-media-test", + continuation, + &self.delivery.current_surface(), + self.fixture.runtime.tool_catalog(), + &policy, + &self.fixture.generated_target_policy, + )?; + let original_call = provider + .native_reset_evidence + .as_ref() + .unwrap() + .tool_call_id; + let inference = provider + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .unwrap(); + let source = inference + .sources + .iter_mut() + .find(|source| { + source.kind == "application_tool_result" + && crate::tool_media::source_call_hint(&source.reference) == Some(original_call) + }) + .expect("whole inference proof includes the verified original native source"); + if self.mode == 14 { + // Matching a call UUID alone cannot admit a caller-modified source proof. + source.reference = format!("v2:{original_call}:{}", "0".repeat(64)); + } + assert!( + provider.budget.estimate.input_tokens + >= provider + .request + .conservative_provider_egress_tokens(&provider.provider_kind) + .map_err(|_| AiError::Conflict)? + ); + if self.mode == 9 { + *self.held_plan.lock().await = Some(provider.clone()); + } + let selected = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + self.delivery.session_binding().fingerprint(), + "mock/v1", + "b".repeat(64), + )?; + AiSupervisedAgentTurnPlan::new_classified_native( + provider, + crate::AiProviderSessionTurnPlan::new(selected, "d".repeat(64))?, + preview_route(), + native_test_rules(self.fixture.scope.clone()), + false, + )? + .with_capability_delivery(self.delivery.clone()) + } +} + +#[async_trait] +impl AiSupervisedAgentTurnPlanner for QuotaRetryPlanner { + async fn initial_plan(&self, _: &AiRunLease) -> Result { + Err(AiError::Conflict) + } + async fn continuation_plan( + &self, + lease: &AiRunLease, + _: u32, + continuation: crate::AiAgentContinuation, + ) -> Result { + let prepared = self + .application + .prepare_task_continuation_context(lease, &self.fixture.scope) + .await?; + self.build( + lease, + continuation.with_prepared_task_context(prepared)?, + None, + ) + .await + } + async fn replan_unstarted_static_offering( + &self, + lease: &AiRunLease, + _: u32, + context: crate::AiUnstartedStaticOfferingReplan, + ) -> Result, AiError> { + assert_eq!(self.retries.fetch_add(1, Ordering::SeqCst), 0); + assert_eq!(context.image_unit_quote(), 4); + assert!( + context + .capability_delivery() + .shares_run_state(&self.delivery) + ); + // The owning SDK must have dropped EVERY plan freeze before this callback. + context + .capability_delivery() + .set_static_bootstrap_offering(&BTreeSet::new())?; + if matches!(self.mode, 11 | 13) { + crate::OrmAiRunCancellationService::new( + self.fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(self.fixture.principal.clone())), + Arc::new(SystemClock), + crate::AiRunCancellationLimits::default(), + Arc::new(crate::AiRunCancellationHub::default()), + ) + .request_cancellation( + &self.fixture.principal, + crate::CancelAiRunInput { + session_id: lease.session_id().0, + run_id: lease.run_id().0, + client_request_id: Uuid::new_v4(), + }, + ) + .await?; + } + if matches!(self.mode, 12 | 13) { + // Actual durable cancellation must interrupt a host that never returns; + // without cancellation the same finite lease/reset deadline must end it. + return std::future::pending().await; + } + self.build( + lease, + context.continuation(), + Some(context.original_budget_request().clone()), + ) + .await + .map(Some) + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn owning_coordinator_reset( + fixture: Fixture, + custody: Arc, + application: Arc, + sessions: Arc, + checkpoints: Arc, + consequential: Arc, + resume: Arc, + delivery: crate::AiCapabilityDeliveryTurn, + approved: crate::AiApprovedRunClaim, + mode: usize, +) { + let mut fixture = fixture; + // This extended original-task continuation is larger than the parent's 10,000 + // token test profile. Give only this fixture finite capacity for its complete + // SDK-priced request; the separate zero image policy still forces the retry. + fixture.budget_service = extended_task_budget_service(&fixture); + let fixture = Arc::new(fixture); + let policy = AiBudgetPolicyRecord::query(fixture.database.pool()) + .limit(1) + .fetch_one() + .await + .unwrap() + .unwrap(); + // This is actual owning budget configuration, not a fake reservation failure. The + // already-settled approval turn remains charged; zero capacity denies only new images. + AiBudgetPolicyRecord::update_by_id( + &fixture.database, + &policy.id, + crate::persistence::UpdateAiBudgetPolicyRecordInput { + maximum_image_units: Some(Some(if mode == 14 { 4 } else { 0 })), + maximum_input_tokens: Some(Some(20_000)), + maximum_runs: (mode == 10).then_some(Some(1)), + ..Default::default() + }, + ) + .await + .unwrap(); + let planner = Arc::new(QuotaRetryPlanner { + fixture: fixture.clone(), + application: application.clone(), + delivery, + mode, + retries: AtomicUsize::new(0), + held_plan: Mutex::new(None), + }); + let deletion = Arc::new(ConfirmPreviewDeletion { + custody: custody.clone(), + fail: false, + requests: Mutex::new(Vec::new()), + created_at_delete: Mutex::new(Vec::new()), + }); + let executor = Arc::new( + AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ) + .with_tool_result_source_service(application.clone()) + .unwrap() + .with_static_offering_reset_deletion_service(deletion.clone()), + ); + let output = Arc::new(OrmAiProviderOutputService::new( + fixture.run_service.clone(), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(SystemClock), + Default::default(), + )); + let coordinator = AiSupervisedAgentCoordinator::new( + Arc::new(fixture.run_service.clone()), + executor, + output, + checkpoints.clone(), + checkpoints.clone(), + consequential.clone(), + application.clone(), + resume, + Arc::new(NativeTestRuleResolver), + planner.clone(), + Arc::new(SystemClock), + AiSupervisedAgentCoordinatorLimits::new( + AiAgentLoopLimits::new(4, 8).unwrap(), + Duration::seconds(1), + Duration::minutes(5), + false, + ) + .unwrap(), + ) + .with_provider_session_service(sessions) + .with_classified_native_tools(application, consequential, checkpoints); + custody.complete_after_capture.store(true, Ordering::SeqCst); + custody.requests.lock().await.clear(); + let events_before = AiSessionEventRecord::query(fixture.database.pool()) + .count() + .await + .unwrap(); + let original_binding = + crate::orm_provider_session::AiProviderSessionBindingRecord::query(fixture.database.pool()) + .limit(1) + .fetch_one() + .await + .unwrap() + .unwrap(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(35), + Box::pin(coordinator.execute_approved_claim(&approved)), + ) + .await + .expect("a pending host retry must end within its finite same-lease bound"); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1, "{result:?}"); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); + let calls = AiToolCallRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].state, "completed"); + assert!(calls[0].protected_result.is_some()); + assert_eq!( + AiApprovalRecord::find_by_id(&fixture.database, &approved.approval_id().0) + .await + .unwrap() + .unwrap() + .consumed_uses, + 1 + ); + // No human message was redispatched. Budget/assistant status events may be appended + // by the owning coordinator, so count the exact original message event separately. + let queued = AiSessionEventRecord::query(fixture.database.pool()) + .filter(crate::persistence::AiSessionEventRecordWhereInput { + event_type: Some(graphql_orm::graphql::filters::StringFilter { + eq: Some("message_queued".into()), + ..Default::default() + }), + ..Default::default() + }) + .count() + .await + .unwrap(); + assert_eq!(queued, 1); + assert!( + AiSessionEventRecord::query(fixture.database.pool()) + .count() + .await + .unwrap() + >= events_before + ); + let requests = custody.requests.lock().await; + match mode { + 8 | 14 => { + assert!( + matches!(result, Ok(AiSupervisedAgentRunOutcome::Completed { .. })), + "{result:?}" + ); + assert_eq!( + planner.retries.load(Ordering::SeqCst), + usize::from(mode == 8) + ); + assert_eq!(requests.len(), 1); + assert!(requests[0].tools.is_empty()); + assert!(requests[0].builtin_tools.is_empty()); + assert!(requests[0].continuation.is_none()); + let serialized = serde_json::to_string(&requests[0].input).unwrap(); + assert!(serialized.contains("FrameworkTaskContinuationContext")); + assert!(serialized.contains("historicalToolContentWithheld")); + assert!(!serialized.contains("FrameworkApprovedToolOutcome")); + assert!(!serialized.contains("Preview")); + assert!(!serialized.contains("data:image")); + assert!(!serialized.contains("v2:")); + assert!(!serialized.contains(&"0".repeat(64))); + let ModelInputBlock::Text { text } = &requests[0].input[0] else { + panic!("same original task remains untrusted user context") + }; + let task: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!( + task["originalMessageId"], + approved.lease().input_message_id().to_string() + ); + let original_message = AiMessageRecord::find_by_id( + &fixture.database, + &approved.lease().input_message_id(), + ) + .await + .unwrap() + .unwrap(); + let original_event = AiSessionEventRecord::query(fixture.database.pool()) + .filter(crate::persistence::AiSessionEventRecordWhereInput { + event_type: Some(graphql_orm::graphql::filters::StringFilter { + eq: Some("message_queued".into()), + ..Default::default() + }), + ..Default::default() + }) + .fetch_one() + .await + .unwrap() + .unwrap(); + assert_eq!(task["originalEventId"], original_event.id.to_string()); + assert_eq!( + task["originalClientMessageId"], + original_message.client_message_id.unwrap().to_string() + ); + assert_eq!(task["goalContext"]["text"], "hello"); + assert_eq!(task["priorProtectedContentIncluded"], false); + assert_eq!(task["newHumanEventCreated"], false); + assert_eq!( + task["savedInvocationStatuses"][0]["toolCallId"], + calls[0].id.to_string() + ); + assert_eq!(task["savedInvocationStatuses"][0]["state"], "completed"); + for capture in custody.captures.lock().await.values() { + assert!(!serialized.contains(&capture.reference.reference().to_string())); + assert!(!serialized.contains(capture.reference.sha256())); + } + // Approval thread, denied empty thread, reduced empty thread, and the exact + // tool-free withheld projection each have real retained custody. + if mode == 8 { + assert!(custody.created_threads.load(Ordering::SeqCst) >= 3); + assert!(deletion.requests.lock().await.len() >= 2); + } else { + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 2); + assert_eq!(*deletion.created_at_delete.lock().await, vec![1]); + assert_eq!( + *deletion.requests.lock().await, + vec![original_binding.cursor_fingerprint] + ); + } + } + 9 => { + assert!( + matches!( + result, + Ok(AiSupervisedAgentRunOutcome::RecoveryRequired { .. }) + ), + "{result:?}" + ); + assert_eq!(planner.retries.load(Ordering::SeqCst), 0); + assert!(planner.held_plan.lock().await.is_some()); + assert!(requests.is_empty()); + } + 10 | 12 => { + assert!( + matches!(result, Ok(AiSupervisedAgentRunOutcome::Failed { .. })), + "{result:?}" + ); + assert_eq!(planner.retries.load(Ordering::SeqCst), 1); + assert!(requests.is_empty()); + } + 11 | 13 => { + assert!( + matches!( + result, + Ok(AiSupervisedAgentRunOutcome::Cancelled { .. }) | Err(AiError::Conflict) + ), + "{result:?}" + ); + assert_eq!(planner.retries.load(Ordering::SeqCst), 1); + assert!(requests.is_empty()); + } + _ => unreachable!(), + } + let budgets = AiBudgetReservationRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + assert!( + budgets + .iter() + .all(|row| matches!(row.state.as_str(), "committed" | "released")) + ); + assert_eq!( + budgets + .iter() + .map(|row| row.actual_image_units.unwrap_or(0)) + .sum::(), + 0 + ); + drop(requests); + assert!( + Box::pin(coordinator.execute_approved_claim(&approved)) + .await + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn approved_native_quota_retry_is_once_and_preserves_effect_task_and_external_freeze() { + for mode in [8, 9, 10, 11] { + Box::pin(approved_media_lifecycle(false, false, 0, Some(mode))).await; + } +} + +#[tokio::test] +async fn approved_native_pending_quota_replan_is_bounded_and_owner_cancellable() { + for mode in [12, 13] { + Box::pin(approved_media_lifecycle(false, false, 0, Some(mode))).await; + } +} + +#[tokio::test] +async fn verified_native_inference_refuses_modified_original_source_reference() { + Box::pin(approved_media_lifecycle(false, false, 0, Some(14))).await; +} diff --git a/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs new file mode 100644 index 00000000..cc4b670e --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs @@ -0,0 +1,2532 @@ +//! Temporary SQLite owner/provenance tests; no consumer application or external endpoint. +use super::*; +use crate::{AiToolImageFieldBindings, AiToolResultMediaLimits, AiToolResultMediaPolicy}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +#[path = "static_offering_media_tests.rs"] +mod static_offering_tests; + +#[derive(Clone)] +struct Capture { + reference: crate::AiToolImageReference, + origin: crate::AiToolExecutionProvenance, + bytes: Arc<[u8]>, +} +#[derive(Default)] +struct Custody { + captures: Mutex>, + effects: AtomicUsize, + created_threads: AtomicUsize, + complete_after_capture: AtomicBool, + opens: AtomicUsize, + allowed: AtomicBool, + ordinary_allowed: AtomicBool, + revoke_before_delivery: AtomicBool, + fail_next_source_open: AtomicBool, + invalid_image_metadata: AtomicBool, + model_disclosures: AtomicUsize, + deny_model_disclosure_at: AtomicUsize, + requests: Mutex>, + delivered_images: AtomicUsize, + delay_open: AtomicBool, + source_delivery_allowed: AtomicBool, + withdraw_access_after_effect: AtomicUsize, + access_withdrawn: AtomicBool, + #[cfg(feature = "provider-codex-app-server")] + legacy_factory_launches: AtomicUsize, +} +#[async_trait] +impl crate::AiAccessPolicy for Custody { + async fn can_access_scope( + &self, + _principal: &agql_auth::AuthPrincipal, + _scope: &AiScope, + _action: crate::AiSessionAction, + ) -> crate::AiAccessDecision { + if self.access_withdrawn.load(Ordering::SeqCst) + && self.withdraw_access_after_effect.load(Ordering::SeqCst) == 1 + { + crate::AiAccessDecision::deny("scope_withdrawn", "preview-access-v1") + } else { + crate::AiAccessDecision::allow("current_scope", "preview-access-v1") + } + } + async fn can_access_session( + &self, + _principal: &agql_auth::AuthPrincipal, + _session: AiSessionId, + _action: crate::AiSessionAction, + ) -> crate::AiAccessDecision { + if self.access_withdrawn.load(Ordering::SeqCst) + && self.withdraw_access_after_effect.load(Ordering::SeqCst) == 2 + { + crate::AiAccessDecision::deny("session_withdrawn", "preview-access-v1") + } else { + crate::AiAccessDecision::allow("current_session", "preview-access-v1") + } + } +} +#[cfg(feature = "provider-codex-app-server")] +struct LegacyDynamicFactory(Arc); +#[cfg(feature = "provider-codex-app-server")] +#[async_trait] +impl crate::providers::AiCodexAppServerRunProcessFactory for LegacyDynamicFactory { + fn supports_launch_profile(&self, _: crate::providers::AiCodexAppServerLaunchProfile) -> bool { + true + } + async fn launch( + &self, + _: Arc, + ) -> Result { + self.0 + .legacy_factory_launches + .fetch_add(1, Ordering::SeqCst); + Err(ProviderError::Unavailable) + } +} +#[cfg(feature = "provider-codex-app-server")] +fn legacy_dynamic_provider( + custody: Arc, +) -> Arc { + use crate::providers::*; + let registration = AiCodexAppServerRegistration::new( + "mock-profile", + "mock-model", + "a".repeat(64), + "1.0.0", + "isolated-test", + AI_CODEX_APP_SERVER_PROTOCOL_V2, + ) + .unwrap() + .with_launch_profile( + AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, + ) + .unwrap(), + ) + .with_guarded_tool_result_source_delivery(); + Arc::new(AiCodexAppServerProvider::new( + Arc::new(registration), + AiCodexAppServerRunPool::new( + Arc::new(LegacyDynamicFactory(custody)), + AiCodexAppServerRunLimits::default(), + ), + )) +} +struct SourceProtector(Arc); +#[async_trait] +impl crate::AiContentProtector for SourceProtector { + async fn protect( + &self, + policy: &crate::AiContentProtectionPolicy, + context: &crate::ContentProtectionContext, + value: serde_json::Value, + ) -> Result { + crate::AiContentProtector::protect(&DatabaseManagedContentProtector, policy, context, value) + .await + } + async fn open( + &self, + policy: &crate::AiContentProtectionPolicy, + context: &crate::ContentProtectionContext, + envelope: &crate::ProtectedContentEnvelope, + ) -> Result { + if context.field == "protected_result_source" + && self.0.fail_next_source_open.swap(false, Ordering::SeqCst) + { + return Err(crate::ContentProtectionError::KeyUnavailable); + } + crate::AiContentProtector::open(&DatabaseManagedContentProtector, policy, context, envelope) + .await + } +} +struct PreviewExecutor(Arc); +#[async_trait] +impl AuthenticatedGraphqlExecutor for PreviewExecutor { + async fn execute( + &self, + _context: GraphqlRequestContext, + _request: ToolGraphqlRequest, + ) -> Result { + Err(ToolExecutionError::StaleContract) + } + async fn execute_registered( + &self, + _context: GraphqlRequestContext, + binding: &crate::AiRegisteredToolExecutionBinding, + _request: ToolGraphqlRequest, + ) -> Result { + let origin = binding + .provenance() + .ok_or(ToolExecutionError::StaleContract)? + .clone(); + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2, 3) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let reference_id = Uuid::new_v4(); + let now_ms = + i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000).unwrap(); + let metadata = json!({"reference":reference_id,"sha256":format!("{:x}",Sha256::digest(&bytes)), + "mime":"image/png","width":2,"height":3,"captured_at_ms":now_ms, + "action_expires_at_ms":now_ms+10,"retained_until_ms":now_ms+300_000}); + let reference: crate::AiToolImageReference = serde_json::from_value(metadata).unwrap(); + self.0.captures.lock().await.insert( + reference_id, + Capture { + reference: reference.clone(), + origin, + bytes, + }, + ); + self.0.effects.fetch_add(1, Ordering::SeqCst); + if self.0.withdraw_access_after_effect.load(Ordering::SeqCst) != 0 { + // The real resolver completes its effect while current disclosure access is lost. + self.0.access_withdrawn.store(true, Ordering::SeqCst); + } + Ok(ToolGraphqlResponse { + data: json!({"Preview":{ + "Reference":reference.reference(),"Sha256":reference.sha256(),"Mime":reference.mime(), + "Width":if self.0.invalid_image_metadata.load(Ordering::SeqCst) {json!(-1)} else {json!(reference.width())},"Height":reference.height(),"CapturedAtMs":reference.captured_at_ms(), + "RetainedUntilMs":reference.retained_until_ms(),"ActionExpiresAtMs":reference.action_expires_at_ms() + }}), + error_codes: Vec::new(), + application_audit_ref: Some("preview-effect".into()), + }) + } +} +#[async_trait] +impl crate::AiToolResultMediaResolver for Custody { + async fn resolve_for_provider( + &self, + principal: &ResolvedPrincipal, + descriptor: &AiToolDescriptor, + context: &crate::AiToolResultDisclosureContext, + reference: &crate::AiToolImageReference, + ) -> Result { + if !self.allowed.load(Ordering::SeqCst) + || principal.reference() != context.current_principal_reference() + || principal.principal().subject() != context.original_principal_reference().subject + { + return Err(AiError::Forbidden); + } + let capture = self + .captures + .lock() + .await + .get(&reference.reference()) + .cloned() + .ok_or(AiError::NotFound)?; + if capture.reference != *reference + || &capture.origin != context.execution_provenance() + || descriptor.fingerprint != capture.origin.tool_fingerprint() + { + return Err(AiError::Conflict); + } + self.opens.fetch_add(1, Ordering::SeqCst); + if self.delay_open.load(Ordering::SeqCst) { + // Deliberately model a host callback that returns late without yielding. A timeout + // future alone can observe its ready value before an elapsed timer is polled. + std::thread::sleep(std::time::Duration::from_millis(40)); + } + crate::AiResolvedToolImage::decode_bounded( + reference.clone(), + crate::AiToolMediaResourceId::new("preview-v1", reference.reference())?, + capture.bytes, + descriptor + .result_media + .as_ref() + .ok_or(AiError::Forbidden)? + .limits(), + ) + .await + } +} +#[async_trait] +impl AiEgressPolicy for Custody { + async fn authorize( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + ) -> AiEgressDecision { + if self.ordinary_allowed.load(Ordering::SeqCst) { + AiEgressDecision::allow( + manifest, + "preview-policy-v1", + principal.principal().subject(), + ) + } else { + AiEgressDecision::deny( + manifest, + crate::AiEgressReason::PolicyDenied, + "preview-policy-v1", + principal.principal().subject(), + ) + } + } + async fn authorize_tool_sources( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + sources: &[crate::AiToolResultDisclosureContext], + ) -> AiEgressDecision { + if sources.is_empty() { + // Withheld output has no media/source consent to admit; ordinary current + // egress policy still governs the exact saved-task/status projection. + return self.authorize(principal, manifest).await; + } + let model_denied = manifest.capability == AiEgressCapability::ModelInference + && self.deny_model_disclosure_at.load(Ordering::SeqCst) > 0 + && self.model_disclosures.fetch_add(1, Ordering::SeqCst) + 1 + >= self.deny_model_disclosure_at.load(Ordering::SeqCst); + if self.allowed.load(Ordering::SeqCst) + && !model_denied + && sources.iter().all(|source| { + source.current_principal_reference() == principal.reference() + && source.scope() == &manifest.scope + && source.execution_provenance().tool_fingerprint().len() == 64 + }) + { + AiEgressDecision::allow( + manifest, + "preview-current-consent-v1", + principal.principal().subject(), + ) + } else { + AiEgressDecision::deny( + manifest, + crate::AiEgressReason::PolicyDenied, + "preview-current-consent-v1", + principal.principal().subject(), + ) + } + } +} +struct MediaProvider { + custody: Arc, + supported: bool, +} +#[async_trait] +impl crate::AiProvider for MediaProvider { + fn provider_kind(&self) -> ProviderKind { + ProviderKind::OpenAiCompatible + } + fn capabilities(&self) -> crate::ProviderCapabilities { + crate::ProviderCapabilities { + image_input: self.supported, + provider_retained_continuation: true, + ..MockProvider::new(Vec::new()).capabilities() + } + } + fn supports_tool_result_images(&self) -> bool { + self.supported + } + fn supports_tool_result_source_delivery(&self) -> bool { + self.supported && self.custody.source_delivery_allowed.load(Ordering::SeqCst) + } + fn supports_tool_result_image_policy(&self, _: &AiToolDescriptor) -> bool { + self.supported + } + async fn create_empty_session( + &self, + _: &crate::AiProviderRunBinding, + _: &AiProviderSessionDescriptor, + _: &ModelRequest, + ) -> Result { + AiProviderSessionCursor::new( + "test.thread", + format!( + "preview-thread-{}", + self.custody.created_threads.fetch_add(1, Ordering::SeqCst) + ), + ) + .map_err(|_| ProviderError::InvalidRequest) + } + async fn stream( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> Result { + context.authorize_tool_source_delivery().await?; + self.custody.delivered_images.fetch_add( + context.retained_tool_result_images().len(), + Ordering::SeqCst, + ); + self.custody.requests.lock().await.push(request.clone()); + if request.tools.is_empty() || self.custody.complete_after_capture.load(Ordering::SeqCst) { + return Ok(Box::pin(futures::stream::iter([ + Ok(ProviderEvent::TextDelta { + text: "The saved task is complete.".into(), + }), + Ok(ProviderEvent::Usage { + input_tokens: 1, + output_tokens: 1, + cached_input_tokens: 0, + }), + Ok(ProviderEvent::ResponseCompleted { + response_id: Some("fresh-text-turn".into()), + }), + ]))); + } + let definition = request.tools.first().ok_or(ProviderError::Rejected)?; + Ok(Box::pin(futures::stream::iter([ + Ok(ProviderEvent::ResponseStarted { + response_id: Some("preview-turn".into()), + }), + Ok(ProviderEvent::ToolCallStarted { + call_id: "preview-call".into(), + tool_id: definition.tool_id.clone(), + }), + Ok(ProviderEvent::ToolCallCompleted { + call_id: "preview-call".into(), + arguments: json!({}), + }), + Ok(ProviderEvent::Usage { + input_tokens: 1, + output_tokens: 1, + cached_input_tokens: 0, + }), + Ok(ProviderEvent::ResponseCompleted { + response_id: Some("preview-turn".into()), + }), + ]))) + } + async fn stream_with_dynamic_tools( + &self, + request: ModelRequest, + context: ProviderRequestContext, + responder: Arc, + ) -> Result { + context.authorize_tool_source_delivery().await?; + if self.custody.complete_after_capture.load(Ordering::SeqCst) { + return self.stream(request, context).await; + } + let definition = request + .tools + .iter() + .find(|definition| definition.tool_id == "records.read") + .ok_or(ProviderError::Rejected)? + .clone(); + let custody = self.custody.clone(); + // Real callbacks occur while polling the dispatched stream, after the SDK has + // durably marked its atomic reservation uncertain. Do not execute effects while + // merely preparing/returning the stream. + Ok(Box::pin(async_stream::try_stream! { + yield ProviderEvent::ResponseStarted { response_id: Some("preview-turn".into()) }; + yield ProviderEvent::ToolCallStarted { call_id: "preview-call".into(), tool_id: definition.tool_id.clone() }; + let call = ProviderDynamicToolCall::from_definition("preview-turn", "preview-call", &definition, json!({}))?; + let result = responder.respond(call).await?; + if custody.revoke_before_delivery.load(Ordering::SeqCst) { + custody.allowed.store(false, Ordering::SeqCst); + } + responder.authorize_delivery(&result).await?; + if result.output()["status"] != "ApprovalPending" + && (result.images().len() != 1 || result.images()[0].bytes().is_empty()) { + Err::<(), _>(ProviderError::Rejected)?; + } + yield ProviderEvent::ToolCallCompleted { call_id: "preview-call".into(), arguments: json!({}) }; + yield ProviderEvent::Usage { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0 }; + yield ProviderEvent::ResponseCompleted { response_id: Some("preview-turn".into()) }; + })) + } +} +struct ImageAccounting; +#[async_trait] +impl AiProviderUsageAccounting for ImageAccounting { + async fn settle( + &self, + observation: &AiProviderUsageObservation, + ) -> Result { + Ok(AiBudgetAmounts { + input_tokens: observation.input_tokens(), + output_tokens: observation.output_tokens(), + image_units: observation.tool_result_image_units(), + runs: 1, + ..Default::default() + }) + } +} +struct PreviewExecution { + service: Arc, + scope: AiScope, +} +#[async_trait] +impl AiProviderDynamicToolExecution for PreviewExecution { + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + Some(self.service.as_ref()) + } + async fn execute_dynamic_tool( + &self, + lease: &AiRunLease, + result: &AiProviderCallResult, + index: usize, + ) -> Result { + self.service + .execute_read_only( + lease, + result, + AiApplicationToolCallContext::new( + 0, + index, + self.scope.clone(), + "preview-test", + "preview-turn", + )?, + preview_route(), + ) + .await + } +} +fn preview_route() -> AiToolResultEgressRoute { + AiToolResultEgressRoute::new( + "mock-profile", + "local-mock", + AiDestinationTrust::ManagedProvider, + "preview_test", + "none", + "preview-policy-v1", + ) + .unwrap() +} +fn boundary(classification: DataClassification, attachments: u32) -> AiDeploymentEgressBoundary { + AiDeploymentEgressBoundary { + allowed_destination_trust: [AiDestinationTrust::ManagedProvider].into_iter().collect(), + allowed_capabilities: [ + AiEgressCapability::ModelInference, + AiEgressCapability::ToolResult, + AiEgressCapability::ImageAnalysis, + ] + .into_iter() + .collect(), + maximum_classification: classification, + maximum_bytes: 16 * 1024 * 1024, + maximum_attachments: attachments, + } +} +async fn media_fixture( + supported: bool, +) -> (Fixture, Arc, Arc) { + media_fixture_mode(supported, false).await +} +async fn media_fixture_mode( + supported: bool, + supervised: bool, +) -> (Fixture, Arc, Arc) { + media_fixture_resolution(supported, supervised, 5000).await +} +async fn media_fixture_resolution( + supported: bool, + supervised: bool, + resolution_ms: u64, +) -> (Fixture, Arc, Arc) { + let mut fixture = fixture(Vec::new()).await; + let custody = Arc::new(Custody::default()); + custody.allowed.store(true, Ordering::SeqCst); + custody.ordinary_allowed.store(true, Ordering::SeqCst); + custody + .source_delivery_allowed + .store(true, Ordering::SeqCst); + let rule = AiDisclosureRule::exportable(DataClassification::Restricted); + let fields = [ + "Reference", + "Sha256", + "Mime", + "Width", + "Height", + "CapturedAtMs", + "RetainedUntilMs", + "ActionExpiresAtMs", + ]; + let disclosure = AiDisclosureSchema::new( + "preview-disclosure-v1", + AiDisclosureShape::object( + rule, + [( + "Preview".into(), + AiDisclosureShape::object( + rule, + fields + .into_iter() + .map(|name| (name.into(), AiDisclosureShape::scalar(rule))), + ), + )], + ), + ) + .unwrap(); + let document = if supervised { + "mutation Preview { Preview { Reference Sha256 Mime Width Height CapturedAtMs RetainedUntilMs ActionExpiresAtMs } }" + } else { + "query Preview { Preview { Reference Sha256 Mime Width Height CapturedAtMs RetainedUntilMs ActionExpiresAtMs } }" + }; + let contract = GraphqlOperationContract::new( + GraphqlExecutionTargetId::parse("local-application").unwrap(), + "schema-v1", + "Preview", + document, + "preview-projection-v1", + disclosure.fingerprint.clone(), + ) + .unwrap(); + let media = AiToolResultMediaPolicy::new( + "preview-v1", + vec![vec!["Preview".into()]], + AiToolImageFieldBindings::new( + "Reference", + "Sha256", + "Mime", + "Width", + "Height", + "CapturedAtMs", + "RetainedUntilMs", + ) + .unwrap() + .with_action_expires_at_ms("ActionExpiresAtMs") + .unwrap(), + AiToolResultMediaLimits::new(1, 4 * 1024 * 1024, 1536, 4_000_000, 300_000, resolution_ms) + .unwrap(), + DataClassification::Restricted, + ) + .unwrap(); + let descriptor = AiToolDescriptor::new( + "records.read", + "Read a bounded retained preview", + if supervised { AiToolOperationKind::Mutation } else { AiToolOperationKind::Query }, + document, + json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{},"additionalProperties":false}), + ) + .unwrap() + .with_result_projection("preview-projection-v1") + .with_graphql_contract(contract) + .with_result_media(media) + .unwrap(); + let descriptor = if supervised { + descriptor + .with_maturity(ToolMaturity::SupervisedWrite) + .with_risk(AiToolRisk::NonIdempotentWrite, AiApprovalRule::OneShot) + .with_idempotent(false) + } else { + descriptor + }; + let scoped = crate::AiToolResultEgressBoundary::new( + descriptor.fingerprint.clone(), + "mock-profile", + "local-mock", + boundary(DataClassification::Restricted, 4), + ) + .unwrap(); + let mut catalog = AiToolCatalog::new(); + catalog + .register_with_disclosure(descriptor, disclosure) + .unwrap(); + let mut targets = GraphqlExecutionTargetRegistry::new(); + targets + .register(GraphqlExecutionTarget { + id: GraphqlExecutionTargetId::parse("local-application").unwrap(), + class: GraphqlExecutionTargetClass::Local, + audience: None, + resource_type: None, + resource_id: None, + schema_fingerprint: "schema-v1".into(), + }) + .unwrap(); + let builder = AiRuntime::builder() + .maximum_tool_maturity(ToolMaturity::SupervisedWrite) + .principal_resolver(Arc::new(CountingResolver { + principal: fixture.principal.clone(), + resolutions: fixture.principal_resolutions.clone(), + })) + .access_policy(custody.clone()) + .tool_authorization_policy(Arc::new(AllowTools(fixture.tool_policy_version.clone()))) + .request_context_factory(Arc::new(ContextFactory)) + .graphql_executor(Arc::new(PreviewExecutor(custody.clone()))) + .graphql_targets(targets) + .egress_policy(custody.clone()) + .deployment_egress(boundary(DataClassification::Internal, 0)) + .tool_result_egress_boundary(scoped) + .tool_result_media_resolver("preview-v1", custody.clone()) + .unwrap() + .tool_catalog(catalog) + .secret_store(Arc::new(EnvironmentSecretStore::new())) + .content_protection_policy_resolver(Arc::new(ProtectionPolicy)) + .content_protector(Arc::new(SourceProtector(custody.clone()))) + .provider(Arc::new(MediaProvider { + custody: custody.clone(), + supported, + })) + .unwrap(); + #[cfg(feature = "provider-codex-app-server")] + let builder = builder + .provider(legacy_dynamic_provider(custody.clone())) + .unwrap(); + let runtime = builder.build().unwrap(); + runtime + .start_gate() + .open(&AiRuntimeReadinessReport { + module_fingerprint: runtime.start_gate().expected_module_fingerprint().into(), + executor_bound: true, + restore_reconciled: true, + fatal_issue_count: 0, + }) + .unwrap(); + fixture.runtime = Arc::new(runtime); + let service = Arc::new(OrmAiApplicationToolCallService::new( + fixture.run_service.clone(), + fixture.runtime.clone(), + fixture.audit.clone(), + Arc::new(SystemClock), + AiApplicationToolCallLimits::new( + 8192, + 16384, + 4, + 4, + Duration::seconds(30), + Duration::seconds(10), + ) + .unwrap(), + )); + (fixture, custody, service) +} +async fn preview_turn( + fixture: &Fixture, + service: Arc, + image_units: u64, +) -> Result { + let mut plan = tool_plan(fixture); + plan.budget.estimate.image_units = image_units; + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ); + executor + .execute_with_dynamic_tools( + Arc::new(Mutex::new(fixture.lease.clone())), + plan, + Arc::new(PreviewExecution { + service, + scope: fixture.scope.clone(), + }), + ) + .await +} +#[tokio::test] +async fn typed_pixels_charge_atomic_budget_and_reopen_without_effect_replay() { + let (fixture, custody, service) = media_fixture(true).await; + let result = preview_turn(&fixture, service.clone(), 4).await.unwrap(); + assert_eq!(result.usage().image_units, 1); + let call = &result.interactive_tool_results()[0]; + let source = service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::History, + ) + .await + .unwrap(); + assert_eq!( + source.execution_provenance().argument_hash(), + crate::tools::canonical_json_digest(&json!({})) + ); + assert_eq!(source.current_attempt_id(), fixture.lease.attempt_id()); + assert_eq!(source.effect_attempt_id(), fixture.lease.attempt_id()); + assert_eq!( + source.execution_provenance().effect_attempt_id(), + Some(fixture.lease.attempt_id()) + ); + assert_eq!( + source.execution_provenance().effect_lease_generation(), + Some(fixture.lease.lease_generation()) + ); + let reopened = service + .authorize_tool_images(call.lease(), call.id(), call.egress_manifest().unwrap()) + .await + .unwrap(); + assert_eq!(reopened.len(), 1); + assert!(source.binds_egress_source(&source.data_source())); + let image_source = source.images()[0].egress_source(&source, reopened[0].byte_count()); + assert!(source.binds_egress_source(&image_source)); + for replacement in [ + AiDataSourceRef { + reference: "v2:unknown".into(), + ..image_source.clone() + }, + AiDataSourceRef { + kind: "unknown_image".into(), + ..image_source.clone() + }, + AiDataSourceRef { + classification: DataClassification::Internal, + ..image_source.clone() + }, + AiDataSourceRef { + trust: AiSourceTrust::UserProvided, + ..image_source.clone() + }, + ] { + assert!(!source.binds_egress_source(&replacement)); + } + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let reservation = + AiBudgetReservationRecord::find_by_id(&fixture.database, &result.budget_reservation_id().0) + .await + .unwrap() + .unwrap(); + assert_eq!(reservation.state, "committed"); + assert_eq!(reservation.actual_image_units, Some(1)); + let saved = AiToolCallRecord::find_by_id(&fixture.database, &call.id().0) + .await + .unwrap() + .unwrap(); + assert!(saved.protected_result_source.is_some()); + // Database-managed protection deliberately retains structured metadata; actual pixels stay + // in the owning custody and are never inserted into the durable JSON result. + let json = serde_json::to_string(&saved.protected_result).unwrap(); + assert!(!json.contains("data:image")); + let captures = custody.captures.lock().await; + let capture = captures.values().next().unwrap(); + assert!(!json.contains(&hex::encode(&capture.bytes))); +} +#[tokio::test] +async fn ordinary_attachments_and_reopened_pixels_share_complete_request_limits() { + let (fixture, custody, service) = media_fixture(true).await; + let result = preview_turn(&fixture, service.clone(), 4).await.unwrap(); + let call = &result.interactive_tool_results()[0]; + let images = service + .authorize_tool_images(call.lease(), call.id(), call.egress_manifest().unwrap()) + .await + .unwrap(); + let mut request = plan(&fixture).request; + let attachment = ModelInputBlock::Attachment { + attachment_id: Uuid::new_v4().to_string(), + mime: "image/png".into(), + byte_count: images[0].byte_count(), + sha256: images[0].reference().sha256().into(), + }; + request.input.push(attachment.clone()); + let raw = images[0].byte_count() * 2; + let exact = AiProviderAttachmentResolutionLimits::new(2, raw, raw).unwrap(); + assert!(validate_combined_media_batch(&request, &images, exact, 2).is_ok()); + let over_bytes = AiProviderAttachmentResolutionLimits::new(2, raw - 1, raw - 1).unwrap(); + assert!(validate_combined_media_batch(&request, &images, over_bytes, 2).is_err()); + assert!(validate_combined_media_batch(&request, &images, exact, 1).is_err()); + let over_count = AiProviderAttachmentResolutionLimits::new(1, raw, raw).unwrap(); + assert!(validate_combined_media_batch(&request, &images, over_count, 2).is_err()); + request + .input + .extend([attachment.clone(), attachment.clone(), attachment]); + assert!( + validate_combined_media_batch( + &request, + &images, + AiProviderAttachmentResolutionLimits::default(), + 5 + ) + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn absent_image_capacity_and_unsupported_adapter_refuse_before_effect() { + for (supported, units) in [(true, 0), (false, 4)] { + let (fixture, custody, service) = media_fixture(supported).await; + assert!(preview_turn(&fixture, service, units).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + } +} + +#[cfg(feature = "provider-codex-app-server")] +#[tokio::test] +async fn legacy_codex_dynamic_factory_refuses_protected_tool_before_launch_or_effect() { + let (fixture, custody, service) = media_fixture(true).await; + let mut plan = tool_plan(&fixture); + plan.provider_kind = ProviderKind::LocalHarness; + plan.budget.provider_kind = ProviderKind::LocalHarness; + plan.budget.estimate.image_units = 4; + for transfer in &mut plan.transfers { + transfer.provider_kind = ProviderKind::LocalHarness.as_str().into(); + } + let plan = AiProviderCallPlan::new_with_tools( + plan.provider_kind, + plan.request, + plan.budget, + plan.transfers, + plan.correlation_id, + fixture.runtime.tool_catalog(), + &static_read_policy(&fixture), + ) + .unwrap(); + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ); + let result = executor + .execute_with_dynamic_tools( + Arc::new(Mutex::new(fixture.lease.clone())), + plan, + Arc::new(PreviewExecution { + service, + scope: fixture.scope.clone(), + }), + ) + .await; + assert!(matches!(result, Err(AiError::PreTransportProviderFailed))); + assert_eq!(custody.legacy_factory_launches.load(Ordering::SeqCst), 0); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + assert!(custody.captures.lock().await.is_empty()); + let budgets = AiBudgetReservationRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + assert_eq!(budgets.len(), 1); + assert_eq!(budgets[0].state, "released"); + + // The legacy factory remains usable for ordinary text. Its intentional launch failure + // proves the protected refusal above happened earlier than the factory transport seam. + let mut ordinary = super::plan(&fixture); + ordinary.provider_kind = ProviderKind::LocalHarness; + ordinary.budget.provider_kind = ProviderKind::LocalHarness; + ordinary.budget.idempotency_key = "legacy-ordinary-text".into(); + ordinary.request.continuation_mode = ModelContinuationMode::StatelessReplay; + for transfer in &mut ordinary.transfers { + transfer.provider_kind = ProviderKind::LocalHarness.as_str().into(); + } + let ordinary = AiProviderCallPlan::new( + ordinary.provider_kind, + ordinary.request, + ordinary.budget, + ordinary.transfers, + ordinary.correlation_id, + ) + .unwrap(); + assert!(executor.execute(&fixture.lease, ordinary).await.is_err()); + assert_eq!(custody.legacy_factory_launches.load(Ordering::SeqCst), 1); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn image_capable_adapter_without_current_source_delivery_refuses_before_effect() { + let (fixture, custody, service) = media_fixture(true).await; + custody + .source_delivery_allowed + .store(false, Ordering::SeqCst); + assert!(preview_turn(&fixture, service, 4).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + assert!(custody.captures.lock().await.is_empty()); +} + +#[tokio::test] +async fn absent_or_changed_retained_effect_fence_is_refused_without_reexecution() { + for omit in [true, false] { + let (fixture, custody, service) = media_fixture(true).await; + let result = preview_turn(&fixture, service.clone(), 4).await.unwrap(); + let call = &result.interactive_tool_results()[0]; + let saved = AiToolCallRecord::find_by_id(&fixture.database, &call.id().0) + .await + .unwrap() + .unwrap(); + let mut envelope: crate::ProtectedContentEnvelope = + serde_json::from_value(saved.protected_result_source.clone().unwrap()).unwrap(); + let crate::ProtectedContentEnvelope::DatabaseManaged { value } = &mut envelope else { + unreachable!() + }; + if omit { + value["execution"] + .as_object_mut() + .unwrap() + .remove("effect_attempt_id"); + value["execution"] + .as_object_mut() + .unwrap() + .remove("effect_lease_generation"); + } else { + value["execution"]["effect_attempt_id"] = json!(Uuid::new_v4()); + } + AiToolCallRecord::compare_and_swap( + &fixture.database, + &saved.id, + saved.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + protected_result_source: Some(Some(serde_json::to_value(envelope).unwrap())), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + } +} +#[tokio::test] +async fn current_consent_revocation_before_wire_keeps_effect_result_and_uncertain_budget() { + let (fixture, custody, service) = media_fixture(true).await; + custody.revoke_before_delivery.store(true, Ordering::SeqCst); + assert!(preview_turn(&fixture, service.clone(), 4).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let sources = AiToolResultSourceRecord::query(fixture.database.pool()) + .limit(2) + .fetch_all() + .await + .unwrap(); + assert_eq!(sources.len(), 1); + let call = AiToolCallRecord::find_by_id(&fixture.database, &sources[0].id) + .await + .unwrap() + .unwrap(); + assert!(call.protected_result_source.is_some()); + assert!(call.protected_result.is_some()); + let budget = AiBudgetReservationRecord::find_by_id( + &fixture.database, + &call.budget_reservation_id.unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(budget.state, "uncertain"); + assert_eq!(budget.actual_image_units, None); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} +#[tokio::test] +async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { + let (fixture, custody, service) = media_fixture(true).await; + let result = preview_turn(&fixture, service.clone(), 4).await.unwrap(); + let call = &result.interactive_tool_results()[0]; + let source = service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::History, + ) + .await + .unwrap(); + let mut manifest = call.egress_manifest().unwrap().clone(); + manifest.sources.push(AiDataSourceRef { + kind: "caller_marker".into(), + reference: source.source_reference(), + classification: DataClassification::Restricted, + trust: AiSourceTrust::ResolverResult, + }); + assert!( + fixture + .runtime + .authorize_tool_source_egress( + call.lease().principal_reference(), + &manifest, + std::slice::from_ref(&source) + ) + .await + .unwrap() + .authorize(&manifest) + .is_err() + ); + let saved = AiToolCallRecord::find_by_id(&fixture.database, &call.id().0) + .await + .unwrap() + .unwrap(); + AiToolCallRecord::compare_and_swap( + &fixture.database, + &saved.id, + saved.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + argument_hash: Some("0".repeat(64)), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::History + ) + .await + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn delivery_history_race_denies_protected_sources_but_preserves_general_denials() { + let (fixture, custody, service) = media_fixture(true).await; + let result = preview_turn(&fixture, service.clone(), 4).await.unwrap(); + let call = &result.interactive_tool_results()[0]; + let mut manifest = call.egress_manifest().unwrap().clone(); + manifest.capability = AiEgressCapability::ModelInference; + let delivery = ToolSourceDelivery::new( + service, + Arc::new(Mutex::new(call.lease().clone())), + &[manifest], + 4, + None, + ) + .unwrap() + .unwrap(); + delivery.authorize().await.unwrap(); + custody.allowed.store(false, Ordering::SeqCst); + assert!(matches!( + delivery.authorize().await, + Err(ProviderError::ToolSourceUnavailable) + )); + custody.ordinary_allowed.store(false, Ordering::SeqCst); + assert!(matches!( + delivery.authorize().await, + Err(ProviderError::EgressDenied) + )); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn post_effect_source_open_failure_retains_actual_output_for_later_read_without_reexecution() +{ + let (fixture, custody, service) = media_fixture(true).await; + custody.fail_next_source_open.store(true, Ordering::SeqCst); + assert!(preview_turn(&fixture, service.clone(), 4).await.is_err()); + let sources = AiToolResultSourceRecord::query(fixture.database.pool()) + .limit(2) + .fetch_all() + .await + .unwrap(); + assert_eq!(sources.len(), 1); + let call = AiToolCallRecord::find_by_id(&fixture.database, &sources[0].id) + .await + .unwrap() + .unwrap(); + assert_eq!(call.state, "source_retained"); + assert!(call.protected_result.is_some()); + assert!(call.protected_result_source.is_some()); + let actual_run = AiRunRecord::find_by_id(&fixture.database, &fixture.lease.run_id().0) + .await + .unwrap() + .unwrap(); + let current = crate::orm_runs::lease_from_record(&actual_run).unwrap(); + let context = service + .reopen_tool_result_source( + ¤t, + AiToolCallId(call.id), + crate::AiToolResultDisclosurePurpose::Continuation, + ) + .await + .unwrap(); + let images = service + .reopen_tool_images(¤t, &context) + .await + .unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn invalid_post_effect_media_is_saved_but_cannot_be_admitted_or_replayed() { + let (fixture, custody, service) = media_fixture(true).await; + custody.invalid_image_metadata.store(true, Ordering::SeqCst); + assert!(preview_turn(&fixture, service.clone(), 4).await.is_err()); + let calls = AiToolCallRecord::query(fixture.database.pool()) + .limit(2) + .fetch_all() + .await + .unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].protected_result.is_some()); + assert!(calls[0].protected_result_source.is_none()); + assert_eq!(calls[0].state, "executing"); + assert!( + AiToolResultSourceRecord::query(fixture.database.pool()) + .limit(1) + .fetch_all() + .await + .unwrap() + .is_empty() + ); + assert!( + service + .reopen_tool_result_source( + &fixture.lease, + AiToolCallId(calls[0].id), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err() + ); + assert!(preview_turn(&fixture, service, 4).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert_eq!(custody.opens.load(Ordering::SeqCst), 0); +} + +struct MediaActionPreview; +struct NativePreviewExecution { + application: Arc, + consequential: Arc, + scope: AiScope, + prepared: Mutex>, + last_lease: Mutex>, +} +#[async_trait] +impl AiProviderDynamicToolExecution for NativePreviewExecution { + fn tool_result_source_service(&self) -> Option<&OrmAiApplicationToolCallService> { + Some(&self.application) + } + async fn execute_dynamic_tool( + &self, + _: &AiRunLease, + _: &AiProviderCallResult, + _: usize, + ) -> Result { + Err(AiError::Forbidden) + } + async fn execute_dynamic_outcome( + &self, + lease: &AiRunLease, + result: &AiProviderCallResult, + index: usize, + ) -> Result { + let prepared = self + .consequential + .prepare_native_approval( + lease, + result, + AiApplicationToolCallContext::new( + 0, + index, + self.scope.clone(), + "approved-media-test", + result.budget_reservation_id().0.to_string(), + )?, + ) + .await?; + *self.last_lease.lock().await = Some(prepared.lease().clone()); + let receipt = self + .consequential + .prepare_native_approval_receipt(prepared.lease(), &prepared, &preview_route()) + .await?; + *self.last_lease.lock().await = Some(receipt.lease().clone()); + *self.prepared.lock().await = Some(prepared); + Ok(AiProviderDynamicToolOutcome::NativeControl(Box::new( + receipt, + ))) + } + async fn failed_callback_lease(&self) -> Option { + self.last_lease.lock().await.clone() + } +} +#[tokio::test] +async fn late_owning_image_resolution_is_discarded_and_saved_effect_is_not_repeated() { + let (fixture, custody, service) = media_fixture_resolution(true, false, 25).await; + custody.delay_open.store(true, Ordering::SeqCst); + assert!(preview_turn(&fixture, service, 4).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let call = AiToolCallRecord::query(fixture.database.pool()) + .limit(1) + .fetch_one() + .await + .unwrap() + .unwrap(); + assert!(call.protected_result.is_some()); + assert!(call.protected_result_source.is_some()); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); +} + +#[async_trait] +impl AiCanonicalActionPreviewBuilder for MediaActionPreview { + async fn build_preview( + &self, + _: &ResolvedPrincipal, + _: &AiToolDescriptor, + _: &ToolGraphqlRequest, + ) -> Result { + Ok(AiCanonicalActionPreview { + action_kind: "capture_preview".into(), + title: "Capture one authorized preview".into(), + targets: vec![AiApprovalResourceBinding { + resource_type: "preview".into(), + resource_id: "original-resource".into(), + expected_version: "1".into(), + }], + details: json!({"capture":true}), + }) + } +} + +async fn media_input_and_sessions(fixture: &Fixture) -> Arc { + // Initial fixture input uses generated ORM entities; all lifecycle transitions below use + // the owning session/approval/run services, never direct writes to their state fields. + let session = AiSessionRecord::find_by_id(&fixture.database, &fixture.lease.session_id().0) + .await + .unwrap() + .unwrap(); + AiSessionRecord::compare_and_swap( + &fixture.database, + &session.id, + session.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + message_head: Some(1), + stream_head: Some(1), + ..Default::default() + }, + ) + .await + .unwrap(); + AiMessageRecord::insert( + &fixture.database, + CreateAiMessageRecordInput { + id: fixture.lease.input_message_id(), + session_id: session.id, + sequence: 1, + message_role: "user".into(), + author_principal_kind: Some("user".into()), + author_subject: Some(fixture.principal.subject().into()), + client_message_id: Some(Uuid::new_v4()), + content_hash: Some("c".repeat(64)), + run_id: Some(fixture.lease.run_id().0), + provider_kind: None, + provider_model: None, + protected_preview: None, + block_count: 1, + completion_state: "complete".into(), + finalized_at: Some(OffsetDateTime::now_utc().unix_timestamp()), + content_purged_at: None, + }, + ) + .await + .unwrap(); + let message = AiMessageRecord::find_by_id(&fixture.database, &fixture.lease.input_message_id()) + .await + .unwrap() + .unwrap(); + let event_id = Uuid::new_v4(); + AiSessionEventRecord::insert( + &fixture.database, + crate::persistence::CreateAiSessionEventRecordInput { + id: event_id, + session_id: session.id, + sequence: 1, + event_type: "message_queued".into(), + run_id: Some(fixture.lease.run_id().0), + causation_id: Some(message.client_message_id.unwrap().to_string()), + correlation_id: message.client_message_id.unwrap().to_string(), + protected_payload: serde_json::to_value( + crate::ProtectedContentEnvelope::DatabaseManaged { + value: json!({"messageId":message.id,"runId":fixture.lease.run_id().0}), + }, + ) + .unwrap(), + }, + ) + .await + .unwrap(); + let block_id = Uuid::new_v4(); + let policy = ProtectionPolicy + .resolve(&fixture.principal, &fixture.scope) + .await + .unwrap(); + let envelope = crate::AiContentProtector::protect( + &DatabaseManagedContentProtector, + &policy, + &crate::ContentProtectionContext { + entity: "graphql_orm_ai_message_blocks".into(), + row_id: block_id.to_string(), + field: "protected_content".into(), + scope: fixture.scope.clone(), + }, + json!({"text":"hello"}), + ) + .await + .unwrap(); + AiMessageBlockRecord::insert( + &fixture.database, + CreateAiMessageBlockRecordInput { + id: block_id, + message_id: fixture.lease.input_message_id(), + block_index: 0, + block_kind: "text".into(), + protected_content: serde_json::to_value(envelope).unwrap(), + byte_count: 5, + line_count: 1, + }, + ) + .await + .unwrap(); + Arc::new( + OrmAiProviderSessionService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(SystemClock), + AiProviderSessionLimits::default(), + Duration::minutes(5), + ) + .unwrap(), + ) +} + +#[tokio::test] +async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_executing_again() { + approved_media_lifecycle(false, false, 0, None).await; +} + +#[tokio::test] +async fn native_history_normal_deny_after_preflight_withholds_saved_effect_and_releases_unstarted_budget() + { + approved_media_lifecycle(true, false, 0, None).await; +} + +#[tokio::test] +async fn approved_effect_with_invalid_media_keeps_output_and_enters_recovery_without_replay() { + approved_media_lifecycle(false, true, 0, None).await; +} + +#[tokio::test] +async fn approved_completed_effect_survives_mid_effect_scope_and_session_withdrawal() { + for withdrawal in [1, 2] { + approved_media_lifecycle(false, false, withdrawal, None).await; + } +} + +async fn approved_media_lifecycle( + deny_history: bool, + invalid_metadata: bool, + withdrawal: usize, + reset_mode: Option, +) { + let (mut fixture, custody, application) = media_fixture_mode(true, true).await; + if reset_mode.is_some_and(|mode| mode < 8) { + fixture.budget_service = static_offering_tests::extended_task_budget_service(&fixture); + let policy = AiBudgetPolicyRecord::query(fixture.database.pool()) + .limit(1) + .fetch_one() + .await + .unwrap() + .unwrap(); + AiBudgetPolicyRecord::update_by_id( + &fixture.database, + &policy.id, + crate::persistence::UpdateAiBudgetPolicyRecordInput { + maximum_input_tokens: Some(Some(20_000)), + ..Default::default() + }, + ) + .await + .unwrap(); + } + let descriptor = fixture + .runtime + .tool_catalog() + .descriptor(&AiToolId::parse("records.read").unwrap()) + .unwrap(); + let mut base = plan(&fixture); + base.budget.estimate.image_units = 4; + base.request.tools = vec![ModelToolDefinition { + tool_id: descriptor.id.as_str().into(), + provider_name: "records_read".into(), + fingerprint: descriptor.fingerprint.clone(), + description: descriptor.description.clone(), + parameters: descriptor.argument_schema.clone(), + strict: true, + defer_loading: false, + }]; + base.transfers[0].estimated_bytes = base.request.conservative_egress_bytes(); + let mut policy = AiToolPolicySet::new(ToolMaturity::SupervisedWrite); + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + let coordinator_delivery = reset_mode.filter(|mode| *mode >= 8).map(|_| { + super::native_coordinator_tests::mixed_broker_delivery(&fixture, base.request.tools.clone()) + }); + let offered = if let Some(delivery) = &coordinator_delivery { + base.request.tools = delivery.current_tools(); + base.budget.estimate.input_tokens = 8000; + base.transfers[0].estimated_bytes = base.request.conservative_egress_bytes(); + AiProviderCallPlan::new_with_classified_capability_surface( + base.provider_kind, + base.request, + base.budget, + base.transfers, + "approved-media-test", + &delivery.current_surface(), + fixture.runtime.tool_catalog(), + &policy, + &fixture.generated_target_policy, + ) + } else { + AiProviderCallPlan::new_with_classified_tools( + base.provider_kind, + base.request, + base.budget, + base.transfers, + "approved-media-test", + fixture.runtime.tool_catalog(), + &policy, + &fixture.generated_target_policy, + ) + } + .unwrap(); + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ); + let sessions = media_input_and_sessions(&fixture).await; + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + let checkpoints = Arc::new(OrmAiCoordinatorCheckpointService::new( + fixture.run_service.clone(), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(NativeTestRuleResolver), + Arc::new(SystemClock), + AiCoordinatorCheckpointLimits::new(256 * 1024, Duration::minutes(5)).unwrap(), + )); + let (_, approvals) = consequential_test_service(&fixture); + let consequential = Arc::new( + OrmAiConsequentialToolCallService::new( + fixture.run_service.clone(), + fixture.runtime.clone(), + approvals.clone(), + Arc::new(MediaActionPreview), + fixture.audit.clone(), + Arc::new(SystemClock), + AiApplicationToolCallLimits::new( + 8192, + 16384, + 4, + 4, + Duration::seconds(30), + Duration::seconds(10), + ) + .unwrap(), + ) + .with_provider_session_service(sessions.clone()), + ); + let native = Arc::new(NativePreviewExecution { + application: application.clone(), + consequential: consequential.clone(), + scope: fixture.scope.clone(), + prepared: Mutex::new(None), + last_lease: Mutex::new(None), + }); + let active = Arc::new(Mutex::new(fixture.lease.clone())); + let selected = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + coordinator_delivery.as_ref().map_or_else( + || "a".repeat(64), + |delivery| delivery.session_binding().fingerprint().to_owned(), + ), + "mock/v1", + "b".repeat(64), + ) + .unwrap(); + let provider = executor + .with_tool_result_source_service(application.clone()) + .unwrap() + .execute_with_provider_session( + active.clone(), + offered, + crate::AiProviderSessionTurnPlan::new(selected, "d".repeat(64)).unwrap(), + sessions.clone(), + Some(native.clone()), + ) + .await + .unwrap(); + let prepared = native.prepared.lock().await.take().unwrap(); + let active = active.lock().await.clone(); + let (_, usage) = test_rule_checkpoint(&fixture.scope, &[&provider], 1); + let protected = checkpoints + .persist_native_approval_provider_turn( + &active, + &provider, + &prepared, + &fixture.scope, + "approved-media-test", + &preview_route(), + &native_test_rules(fixture.scope.clone()), + usage, + 1, + 1, + ) + .await + .unwrap(); + let requested = consequential + .finalize_prepared_approval( + &protected, + &provider, + &prepared, + OffsetDateTime::now_utc() + Duration::minutes(5), + false, + ) + .await + .unwrap(); + assert_eq!(requested.lease().state(), AiRunState::WaitingApproval); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + let pending = AiApprovalRecord::find_by_id(&fixture.database, &requested.approval_id().0) + .await + .unwrap() + .unwrap(); + approvals + .decide_approval( + &fixture.principal, + DecideAiApprovalInput { + id: pending.id, + decision: AiApprovalDecision::Approve, + expected_version: pending.row_version, + }, + ) + .await + .unwrap(); + let claim = fixture + .run_service + .claim_next_approved("approved-media-resume") + .await + .unwrap() + .unwrap(); + assert!(claim.lease().lease_generation() > fixture.lease.lease_generation()); + let resume = OrmAiSupervisedResumeService::new( + fixture.run_service.clone(), + checkpoints.clone(), + consequential.clone(), + ); + if let Some(delivery) = coordinator_delivery { + Box::pin(static_offering_tests::owning_coordinator_reset( + fixture, + custody, + application, + sessions, + checkpoints, + consequential, + Arc::new(resume), + delivery, + claim, + reset_mode.unwrap(), + )) + .await; + return; + } + custody + .invalid_image_metadata + .store(invalid_metadata, Ordering::SeqCst); + custody + .withdraw_access_after_effect + .store(withdrawal, Ordering::SeqCst); + let completed = resume.execute_claimed(&claim).await.unwrap(); + if invalid_metadata || withdrawal != 0 { + assert!(matches!( + completed, + crate::AiSupervisedResumeOutcome::RecoveryRequired { .. } + )); + let call = AiToolCallRecord::find_by_id(&fixture.database, &requested.tool_call_id().0) + .await + .unwrap() + .unwrap(); + assert!(call.protected_result.is_some()); + assert!(call.protected_result_source.is_none()); + if withdrawal != 0 { + let envelope: crate::ProtectedContentEnvelope = + serde_json::from_value(call.protected_result.clone().unwrap()).unwrap(); + let crate::ProtectedContentEnvelope::DatabaseManaged { value } = envelope else { + unreachable!() + }; + let captures = custody.captures.lock().await; + let capture = captures.values().next().unwrap(); + assert_eq!( + value["data"]["Preview"]["Reference"], + json!(capture.reference.reference()) + ); + assert_eq!( + value["data"]["Preview"]["Sha256"], + capture.reference.sha256() + ); + assert!(custody.access_withdrawn.load(Ordering::SeqCst)); + assert_eq!(custody.opens.load(Ordering::SeqCst), 0); + assert_eq!(custody.model_disclosures.load(Ordering::SeqCst), 0); + assert!( + AiToolResultSourceRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap() + .is_empty() + ); + } + let run = AiRunRecord::find_by_id(&fixture.database, &claim.lease().run_id().0) + .await + .unwrap() + .unwrap(); + assert_eq!(run.state, "recovery_required"); + assert!(resume.execute_claimed(&claim).await.is_err()); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); + return; + } + let current = completed.checkpointed().unwrap().lease(); + let source = application + .reopen_tool_result_source( + current, + requested.tool_call_id(), + crate::AiToolResultDisclosurePurpose::Continuation, + ) + .await + .unwrap(); + assert_eq!( + source.execution_provenance().attempt_id(), + fixture.lease.attempt_id() + ); + assert_eq!(source.effect_attempt_id(), claim.lease().attempt_id()); + assert_eq!( + source.execution_provenance().effect_attempt_id(), + Some(claim.lease().attempt_id()) + ); + assert_eq!( + source.execution_provenance().effect_lease_generation(), + Some(claim.lease().lease_generation()) + ); + assert_eq!( + source.effect_lease_generation(), + claim.lease().lease_generation() + ); + assert_eq!( + source.execution_provenance().approval_id(), + Some(requested.approval_id()) + ); + assert_eq!(source.current_attempt_id(), claim.lease().attempt_id()); + assert_eq!( + application + .reopen_tool_images(current, &source) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert!(resume.execute_claimed(&claim).await.is_err()); + let consumed = AiApprovalRecord::find_by_id(&fixture.database, &pending.id) + .await + .unwrap() + .unwrap(); + assert_eq!(consumed.consumed_uses, 1); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let adopted = checkpoints + .adopt_classified_mutation_batch(current) + .await + .unwrap() + .unwrap(); + let crate::AiAdoptedClassifiedMutationBatch::Supervised(adopted) = adopted else { + panic!("approved effect must retain its supervised checkpoint") + }; + let current = checkpoints + .consume_supervised_before_provider(current, &adopted) + .await + .unwrap(); + if let Some(mode) = reset_mode { + exercise_owned_static_reset( + &fixture, + &custody, + application, + sessions, + native, + ¤t, + &adopted, + mode, + ) + .await; + return; + } + let mut next = plan(&fixture); + next.budget.attempt_id = current.attempt_id(); + next.budget.lease_generation = current.lease_generation(); + next.budget.idempotency_key = "approved-media-history".into(); + next.budget.estimate.image_units = 4; + custody.requests.lock().await.clear(); + if deny_history { + custody.deny_model_disclosure_at.store(2, Ordering::SeqCst); + } + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ) + .with_tool_result_source_service(application.clone()) + .unwrap(); + let selected = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + "a".repeat(64), + "mock/v1", + "b".repeat(64), + ) + .unwrap(); + let continued = executor + .execute_with_provider_session( + Arc::new(Mutex::new(current)), + next, + crate::AiProviderSessionTurnPlan::new(selected, "d".repeat(64)).unwrap(), + sessions, + None, + ) + .await + .unwrap(); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let sent = custody.requests.lock().await; + assert_eq!(sent.len(), 1); + if deny_history { + assert_eq!(custody.model_disclosures.load(Ordering::SeqCst), 2); + assert_eq!(continued.usage().image_units, 0); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); + assert!(sent[0].tools.is_empty()); + assert!(sent[0].continuation.is_none()); + assert!(matches!(&sent[0].input[0],ModelInputBlock::Text{text} if text=="hello")); + let serialized = serde_json::to_string(&sent[0].input).unwrap(); + assert!(serialized.contains("historicalToolContentWithheld")); + assert!(!serialized.contains("Preview")); + let budgets = AiBudgetReservationRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + assert_eq!( + budgets.iter().filter(|row| row.state == "released").count(), + 1 + ); + assert_eq!( + budgets + .iter() + .filter(|row| row.state == "committed") + .count(), + 2 + ); + assert!(budgets.iter().all(|row| row.state != "uncertain")); + assert_eq!( + budgets + .iter() + .find(|row| row.id == continued.budget_reservation_id().0) + .unwrap() + .reserved_image_units, + 0 + ); + } else { + assert_eq!(continued.usage().image_units, 1); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 1); + let inference = continued.model_inference_manifest(); + assert_eq!(inference.attachment_count, 1); + assert_eq!( + inference + .sources + .iter() + .filter(|source| source.kind == "tool_result_image") + .count(), + 1 + ); + let raw = custody + .captures + .lock() + .await + .values() + .next() + .unwrap() + .bytes + .len() as u64; + assert!(inference.estimated_bytes >= raw.div_ceil(3) * 4 + 1024); + } +} + +#[tokio::test] +async fn ordinary_text_with_zero_image_budget_does_not_offer_or_execute_media() { + let (fixture, custody, _) = media_fixture(false).await; + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ); + let result = executor + .execute(&fixture.lease, plan(&fixture)) + .await + .unwrap(); + assert_eq!(result.usage().image_units, 0); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn owner_cancellation_closes_source_retained_call_and_preserves_effect_custody() { + let (fixture, custody, application) = media_fixture(true).await; + custody.fail_next_source_open.store(true, Ordering::SeqCst); + assert!( + preview_turn(&fixture, application.clone(), 4) + .await + .is_err() + ); + let call = AiToolCallRecord::query(fixture.database.pool()) + .limit(1) + .fetch_one() + .await + .unwrap() + .unwrap(); + assert_eq!(call.state, "source_retained"); + let cancellation = crate::OrmAiRunCancellationService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(SystemClock), + crate::AiRunCancellationLimits::default(), + Arc::new(crate::AiRunCancellationHub::default()), + ); + cancellation + .request_cancellation( + &fixture.principal, + crate::CancelAiRunInput { + session_id: fixture.lease.session_id().0, + run_id: fixture.lease.run_id().0, + client_request_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + let cancelled = AiToolCallRecord::find_by_id(&fixture.database, &call.id) + .await + .unwrap() + .unwrap(); + assert_eq!(cancelled.state, "cancelled"); + assert_eq!(cancelled.protected_result, call.protected_result); + assert_eq!( + cancelled.protected_result_source, + call.protected_result_source + ); + assert!( + application + .reopen_tool_result_source( + &fixture.lease, + AiToolCallId(call.id), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn prepared_original_task_survives_a_later_queued_message_without_new_human_dispatch() { + let (mut fixture, custody, application) = media_fixture(true).await; + let sessions = OrmAiSessionService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + ); + let session = sessions + .create_session( + &fixture.principal, + crate::CreateAiSessionInput { + execution_selection: None, + scope: crate::AiScopeInput { + kind: fixture.scope.kind.clone(), + id: fixture.scope.id.clone(), + tenant_id: fixture.scope.tenant_id.clone(), + }, + title: None, + }, + ) + .await + .unwrap(); + let original_client_id = Uuid::new_v4(); + let original = sessions + .send_message( + &fixture.principal, + crate::SendAiMessageInput { + session_id: session.id, + text: "Complete this exact original task".into(), + attachment_ids: Vec::new(), + client_message_id: original_client_id, + }, + ) + .await + .unwrap(); + let claimed = fixture + .run_service + .claim_next("original-context") + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.run_id().0, original.run_id); + fixture.lease = fixture.run_service.start(&claimed).await.unwrap(); + let before = application + .prepare_task_continuation_context(&fixture.lease, &fixture.scope) + .await + .unwrap(); + let later = sessions + .send_message( + &fixture.principal, + crate::SendAiMessageInput { + session_id: session.id, + text: "A separate later task must not replace the active task".into(), + attachment_ids: Vec::new(), + client_message_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + assert_ne!(later.run_id, original.run_id); + let events_before = AiSessionEventRecord::query(fixture.database.pool()) + .count() + .await + .unwrap(); + let after = application + .prepare_task_continuation_context(&fixture.lease, &fixture.scope) + .await + .unwrap(); + assert_eq!(before.source_fingerprint, after.source_fingerprint); + assert_eq!(before.frame, after.frame); + application + .validate_task_continuation_context(&fixture.lease, &fixture.scope, &before) + .await + .unwrap(); + let ModelInputBlock::Text { text } = &after.frame else { + panic!("task context stays untrusted text") + }; + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(value["originalMessageId"], original.message_id.to_string()); + assert_eq!( + value["originalClientMessageId"], + original_client_id.to_string() + ); + assert_eq!( + value["goalContext"]["text"], + "Complete this exact original task" + ); + assert_eq!(value["newHumanEventCreated"], false); + assert_eq!(value["priorProtectedContentIncluded"], false); + assert_eq!(value["savedInvocationStatuses"], json!([])); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); + assert_eq!( + AiSessionEventRecord::query(fixture.database.pool()) + .count() + .await + .unwrap(), + events_before + ); + assert!( + sessions + .delete_session(&fixture.principal, crate::AiSessionId(session.id)) + .await + .unwrap() + ); + assert!( + application + .validate_task_continuation_context(&fixture.lease, &fixture.scope, &before) + .await + .is_err() + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 0); +} + +struct ConfirmPreviewDeletion { + custody: Arc, + fail: bool, + requests: Mutex>, + created_at_delete: Mutex>, +} +#[async_trait] +impl crate::AiProviderSessionDeletionService for ConfirmPreviewDeletion { + async fn delete_or_confirm_absent( + &self, + request: &crate::AiProviderSessionDeletionRequest, + ) -> Result { + self.requests + .lock() + .await + .push(request.cursor().fingerprint()); + assert_eq!(self.custody.effects.load(Ordering::SeqCst), 1); + self.created_at_delete + .lock() + .await + .push(self.custody.created_threads.load(Ordering::SeqCst)); + if self.fail { + return Err(ProviderError::Rejected); + } + Ok(crate::AiProviderSessionAbsenceProof::for_request( + request, + OffsetDateTime::now_utc(), + )) + } +} + +#[tokio::test] +async fn owning_approved_static_reset_requires_confirmed_cleanup_and_current_source_without_effect_replay() + { + for mode in [0, 1, 2, 3, 4, 5, 6, 7] { + Box::pin(approved_media_lifecycle(false, false, 0, Some(mode))).await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn exercise_owned_static_reset( + fixture: &Fixture, + custody: &Arc, + application: Arc, + sessions: Arc, + native: Arc, + lease: &AiRunLease, + adopted: &crate::AiAdoptedSupervisedToolBatch, + mode: usize, +) { + let descriptor = fixture + .runtime + .tool_catalog() + .descriptor(&AiToolId::parse("records.read").unwrap()) + .unwrap(); + let definition = ModelToolDefinition { + tool_id: descriptor.id.as_str().into(), + provider_name: "records_read".into(), + fingerprint: descriptor.fingerprint.clone(), + description: descriptor.description.clone(), + parameters: descriptor.argument_schema.clone(), + strict: true, + defer_loading: false, + }; + let delivery = + super::native_coordinator_tests::mixed_broker_delivery(fixture, vec![definition]); + delivery + .set_static_bootstrap_offering(&BTreeSet::new()) + .unwrap(); + let mut policy = AiToolPolicySet::new(ToolMaturity::SupervisedWrite); + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + let prepared = application + .prepare_task_continuation_context(lease, &fixture.scope) + .await + .unwrap(); + let continuation = adopted + .continuation() + .clone() + .with_prepared_task_context(prepared) + .unwrap(); + let original_reference = continuation.chain_reference().unwrap(); + let mut base = plan(fixture); + base.request.input.clear(); + base.request.tools = delivery.current_tools(); + base.budget.attempt_id = lease.attempt_id(); + base.budget.lease_generation = lease.lease_generation(); + base.budget.idempotency_key = "owned-static-reset".into(); + let mut complete_projection = base.request.clone(); + continuation + .clone() + .apply_with_bound_task_context(&mut complete_projection) + .unwrap(); + base.budget.estimate.input_tokens = complete_projection + .conservative_provider_egress_tokens(&base.provider_kind) + .unwrap(); + base.budget.estimate.image_units = 4; + assert!( + AiProviderCallPlan::new_continuation_with_classified_tools( + base.provider_kind.clone(), + base.request.clone(), + base.budget.clone(), + base.transfers.clone(), + "owned-static-reset", + continuation.clone(), + fixture.runtime.tool_catalog(), + &policy, + &fixture.generated_target_policy + ) + .is_err(), + "another constructor cannot drop owning prepared-context evidence" + ); + let mut next = AiProviderCallPlan::new_continuation_with_classified_capability_surface( + base.provider_kind, + base.request, + base.budget, + base.transfers, + "owned-static-reset", + continuation, + &delivery.current_surface(), + fixture.runtime.tool_catalog(), + &policy, + &fixture.generated_target_policy, + ) + .unwrap(); + let selected = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + "a".repeat(64), + "mock/v1", + "b".repeat(64), + ) + .unwrap(); + let session_plan = crate::AiProviderSessionTurnPlan::new(selected, "d".repeat(64)).unwrap(); + let claim = sessions.reclaim_after_wait(lease).await.unwrap(); + let old_cursor = sessions + .open_for_run(lease, &claim) + .await + .unwrap() + .cursor() + .fingerprint(); + let deletion = Arc::new(ConfirmPreviewDeletion { + custody: custody.clone(), + fail: mode == 1, + requests: Mutex::new(Vec::new()), + created_at_delete: Mutex::new(Vec::new()), + }); + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(ImageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8192, 65536).unwrap(), + ) + .with_tool_result_source_service(application.clone()) + .unwrap() + .with_static_offering_reset_deletion_service(deletion.clone()); + if mode == 7 { + let candidate = + crate::AiUnstartedStaticOfferingReplan::prepare(&next, &delivery, &session_plan) + .unwrap(); + assert!(candidate.ready_for_callback().is_err()); + drop(next); + candidate.ready_for_callback().unwrap(); + assert!( + candidate + .validate_unstarted_cleanup(lease, sessions.as_ref(), OffsetDateTime::now_utc(),) + .await + .is_err(), + "an unstarted error category cannot manufacture deletion proof" + ); + assert!(deletion.requests.lock().await.is_empty()); + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 1); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let binding = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + &fixture.database, + &claim.binding_id(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(binding.state, "claimed"); + assert!(binding.protected_cursor.is_some()); + return; + } + if mode == 3 { + let original_task_context = next + .request + .input + .iter() + .find(|input| matches!(input, ModelInputBlock::Text { text } if text.contains("FrameworkTaskContinuationContext"))) + .unwrap() + .clone(); + let owning_sessions = OrmAiSessionService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + ); + let later = owning_sessions + .send_message( + &fixture.principal, + crate::SendAiMessageInput { + session_id: lease.session_id().0, + text: "A later independent task must not replace this completed effect's task" + .into(), + attachment_ids: Vec::new(), + client_message_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + assert_ne!(later.run_id, lease.run_id().0); + let events_before = AiSessionEventRecord::query(fixture.database.pool()) + .limit(32) + .fetch_all() + .await + .unwrap(); + let event_ids_before = events_before + .iter() + .map(|event| event.id) + .collect::>(); + let human_ids_before = events_before + .iter() + .filter(|event| event.event_type == "message_queued") + .map(|event| event.id) + .collect::>(); + assert_eq!(human_ids_before.len(), 2); + // An ordinary caller cannot admit the older input; only the owning completed-effect + // proof, attached privately by the executor, admits its exact original run context. + assert!( + sessions + .disposition_for_run(lease, &session_plan) + .await + .is_err() + ); + custody.requests.lock().await.clear(); + // Continue through the same owning native responder after the actual approved + // effect; this scripted turn completes without invoking another tool. + custody.complete_after_capture.store(true, Ordering::SeqCst); + let result = executor + .execute_with_provider_session( + Arc::new(Mutex::new(lease.clone())), + next, + session_plan, + sessions, + Some(native), + ) + .await + .unwrap(); + assert!(result.proves_static_offering_reset_from(Some(&original_reference))); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 2); + assert_eq!(*deletion.created_at_delete.lock().await, vec![1]); + assert_eq!(result.usage().image_units, 1); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 1); + let approvals = AiApprovalRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + assert_eq!(approvals.len(), 1); + assert_eq!(approvals[0].consumed_uses, 1); + let sent = custody.requests.lock().await; + assert_eq!(sent.len(), 1); + assert!(sent[0].input.contains(&original_task_context)); + let contents = serde_json::to_string(&sent[0].input).unwrap(); + assert!(contents.contains("FrameworkTaskContinuationContext")); + assert!(contents.contains(&lease.input_message_id().to_string())); + assert!(!contents.contains("A later independent task")); + let events_after = AiSessionEventRecord::query(fixture.database.pool()) + .limit(32) + .fetch_all() + .await + .unwrap(); + let human_ids_after = events_after + .iter() + .filter(|event| event.event_type == "message_queued") + .map(|event| event.id) + .collect::>(); + assert_eq!(human_ids_after, human_ids_before); + assert!( + events_before + .iter() + .all(|before| events_after.iter().any(|after| after.id == before.id)) + ); + let new_events = events_after + .iter() + .filter(|event| !event_ids_before.contains(&event.id)) + .collect::>(); + assert_eq!(new_events.len(), 1); + assert_eq!( + new_events[0].event_type, + crate::orm_runs::PROVIDER_SESSION_REBOUND_EVENT + ); + assert_eq!(new_events[0].session_id, lease.session_id().0); + assert_eq!(new_events[0].run_id, Some(lease.run_id().0)); + assert_eq!(new_events[0].correlation_id, claim.binding_id().to_string()); + return; + } + if mode == 6 { + let cancellation = crate::OrmAiRunCancellationService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(SystemClock), + crate::AiRunCancellationLimits::default(), + Arc::new(crate::AiRunCancellationHub::default()), + ); + cancellation + .request_cancellation( + &fixture.principal, + crate::CancelAiRunInput { + session_id: lease.session_id().0, + run_id: lease.run_id().0, + client_request_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + } + if mode == 2 { + custody + .withdraw_access_after_effect + .store(1, Ordering::SeqCst); + custody.access_withdrawn.store(true, Ordering::SeqCst); + } + if mode == 4 || mode == 5 { + if mode == 4 { + custody.allowed.store(false, Ordering::SeqCst); + } + if mode == 5 { + next.budget.estimate.image_units = 0; + } + custody.requests.lock().await.clear(); + // The admission preflight proves the old image is unavailable under current + // consent/quote. The SDK performs the same protected withholding path used by + // the executor, with confirmed deletion and real fresh retained cursor ownership. + assert!( + executor + .prepare_retained_tool_history(lease, &mut next, &claim) + .await + .is_err() + ); + let result = executor + .execute_withheld_native_task_context( + Arc::new(Mutex::new(lease.clone())), + next, + &session_plan, + &(sessions.clone() as Arc), + &claim, + ) + .await + .unwrap(); + assert!(result.proves_static_offering_reset_from(Some(&original_reference))); + assert!(result.is_withheld_native_task_context()); + assert_eq!(result.usage().image_units, 0); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 2); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); + assert_eq!(*deletion.created_at_delete.lock().await, vec![1]); + let sent = custody.requests.lock().await; + assert_eq!(sent.len(), 1); + assert!(sent[0].tools.is_empty()); + assert!(sent[0].continuation.is_none()); + assert!( + sent[0] + .input + .iter() + .all(|input| matches!(input, ModelInputBlock::Text { .. })) + ); + let contents = serde_json::to_string(&sent[0].input).unwrap(); + assert!(contents.contains("FrameworkTaskContinuationContext")); + assert!(contents.contains("historicalToolContentWithheld")); + assert!(contents.contains("invocationAlreadyCompleted")); + assert!(!contents.contains("FrameworkApprovedToolOutcome")); + assert!(!contents.contains("Preview")); + assert!(!contents.contains("data:image")); + let inference = result.model_inference_manifest(); + assert_eq!(inference.attachment_count, 0); + assert!(inference.sources.iter().all(|source| { + source.kind != "application_tool_result" && source.kind != "tool_result_image" + })); + custody.ordinary_allowed.store(false, Ordering::SeqCst); + let denied = fixture + .runtime + .authorize_tool_source_egress(lease.principal_reference(), inference, &[]) + .await + .unwrap(); + assert!( + denied.authorize(inference).is_err(), + "zero sources never waive ordinary policy" + ); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + let budgets = AiBudgetReservationRecord::query(fixture.database.pool()) + .fetch_all() + .await + .unwrap(); + let current = budgets + .iter() + .find(|budget| budget.id == result.budget_reservation_id().0) + .unwrap(); + assert_eq!(current.state, "committed"); + assert_eq!(current.reserved_image_units, 0); + return; + } + let result = executor + .reset_static_offering( + lease, + &mut next, + &session_plan, + &(sessions.clone() as Arc), + Some(&claim), + ) + .await; + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 0); + if mode == 0 { + let (claim, cursor) = result.unwrap(); + assert_ne!(cursor.fingerprint(), old_cursor); + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 2); + assert_eq!(*deletion.requests.lock().await, vec![old_cursor]); + assert!(next.request.continuation.is_none()); + let receipt = next.static_offering_reset_receipt.as_ref().unwrap(); + assert_eq!(receipt.original_continuation_reference, original_reference); + assert_eq!(receipt.replacement_binding_id, claim.binding_id()); + assert_eq!(receipt.replacement_cursor_fingerprint, cursor.fingerprint()); + assert!( + matches!(&next.request.input[1],ModelInputBlock::Text{text} if text.contains("FrameworkTaskContinuationContext")) + ); + let row = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + &fixture.database, + &claim.binding_id(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(row.state, "claimed"); + assert_eq!( + row.installed_offering_fingerprint.as_deref(), + Some(next.offering_fingerprint().as_str()) + ); + } else { + assert!(result.is_err()); + assert_eq!(custody.created_threads.load(Ordering::SeqCst), 1); + let row = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + &fixture.database, + &claim.binding_id(), + ) + .await + .unwrap() + .unwrap(); + if mode == 1 { + assert_eq!(row.state, "cleanup_in_progress"); + assert!(row.protected_cursor.is_some()); + } else { + assert!(deletion.requests.lock().await.is_empty()); + assert_eq!(row.state, "claimed"); + } + } + let call = AiToolCallRecord::find_by_id(&fixture.database, &adopted.tool_call_id().0) + .await + .unwrap() + .unwrap(); + assert!(call.protected_result.is_some()); + assert_eq!(call.state, "completed"); + assert_eq!(custody.effects.load(Ordering::SeqCst), 1); +} diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs new file mode 100644 index 00000000..47ea68e4 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -0,0 +1,241 @@ +//! Current admission of retained native history and truthful withholding without effect replay. +use super::*; + +impl AiProviderCallExecutor { + pub(super) async fn prepare_retained_tool_history( + &self, + lease: &AiRunLease, + plan: &mut AiProviderCallPlan, + claim: &crate::AiProviderSessionClaim, + ) -> Result { + let Some(service) = &self.tool_result_source_service else { + if self.runtime.tool_catalog().descriptors().any(|descriptor| { + descriptor.result_media.is_some() + || descriptor.maximum_classification >= crate::DataClassification::Restricted + }) { + return Err(AiError::InvalidConfiguration( + "current protected tool source loader is required".into(), + )); + } + return Ok(0); + }; + let binding = crate::orm_provider_session::AiProviderSessionBindingRecord::find_by_id( + service.run_service().database(), + &claim.binding_id(), + ) + .await + .map_err(|_| AiError::PersistenceFailed)? + .ok_or(AiError::Conflict)?; + if binding.session_id != lease.session_id().0 + || binding.state != "claimed" + || binding.claimed_run_id != Some(lease.run_id().0) + || binding.claimed_attempt_id != Some(lease.attempt_id()) + || binding.claimed_run_lease_generation != Some(lease.lease_generation()) + || binding.registration_fingerprint != claim.descriptor().registration_fingerprint() + || binding.provider_profile_id != claim.descriptor().provider_profile_id() + || binding.claim_generation != claim.binding_claim_generation() + { + return Err(AiError::Conflict); + } + let inference = plan + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(AiError::EgressDenied)?; + let contexts = service + .retained_session_sources( + lease, + inference, + claim.binding_id(), + &binding.cursor_fingerprint, + ) + .await?; + if contexts.is_empty() { + return Ok(0); + } + let mut images = 0_u64; + let mut labels = Vec::new(); + for source in &contexts { + let descriptor = self + .runtime + .tool_catalog() + .descriptors() + .find(|descriptor| { + descriptor.fingerprint == source.execution_provenance().tool_fingerprint() + }) + .ok_or(AiError::EgressDenied)?; + for reference in source.images() { + let media = descriptor + .result_media + .as_ref() + .ok_or(AiError::EgressDenied)?; + let now_ms = i64::try_from(self.clock.now().unix_timestamp_nanos() / 1_000_000) + .map_err(|_| AiError::EgressDenied)?; + reference + .validate_at(media.limits(), now_ms) + .map_err(|_| AiError::EgressDenied)?; + images = images.checked_add(1).ok_or(AiError::EgressDenied)?; + labels.push(serde_json::json!({"reference":reference.reference(),"capturedAtMs":reference.captured_at_ms(),"actionExpiresAtMs":reference.action_expires_at_ms(),"retainedUntilMs":reference.retained_until_ms(),"historicalObservation":true,"actionEvidenceRenewed":false,"currentFieldDescribesOriginalCaptureOnly":true})); + } + let data_source = source.data_source(); + if !inference.sources.contains(&data_source) { + inference.sources.push(data_source); + } + } + if images > plan.budget.estimate.image_units { + return Err(AiError::EgressDenied); + } + if !labels.is_empty() { + plan.request.input.push(ModelInputBlock::Text { + text: serde_json::json!({"retainedToolImageCaptureLabels":labels}).to_string(), + }); + inference.estimated_bytes = plan + .request + .conservative_provider_egress_bytes(&plan.provider_kind) + .map_err(|_| AiError::EgressDenied)?; + } + // This is a preflight only. Actual post-reservation proof admission repeats owning + // principal/resource/media checks immediately before provider dispatch. + self.runtime + .authorize_tool_source_egress(lease.principal_reference(), inference, &contexts) + .await? + .authorize(inference)?; + Ok(images) + } + + pub(super) async fn withheld_tool_history_plan( + &self, + lease: &AiRunLease, + mut plan: AiProviderCallPlan, + ) -> Result { + let service = self + .tool_result_source_service + .as_ref() + .ok_or(AiError::EgressDenied)?; + let text = service.current_user_text(lease, &plan.budget.scope).await?; + // Retain only the current independently reopened human input and ordinary owned + // attachments. Prior model text/JSON can contain derived Restricted content too. + plan.request + .input + .retain(|input| matches!(input, ModelInputBlock::Attachment { .. })); + if let Some(context) = &plan.task_context { + plan.request.input.insert(0, context.frame.clone()); + plan.task_context_only = true; + } else { + plan.request.input.insert(0, ModelInputBlock::Text { text }); + } + let notice = serde_json::json!({"savedToolResultsRetained":true,"historicalToolContentWithheld":true, + "executionRepeated":false,"providerContextReset":true}); + plan.request.input.push(ModelInputBlock::Text { + text: notice.to_string(), + }); + plan.request.continuation = None; + plan.request.continuation_mode = ModelContinuationMode::ProviderRetained; + plan.request.tools.clear(); + plan.request.builtin_tools.clear(); + plan.request.maximum_builtin_tool_calls = None; + if !plan + .request + .input + .iter() + .any(|input| matches!(input, ModelInputBlock::Attachment { .. })) + { + // No offered tools or image input remain. Quote zero image units so withheld + // historical media cannot strand an otherwise ordinary text turn at a zero quota. + // Ordinary retained attachments keep their original conservative quote and verifier. + plan.budget.estimate.image_units = 0; + } + plan.tool_rule_bindings.clear(); + plan.classified_native = false; + plan.transfers.retain(|manifest| { + manifest.capability != AiEgressCapability::ToolResult + && manifest.capability != AiEgressCapability::ImageAnalysis + }); + let inference = plan + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(AiError::EgressDenied)?; + inference.sources.retain(|source| { + source.kind != "application_tool_result" && source.kind != "tool_result_image" + }); + inference.sources.push(crate::AiDataSourceRef { + kind: "saved_tool_content_withheld".into(), + reference: format!( + "{}:{}:{}", + lease.run_id().0, + lease.attempt_id(), + lease.lease_generation() + ), + classification: crate::DataClassification::Public, + trust: crate::AiSourceTrust::TrustedRuntime, + }); + inference.estimated_bytes = plan + .request + .conservative_provider_egress_bytes(&plan.provider_kind) + .map_err(|_| AiError::EgressDenied)?; + let idempotency = serde_json::json!({"original":plan.budget.idempotency_key,"run":lease.run_id().0, + "attempt":lease.attempt_id(),"generation":lease.lease_generation(),"purpose":"saved_tool_content_withheld"}); + plan.budget.idempotency_key = format!( + "withheld:{}", + crate::tools::canonical_json_digest(&idempotency) + ); + // Remaining ordinary estimates stay conservative; removing unavailable source media + // never widens any budget or general source boundary. + Ok(plan) + } +} + +// These values are denial-only bounds from already validated original input metadata; actual +// ordinary payload admission still uses the existing exact owner attachment resolver. +pub(super) fn ordinary_attachment_count(request: &ModelRequest) -> u64 { + request + .input + .iter() + .filter(|input| matches!(input, ModelInputBlock::Attachment { .. })) + .count() as u64 +} +pub(super) fn ordinary_attachment_bytes(request: &ModelRequest) -> u64 { + request + .input + .iter() + .filter_map(|input| match input { + ModelInputBlock::Attachment { byte_count, .. } => Some(*byte_count), + _ => None, + }) + .fold(0, u64::saturating_add) +} +pub(super) fn retained_image_egress_bytes(images: &[crate::AiResolvedToolImage]) -> Option { + images.iter().try_fold(0_u64, |sum, image| { + sum.checked_add(image.byte_count().div_ceil(3).checked_mul(4)?)? + .checked_add(image.reference().mime().len() as u64 + 1024) + }) +} +pub(super) fn validate_combined_media_batch( + request: &ModelRequest, + images: &[crate::AiResolvedToolImage], + limits: AiProviderAttachmentResolutionLimits, + image_units: u64, +) -> Result<(), AiError> { + let attachments = ordinary_attachment_count(request) + .checked_add(images.len() as u64) + .ok_or(AiError::ResultBudgetExceeded)?; + let image_count = request.input.iter().filter(|input| matches!(input, ModelInputBlock::Attachment { mime, .. } if mime.starts_with("image/"))).count() as u64; + let image_count = image_count + .checked_add(images.len() as u64) + .ok_or(AiError::ResultBudgetExceeded)?; + let bytes = images + .iter() + .try_fold(ordinary_attachment_bytes(request), |sum, image| { + sum.checked_add(image.byte_count()) + }) + .ok_or(AiError::ResultBudgetExceeded)?; + if attachments > limits.maximum_attachments() as u64 + || image_count > 4 + || image_count > image_units + || bytes > limits.maximum_total_bytes().min(16 * 1024 * 1024) + { + return Err(AiError::ResultBudgetExceeded); + } + Ok(()) +} diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs new file mode 100644 index 00000000..04f03b30 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -0,0 +1,196 @@ +//! Private source revalidation minted only after owning result/body admission. +use super::*; + +pub(crate) struct ToolSourceDelivery { + service: Arc, + lease: Arc>, + manifests: Vec, + maximum_image_units: u64, + task_context: Option>, +} +impl std::fmt::Debug for ToolSourceDelivery { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ToolSourceDelivery()") + } +} +impl ToolSourceDelivery { + pub(super) fn new( + service: Arc, + lease: Arc>, + manifests: &[AiEgressManifest], + maximum_image_units: u64, + task_context: Option>, + ) -> Result>, AiError> { + let manifests = manifests + .iter() + .filter(|manifest| { + (task_context.is_some() + && manifest.capability == AiEgressCapability::ModelInference) + || manifest.sources.iter().any(|source| { + source.kind == "application_tool_result" + && source.reference.starts_with("v2:") + }) + }) + .cloned() + .collect::>(); + let references = manifests + .iter() + .flat_map(|manifest| manifest.sources.iter()) + .filter(|source| { + source.kind == "application_tool_result" && source.reference.starts_with("v2:") + }) + .map(|source| source.reference.as_str()) + .collect::>(); + if references.len() > 64 { + return Err(AiError::ResultBudgetExceeded); + } + if manifests.is_empty() { + Ok(None) + } else { + Ok(Some(Arc::new(Self { + service, + lease, + manifests, + maximum_image_units, + task_context, + }))) + } + } + pub(crate) async fn authorize(&self) -> Result<(), ProviderError> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let operation = async { + let lease = self.lease.lock().await; + self.service + .validate_delivery_fence(&lease) + .await + .map_err(|_| ProviderError::Cancelled)?; + if let Some(context) = &self.task_context { + let inference = self + .manifests + .iter() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(ProviderError::Rejected)?; + self.service + .validate_task_continuation_context(&lease, &inference.scope, context) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)?; + } + let mut seen = BTreeSet::new(); + let mut count = 0_u64; + let mut bytes = 0_u64; + let mut pixels = 0_u64; + for manifest in &self.manifests { + let mut contexts = Vec::new(); + for source in &manifest.sources { + if source.kind != "application_tool_result" + || !source.reference.starts_with("v2:") + { + continue; + } + let id = crate::tool_media::source_call_hint(&source.reference) + .ok_or(ProviderError::ToolSourceUnavailable)?; + let context = self + .service + .reopen_tool_result_source( + &lease, + crate::AiToolCallId(id), + crate::AiToolResultDisclosurePurpose::History, + ) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)?; + if context.data_source() != *source { + return Err(ProviderError::ToolSourceUnavailable); + } + contexts.push(context); + } + let ordinary = crate::tool_media::ordinary_source_component(manifest, &contexts) + .map_err(|_| ProviderError::EgressDenied)?; + self.service + .runtime() + .authorize_egress(lease.principal_reference(), &ordinary) + .await + .map_err(|_| ProviderError::EgressDenied)? + .authorize(&ordinary) + .map_err(|_| ProviderError::EgressDenied)?; + for context in &contexts { + if seen.insert(context.source_reference()) && !context.images().is_empty() { + let images = self + .service + .authorize_retained_image_analysis(&lease, context, manifest) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)?; + count = count + .checked_add(images.len() as u64) + .ok_or(ProviderError::BudgetDenied)?; + bytes = images + .iter() + .try_fold(bytes, |sum, image| sum.checked_add(image.byte_count())) + .ok_or(ProviderError::BudgetDenied)?; + pixels = images + .iter() + .try_fold(pixels, |sum, image| { + let reference = image.reference(); + sum.checked_add( + u64::from(reference.width()) * u64::from(reference.height()), + ) + }) + .ok_or(ProviderError::BudgetDenied)?; + if count > 4 + || count > self.maximum_image_units + || bytes > 16 * 1024 * 1024 + || pixels > 16_000_000 + { + return Err(ProviderError::BudgetDenied); + } + } + } + let decision = self + .service + .runtime() + .authorize_tool_source_egress(lease.principal_reference(), manifest, &contexts) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)?; + self.service + .record_current_source_decision(manifest, &decision) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)?; + if decision.authorize(manifest).is_err() { + let ordinary = + crate::tool_media::ordinary_source_component(manifest, &contexts) + .map_err(|_| ProviderError::EgressDenied)?; + self.service + .runtime() + .authorize_egress(lease.principal_reference(), &ordinary) + .await + .map_err(|_| ProviderError::EgressDenied)? + .authorize(&ordinary) + .map_err(|_| ProviderError::EgressDenied)?; + return Err(ProviderError::ToolSourceUnavailable); + } + if std::time::Instant::now() >= deadline { + return Err(ProviderError::ToolSourceUnavailable); + } + } + self.service + .validate_delivery_fence(&lease) + .await + .map_err(|_| ProviderError::Cancelled)?; + Ok(()) + }; + tokio::time::timeout_at( + tokio::time::Instant::from_std(deadline), + Box::pin(operation), + ) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)? + } +} + +impl PartialEq for ToolSourceDelivery { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.service, &other.service) + && Arc::ptr_eq(&self.lease, &other.lease) + && self.manifests == other.manifests + && self.maximum_image_units == other.maximum_image_units + } +} diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index b2144043..32666144 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -348,6 +348,7 @@ pub struct AiProviderSessionBindRequest { cursor: AiProviderSessionCursor, transcript_fingerprint: String, provider_expires_at: Option, + installed_offering_fingerprint: Option, } /// Host-planned durable provider-session identity for one provider turn. @@ -360,6 +361,7 @@ pub struct AiProviderSessionBindRequest { pub struct AiProviderSessionTurnPlan { descriptor: AiProviderSessionDescriptor, transcript_fingerprint: String, + pub(crate) native_context: Option<(std::sync::Arc, String)>, } impl AiProviderSessionTurnPlan { @@ -377,6 +379,7 @@ impl AiProviderSessionTurnPlan { let plan = Self { descriptor, transcript_fingerprint: transcript_fingerprint.into(), + native_context: None, }; if !crate::valid_sha256(&plan.transcript_fingerprint) { return Err(AiError::InvalidInput( @@ -419,6 +422,7 @@ impl AiProviderSessionBindRequest { cursor, transcript_fingerprint: transcript_fingerprint.into(), provider_expires_at, + installed_offering_fingerprint: None, }; if !crate::valid_sha256(&request.transcript_fingerprint) { return Err(AiError::InvalidInput( @@ -438,6 +442,14 @@ impl AiProviderSessionBindRequest { self.provider_expires_at } + pub(crate) fn with_installed_offering(mut self, fingerprint: String) -> Result { + if !crate::valid_sha256(&fingerprint) { + return Err(AiError::Conflict); + } + self.installed_offering_fingerprint = Some(fingerprint); + Ok(self) + } + pub(crate) fn into_parts( self, ) -> ( @@ -445,12 +457,14 @@ impl AiProviderSessionBindRequest { AiProviderSessionCursor, String, Option, + Option, ) { ( self.descriptor, self.cursor, self.transcript_fingerprint, self.provider_expires_at, + self.installed_offering_fingerprint, ) } } @@ -483,6 +497,64 @@ pub struct AiProviderSessionBindingView { pub(crate) idle_expires_at: OffsetDateTime, pub(crate) absolute_expires_at: OffsetDateTime, pub(crate) row_version: i64, + pub(crate) installed_offering_fingerprint: Option, +} + +// Minted only after the checkpoint owner reopens and validates the full native outcome. +// No deserializer or public constructor turns a result body/hash into this proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AiVerifiedNativeContinuation { + pub(crate) session_id: AiSessionId, + pub(crate) run_id: AiRunId, + pub(crate) attempt_id: Uuid, + pub(crate) lease_generation: i64, + pub(crate) checkpoint_id: Uuid, + pub(crate) checkpoint_hash: String, + pub(crate) tool_call_id: Uuid, + pub(crate) tool_row_version: i64, + pub(crate) approval_id: Uuid, + pub(crate) outcome_hash: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct AiStaticOfferingResetReceipt { + pub(crate) session_id: AiSessionId, + pub(crate) run_id: AiRunId, + pub(crate) attempt_id: Uuid, + pub(crate) lease_generation: i64, + pub(crate) original_continuation_reference: String, + pub(crate) original_cursor_fingerprint: String, + pub(crate) replacement_cursor_fingerprint: String, + pub(crate) replacement_binding_id: Uuid, + pub(crate) task_context_only: bool, + pub(crate) cleanup_generation: i64, + pub(crate) offering_fingerprint: String, + pub(crate) outcome_hash: String, +} + +/// SDK-owned request to change the offered static subset after an actual completed checkpoint. +/// +/// It has no public constructor or deserializer. The checkpoint owner independently opens +/// the original completed outcome before this evidence can reach a sealed provider plan. +/// The request grants no provider input, image, application effect or human consent authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiProviderStaticOfferingResetRequest { + pub(crate) evidence: std::sync::Arc, + pub(crate) planned: AiProviderSessionTurnPlan, + pub(crate) desired_offering_fingerprint: String, + pub(crate) definitely_unstarted_budget_denial: bool, +} + +impl AiProviderStaticOfferingResetRequest { + /// Exact immutable provider/model/registration contract retained across the reset. + pub fn descriptor(&self) -> &AiProviderSessionDescriptor { + self.planned.descriptor() + } + + /// Exact SDK-derived newly offered definitions fingerprint, not a registration relabel. + pub fn offering_fingerprint(&self) -> &str { + &self.desired_offering_fingerprint + } } /// Crate-issued authority to replace one exact deleted provider-session @@ -511,6 +583,7 @@ pub struct AiProviderSessionRebindAuthorization { pub(crate) scope: AiScope, pub(crate) descriptor: AiProviderSessionDescriptor, pub(crate) transcript_fingerprint: String, + pub(crate) static_offering_reset: Option, } impl AiProviderSessionRebindAuthorization { @@ -1338,6 +1411,64 @@ pub trait AiProviderSessionDeletionService: Send + Sync { #[async_trait] #[cfg(any(feature = "sqlite", feature = "postgres"))] pub trait AiProviderSessionService: Send + Sync { + /// Fences the exact old cursor into cleanup for one verified native continuation offering. + /// + /// Current access, same running lease/descriptor, independently verified completed + /// checkpoint, consumed approval and settled preceding budgets remain mandatory. The + /// returned request is only for the registered deletion adapter. No new cursor or business + /// input may be admitted before exact absence is committed. + /// + /// # Errors + /// Defaults closed when this dedicated lifecycle is unavailable, and rejects stale, + /// cancelled, pending, uncertain, swapped or unverified source evidence. + async fn prepare_static_offering_reset( + &self, + _lease: &AiRunLease, + _claim: &AiProviderSessionClaim, + _request: &AiProviderStaticOfferingResetRequest, + ) -> Result< + ( + AiProviderSessionCleanupClaim, + AiProviderSessionDeletionRequest, + ), + AiError, + > { + Err(AiError::RuntimeNotReady) + } + + /// Mints a one-shot rebind only after the exact dedicated cleanup committed absence. + /// + /// This does not widen the generic pre-dispatch rebind reasons. The current lease, + /// provider/model/selection, verified saved checkpoint and new offering must all still + /// match, and [`Self::rebind_for_run`] rechecks them at its cursor-replacement CAS. + /// + /// # Errors + /// Defaults closed and rejects unknown cleanup, obsolete fencing or altered evidence. + async fn authorize_static_offering_rebind( + &self, + _lease: &AiRunLease, + _cleanup: &AiProviderSessionCleanupClaim, + _request: &AiProviderStaticOfferingResetRequest, + ) -> Result { + Err(AiError::RuntimeNotReady) + } + + /// Reopens the SDK's exact confirmed-empty-thread cleanup after a definitely unstarted + /// native image reservation denial. Ordinary deleted bindings gain no new rebind reason. + /// The default refuses; the owning service repeats current access, the original completed + /// checkpoint graph and exact absence generation before minting a dedicated authorization. + /// + /// # Errors + /// Defaults closed and rejects an ordinary, missing, claimed, uncertain, stale or + /// cancelled binding, or any different cleanup cause, owner, checkpoint or descriptor. + async fn authorize_static_offering_budget_rebind( + &self, + _lease: &AiRunLease, + _request: &AiProviderStaticOfferingResetRequest, + ) -> Result<(AiProviderSessionRebindAuthorization, String), AiError> { + Err(AiError::RuntimeNotReady) + } + /// Returns the current owner-visible binding shell, when one exists. /// /// Current principal, session, scope, and protection readiness are checked diff --git a/crates/graphql-orm-ai/src/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index b48384e3..0c48abc2 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -17,6 +17,7 @@ use std::time::Duration; use agql_auth::Clock; use async_trait::async_trait; +use base64::Engine as _; use futures::StreamExt; use serde::Deserialize; use serde_json::{Value, json}; @@ -415,6 +416,7 @@ pub struct AiCodexAppServerRegistration { launch_profile: AiCodexAppServerLaunchProfile, bootstrap_instructions: AiCodexAppServerBootstrapInstructions, reasoning_effort_profile: Option, + guarded_tool_result_source_delivery: bool, capability_session_bindings: BTreeMap, identity: String, } @@ -463,6 +465,7 @@ impl AiCodexAppServerRegistration { launch_profile, bootstrap_instructions, reasoning_effort_profile: None, + guarded_tool_result_source_delivery: false, capability_session_bindings: BTreeMap::new(), identity: String::new(), }; @@ -484,6 +487,19 @@ impl AiCodexAppServerRegistration { self } + /// Pins the reviewed final protected-source delivery contract into registration identity. + /// + /// Enable only when the actual factory preserves and awaits both the SDK input and + /// responder guards immediately before transport. The factory must independently + /// attest that contract. This grants no execution, resource, egress or budget authority. + /// Enabling invalidates existing capability-session bindings; derive them afterward. + #[must_use] + pub fn with_guarded_tool_result_source_delivery(mut self) -> Self { + self.guarded_tool_result_source_delivery = true; + self.refresh_identity(); + self + } + /// Installs immutable static developer instructions for retained threads. /// /// The instructions become part of the registration identity and cannot @@ -740,15 +756,18 @@ impl AiCodexAppServerWebSearchTurnConfig { /// Bounded text and optional native-search input for one fresh app-server turn. /// -/// This type has no tool, URL, path, shell, environment, browser, image, MCP, -/// app, credential, or generic JSON-RPC field. It intentionally cannot model -/// coordinator-managed application tools. +/// Static tools and SDK-derived retained images are admitted only through their owning +/// coordinator paths. This type accepts no arbitrary image URL/path, shell, environment, +/// browser, MCP, app, credential or generic JSON-RPC field. #[derive(Clone, PartialEq)] pub struct AiCodexAppServerTurnInput { model: String, instructions: Vec, retained_bootstrap_fingerprint: Option, input: Vec, + tool_images: Vec, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + source_delivery: Option>, tools: Vec, web_search: Option, reasoning_effort: ModelReasoningEffort, @@ -798,6 +817,9 @@ impl AiCodexAppServerTurnInput { instructions, retained_bootstrap_fingerprint: None, input, + tool_images: Vec::new(), + #[cfg(any(feature = "sqlite", feature = "postgres"))] + source_delivery: None, tools: Vec::new(), web_search: None, reasoning_effort, @@ -808,6 +830,53 @@ impl AiCodexAppServerTurnInput { Ok(turn) } + fn with_tool_result_context( + mut self, + context: &ProviderRequestContext, + ) -> Result { + self.tool_images = context.retained_tool_result_images().to_vec(); + #[cfg(any(feature = "sqlite", feature = "postgres"))] + { + self.source_delivery = context.tool_source_delivery_check(); + } + self.protocol_input()?; + self.validate()?; + Ok(self) + } + /// Revalidates original protected tool/image sources after process-pool queueing and + /// immediately before actual `turn/start` transport. It never executes the original tool, + /// renews action evidence or relies on a retained positive grant decision. + /// + /// # Errors + /// Returns a safe error for unavailable/expired/revoked/cancelled historical sources. + pub async fn authorize_tool_source_delivery(&self) -> Result<(), ProviderError> { + #[cfg(any(feature = "sqlite", feature = "postgres"))] + if let Some(delivery) = &self.source_delivery { + delivery.authorize().await?; + } + Ok(()) + } + fn protocol_input(&self) -> Result, ProviderError> { + let mut input = self + .input + .iter() + .map(|text| json!({"type":"text","text":text})) + .collect::>(); + for image in &self.tool_images { + input.push(json!({"type":"text","text":serde_json::to_string(&json!({"capturedObservation":true,"capturedAtMs":image.reference().captured_at_ms(),"actionExpiresAtMs":image.reference().action_expires_at_ms(),"retainedUntilMs":image.reference().retained_until_ms(),"actionEvidenceRenewed":false,"currentFieldDescribesOriginalCaptureOnly":true})).map_err(|_|ProviderError::ToolSourceUnavailable)?})); + input.push(json!({"type":"image","url":format!("data:{};base64,{}",image.reference().mime(),base64::engine::general_purpose::STANDARD.encode(image.bytes()))})); + } + if self.tool_images.len() > 4 + || serde_json::to_vec(&input) + .map_err(|_| ProviderError::ToolSourceUnavailable)? + .len() + > MAXIMUM_FRAME_BYTES.saturating_sub(4096) + { + return Err(ProviderError::ToolSourceUnavailable); + } + Ok(input) + } + /// Creates one retained dynamic-tool turn for a host-owned readiness /// probe. /// @@ -1320,6 +1389,41 @@ impl AiProvider for AiCodexAppServerProvider { ProviderKind::LocalHarness } + fn supports_tool_result_images(&self) -> bool { + self.registration.experimental_dynamic_tools() + && self.registration.guarded_tool_result_source_delivery + && self.pool.supports_tool_result_source_delivery() + && self + .pool + .supports_launch_profile(self.registration.launch_profile()) + } + + fn supports_tool_result_source_delivery(&self) -> bool { + self.registration.experimental_dynamic_tools() + && self.registration.guarded_tool_result_source_delivery + && self.pool.supports_tool_result_source_delivery() + && self + .pool + .supports_launch_profile(self.registration.launch_profile()) + } + + fn supports_tool_result_image_policy(&self, descriptor: &crate::AiToolDescriptor) -> bool { + let Some(policy) = &descriptor.result_media else { + return true; + }; + let wire = policy + .limits() + .maximum_raw_bytes() + .div_ceil(3) + .checked_mul(4) + .and_then(|bytes| bytes.checked_add(descriptor.maximum_result_bytes)) + .and_then(|bytes| { + bytes.checked_add(u64::from(policy.limits().maximum_images()) * 1024 + 1024) + }); + self.supports_tool_result_images() + && wire.is_some_and(|bytes| bytes <= MAXIMUM_FRAME_BYTES as u64) + } + fn capabilities(&self) -> ProviderCapabilities { let dynamic_tools_available = self.registration.experimental_dynamic_tools() && self @@ -1343,6 +1447,7 @@ impl AiProvider for AiCodexAppServerProvider { .pool .supports_launch_profile(self.registration.launch_profile()), provider_retained_continuation: true, + image_input: self.supports_tool_result_images(), local: true, reasoning_effort_profiles: self .registration @@ -1382,7 +1487,9 @@ impl AiProvider for AiCodexAppServerProvider { )? } else { AiCodexAppServerTurnInput::try_from_model_request(request)? - }; + } + .with_tool_result_context(&context)?; + context.authorize_tool_source_delivery().await?; if let Some(session) = retained { match session.activation() { crate::AiProviderSessionActivation::NewlyBoundEmpty => { @@ -1458,7 +1565,9 @@ impl AiProvider for AiCodexAppServerProvider { )? } else { AiCodexAppServerTurnInput::try_from_dynamic_request(request)? - }; + } + .with_tool_result_context(&context)?; + context.authorize_tool_source_delivery().await?; if let Some(session) = retained { match session.activation() { crate::AiProviderSessionActivation::NewlyBoundEmpty => { @@ -1931,6 +2040,13 @@ impl Drop for AiCodexAppServerLaunchedProcess { /// Trusted deployment seam that launches one reviewed app-server process. #[async_trait] pub trait AiCodexAppServerRunProcessFactory: Send + Sync { + /// Whether the actual launched process preserves the SDK's protected source guards + /// through every queue and awaits both input and responder delivery authorization + /// immediately before transport. Legacy dynamic-tool launch support proves neither. + /// The compatibility default refuses protected-source/typed-image effects. + fn supports_tool_result_source_delivery(&self) -> bool { + false + } /// Whether this factory implements one exact closed launch profile. /// /// The default keeps existing text-only factories compatible and refuses @@ -2066,6 +2182,16 @@ impl ProviderDynamicToolResponder for DynamicToolCallCounter { self.inner.respond(call).await } + async fn authorize_delivery( + &self, + result: &crate::ProviderDynamicToolResult, + ) -> Result<(), ProviderError> { + if self.entry.interrupt_started.load(Ordering::SeqCst) { + return Err(ProviderError::Cancelled); + } + self.inner.authorize_delivery(result).await + } + async fn reject_invalid_arguments( &self, call: crate::ProviderInvalidDynamicToolCall, @@ -2169,6 +2295,12 @@ impl AiCodexAppServerRunPool { self.inner.factory.supports_launch_profile(profile) } + /// Explicit immutable owning-factory attestation for final protected text/image delivery. + /// This alone grants no egress, budget, resource access or execution authority. + pub fn supports_tool_result_source_delivery(&self) -> bool { + self.inner.factory.supports_tool_result_source_delivery() + } + async fn create_empty_thread( &self, binding: AiProviderRunBinding, @@ -4227,8 +4359,8 @@ impl AiCodexAppServerProtocolActor { /// Encodes the only allowed successful response to an admitted dynamic /// tool request. /// - /// The result is serialized as one text content item because arbitrary - /// image/audio URLs are outside this integration. + /// Sends the exact authorized JSON text and any SDK-verified images as separate typed content + /// items. Images use inline verified bytes; this path accepts no source URL/path or audio. /// /// # Errors /// @@ -4249,10 +4381,20 @@ impl AiCodexAppServerProtocolActor { if text.len() > MAXIMUM_TEXT_BYTES { return Err(ProviderError::Rejected); } + let mut content = vec![json!({"type":"inputText","text":text})]; + for image in result.images() { + content.push(json!({"type":"inputText","text":serde_json::to_string(&json!({ + "capturedObservation":true,"capturedAtMs":image.reference().captured_at_ms(), + "actionExpiresAtMs":image.reference().action_expires_at_ms(),"retainedUntilMs":image.reference().retained_until_ms(), + "actionEvidenceRenewed":false,"historicalCurrentFlagIsCaptureTimeOnly":true + })).map_err(|_| ProviderError::Rejected)?})); + let encoded = base64::engine::general_purpose::STANDARD.encode(image.bytes()); + content.push(json!({"type":"inputImage","imageUrl":format!("data:{};base64,{encoded}", image.reference().mime())})); + } let frame = self.encode(json!({ "id": request_id, "result": { - "contentItems": [{"type": "inputText", "text": text}], + "contentItems": content, "success": true, } }))?; @@ -4327,10 +4469,10 @@ impl AiCodexAppServerProtocolActor { && !self.turn_started_observed } - /// Encodes text-only user input for one exact lifecycle-complete thread. + /// Encodes text and SDK-verified original image input for an exact lifecycle-complete thread. /// /// Trusted instructions are deliberately not copied into the user input - /// list. No tool, path, URL, image, skill, audio, environment, approval, + /// list. No arbitrary tool, path, source URL, skill, audio, environment, approval, /// sandbox, or generic JSON field can be supplied through this method. /// /// # Errors @@ -4387,7 +4529,7 @@ impl AiCodexAppServerProtocolActor { } let mut params = json!({ "threadId": thread_id, - "input": input.input().iter().map(|text| json!({"type": "text", "text": text})).collect::>(), + "input": input.protocol_input()?, "summary": "none", }); if let Some(effort) = input.reasoning_effort().wire_value() { @@ -6735,9 +6877,18 @@ fn registration_identity(registration: &AiCodexAppServerRegistration) -> String } else { hasher.update(b"no-profile\0"); } + // Keep legacy false registration bytes unchanged. This marker is immutable transport + // policy, not a request/source claim, and changes every derived capability fingerprint. + if registration.guarded_tool_result_source_delivery { + hasher.update(b"guarded-tool-result-source-delivery-v1\0"); + } hex::encode(hasher.finalize()) } +#[cfg(test)] +#[path = "codex_tool_media.rs"] +mod tool_media_tests; + #[cfg(test)] pub(crate) mod tests { use std::io::{BufRead, BufReader, Write}; @@ -7795,6 +7946,46 @@ pub(crate) mod tests { )); } + #[test] + fn guarded_source_delivery_changes_identity_and_invalidates_old_capability_binding() { + let base = dynamic_registration("1.0.0"); + let legacy_identity = base.identity().to_owned(); + let legacy_binding = capability_session_binding(&base, ModelReasoningEffort::Unspecified); + let admitted = (*base) + .clone() + .with_capability_session_binding(legacy_binding.clone()) + .unwrap(); + assert_eq!(admitted.identity(), legacy_identity); + let guarded = admitted.with_guarded_tool_result_source_delivery(); + assert_ne!(guarded.identity(), legacy_identity); + assert!(!guarded.admits_provider_session_fingerprint(legacy_binding.fingerprint())); + assert!( + guarded + .clone() + .with_capability_session_binding(legacy_binding) + .is_err() + ); + let new_binding = capability_session_binding(&guarded, ModelReasoningEffort::Unspecified); + assert!(guarded.with_capability_session_binding(new_binding).is_ok()); + } + + #[test] + fn legacy_dynamic_factory_refuses_protected_source_capability() { + let counters = Arc::new(Counters::new()); + let reviewed = (*dynamic_registration("1.0.0")) + .clone() + .with_guarded_tool_result_source_delivery(); + let provider = Arc::new(AiCodexAppServerProvider::new( + Arc::new(reviewed), + pool(counters.clone(), 1, 2), + )); + assert!(provider.capabilities().custom_tools); + assert!(!provider.supports_tool_result_source_delivery()); + assert!(!provider.supports_tool_result_images()); + assert_eq!(counters.launches.load(Ordering::SeqCst), 0); + assert_eq!(counters.turns.load(Ordering::SeqCst), 0); + } + #[test] fn capability_session_admission_validates_the_embedded_registration_identity() { let base = dynamic_registration("1.0.0"); @@ -9793,7 +9984,7 @@ pub(crate) mod tests { assert!(!unverified_dynamic_provider.capabilities().custom_tools); } - fn turn() -> AiCodexAppServerTurnInput { + pub(super) fn turn() -> AiCodexAppServerTurnInput { AiCodexAppServerTurnInput::new( "model-1", vec!["trusted".to_owned()], @@ -9810,7 +10001,7 @@ pub(crate) mod tests { input } - fn model_request() -> ModelRequest { + pub(super) fn model_request() -> ModelRequest { ModelRequest { model: "model-1".to_owned(), instructions: vec!["trusted".to_owned()], @@ -14979,6 +15170,86 @@ pub(crate) mod tests { )); } + #[test] + fn changed_static_offering_has_real_fresh_thread_wire_and_no_retained_definition_override() { + let semantic = dynamic_model_request(); + let original = + AiCodexAppServerTurnInput::try_from_dynamic_request(semantic.clone()).unwrap(); + let mut with_image = semantic; + let mut image_tool = with_image.tools[0].clone(); + image_tool.tool_id = "preview.image".into(); + image_tool.provider_name = "preview_image".into(); + image_tool.fingerprint = hex::encode(Sha256::digest(b"fixture-original-bound-image-tool")); + with_image.tools.push(image_tool); + let expanded = AiCodexAppServerTurnInput::try_from_dynamic_request(with_image).unwrap(); + let bootstrap = AiCodexAppServerBootstrapInstructions::disabled(); + let mut old = initialized_protocol_actor(); + let initial: Value = serde_json::from_slice( + &old.start_persistent_empty_thread( + "model-1", + ModelReasoningEffort::Unspecified, + &bootstrap, + original.tools(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(initial["method"], "thread/start"); + assert_eq!( + initial["params"]["dynamicTools"].as_array().unwrap().len(), + 1 + ); + assert!(initial["params"].get("input").is_none()); + old.accept(br#"{"id":2,"result":{"thread":{"id":"offering-original"}}}"#) + .unwrap(); + old.accept(&thread_started_notification("offering-original")) + .unwrap(); + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "offering-original") + .unwrap(); + assert!( + old.resume_thread(&cursor, &expanded).is_err(), + "retained definitions cannot be replaced in Rust alone" + ); + let delete: Value = serde_json::from_slice(&old.delete_thread(&cursor).unwrap()).unwrap(); + assert_eq!(delete["method"], "thread/delete"); + assert_eq!(delete["params"]["threadId"], "offering-original"); + assert!( + old.start_persistent_empty_thread( + "model-1", + ModelReasoningEffort::Unspecified, + &bootstrap, + expanded.tools() + ) + .is_err(), + "pending deletion does not admit another thread" + ); + old.accept(br#"{"id":3,"result":{}}"#).unwrap(); + let mut fresh = initialized_protocol_actor(); + let replacement: Value = serde_json::from_slice( + &fresh + .start_persistent_empty_thread( + "model-1", + ModelReasoningEffort::Unspecified, + &bootstrap, + expanded.tools(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(replacement["method"], "thread/start"); + let names = replacement["params"]["dynamicTools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect::>(); + assert_eq!(names, ["inventory_count", "preview_image"]); + assert!(replacement["params"].get("input").is_none()); + assert!(replacement["params"].get("threadId").is_none()); + assert_eq!(replacement["params"]["model"], initial["params"]["model"]); + } + #[test] fn detached_cleanup_deletes_exact_cursor_without_resuming_it_first() { let cursor = crate::AiProviderSessionCursor::new( diff --git a/crates/graphql-orm-ai/src/providers/codex_tool_media.rs b/crates/graphql-orm-ai/src/providers/codex_tool_media.rs new file mode 100644 index 00000000..36d0ce7f --- /dev/null +++ b/crates/graphql-orm-ai/src/providers/codex_tool_media.rs @@ -0,0 +1,61 @@ +//! Codec/protocol tests. Owning source and budget admission is tested separately in SQLite. +use super::tests::{model_request, turn}; +use super::*; +use sha2::{Digest, Sha256}; +fn genuine_image() -> crate::AiResolvedToolImage { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2, 3) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let bytes = bytes.into_inner(); + let id = uuid::Uuid::new_v4(); + let reference:crate::AiToolImageReference=serde_json::from_value(json!({"reference":id,"sha256":hex::encode(Sha256::digest(&bytes)),"mime":"image/png","width":2,"height":3,"captured_at_ms":1000,"action_expires_at_ms":1010,"retained_until_ms":2000})).unwrap(); + crate::AiResolvedToolImage::new( + reference, + crate::AiToolMediaResourceId::new("preview-v1", id).unwrap(), + bytes, + ) + .unwrap() +} +#[test] +fn reopened_original_pixels_are_real_image_input_and_keep_original_action_label() { + let image = genuine_image(); + let mut input = turn(); + input.tool_images = vec![image.clone()]; + let items = input.protocol_input().unwrap(); + assert_eq!(items[0], json!({"type":"text","text":"hello"})); + let label: Value = serde_json::from_str(items[1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(label["capturedAtMs"], 1000); + assert_eq!(label["actionExpiresAtMs"], 1010); + assert_eq!(label["actionEvidenceRenewed"], false); + assert_eq!(items[2]["type"], "image"); + assert_eq!( + items[2]["url"], + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(image.bytes()) + ) + ); + assert!(!items[1]["text"].as_str().unwrap().contains("base64")); + let debug = format!("{input:?}"); + assert!(!debug.contains("data:image")); +} +#[test] +fn json_reference_is_text_only_and_unadmitted_image_batches_are_refused() { + let mut request = model_request(); + request.input = vec![ModelInputBlock::Text { + text: json!({"ImageReference":uuid::Uuid::new_v4(),"ImageMimeType":"image/png"}) + .to_string(), + }]; + let input = AiCodexAppServerTurnInput::try_from_model_request(request).unwrap(); + assert!( + input + .protocol_input() + .unwrap() + .iter() + .all(|item| item["type"] == "text") + ); + let mut input = turn(); + input.tool_images = vec![genuine_image(); 5]; + assert!(input.protocol_input().is_err()); +} diff --git a/crates/graphql-orm-ai/src/providers/grok_acp_provider.rs b/crates/graphql-orm-ai/src/providers/grok_acp_provider.rs index e8c5ca17..e24cea6a 100644 --- a/crates/graphql-orm-ai/src/providers/grok_acp_provider.rs +++ b/crates/graphql-orm-ai/src/providers/grok_acp_provider.rs @@ -38,6 +38,12 @@ fn argument_digest(arguments: &serde_json::Value) -> Result<[u8; 32], ProviderEr #[async_trait] impl ProviderDynamicToolResponder for RejectionTrackingResponder { + async fn authorize_delivery( + &self, + result: &crate::ProviderDynamicToolResult, + ) -> Result<(), ProviderError> { + self.inner.authorize_delivery(result).await + } async fn respond( &self, call: crate::ProviderDynamicToolCall, diff --git a/crates/graphql-orm-ai/src/providers/grok_acp_wire.rs b/crates/graphql-orm-ai/src/providers/grok_acp_wire.rs index 746fb1de..2958e56b 100644 --- a/crates/graphql-orm-ai/src/providers/grok_acp_wire.rs +++ b/crates/graphql-orm-ai/src/providers/grok_acp_wire.rs @@ -680,6 +680,7 @@ impl AiGrokAcpRunProcess for AiGrokAcpWireProcess { let arguments=call.arguments().clone(); yield ProviderEvent::ToolCallStarted{call_id:call_id.clone(),tool_id:call.tool_id().to_owned()}; let result=responder.respond(call).await?; + responder.authorize_delivery(&result).await?; let response=state.broker.tool_response(&result).map_err(|e| classify_protocol(e, Failure::ProtocolSdk))?; yield ProviderEvent::ToolCallCompleted{call_id,arguments}; response diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index a4c16c8b..55e5b096 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -95,6 +95,10 @@ pub struct AiRuntime { tool_bridge: AuthenticatedToolBridge, egress_policy: Arc, deployment_egress: AiDeploymentEgressBoundary, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_egress_boundaries: Vec, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_media_resolvers: BTreeMap>, maximum_tool_maturity: ToolMaturity, generated_graphql_target_policy: AiGeneratedGraphqlTargetPolicySet, tool_catalog: AiToolCatalog, @@ -316,6 +320,43 @@ impl AiRuntime { .map_err(|_| AiError::ReauthorizationFailed) } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn tool_media_resolver( + &self, + id: &str, + ) -> Result<&Arc, AiError> { + self.tool_result_media_resolvers + .get(id) + .ok_or(AiError::Forbidden) + } + + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn preflight_tool_result_media( + &self, + descriptor: &AiToolDescriptor, + kind: &ProviderKind, + ) -> Result<(), AiError> { + if (descriptor.result_media.is_some() + || descriptor.maximum_classification >= crate::DataClassification::Restricted) + && !self + .providers + .get(kind) + .is_some_and(|provider| provider.supports_tool_result_source_delivery()) + { + return Err(AiError::Forbidden); + } + if let Some(policy) = &descriptor.result_media { + self.tool_media_resolver(policy.resolver_id())?; + if !self.providers.get(kind).is_some_and(|provider| { + provider.capabilities().image_input + && provider.supports_tool_result_image_policy(descriptor) + }) { + return Err(AiError::Forbidden); + } + } + Ok(()) + } + /// Applies deployment hard limits and scope policy to an exact egress /// manifest after current-principal rehydration. pub async fn authorize_egress( @@ -339,6 +380,45 @@ impl AiRuntime { Ok(self.egress_policy.authorize(&principal, manifest).await) } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) async fn authorize_tool_source_egress( + &self, + principal_reference: &PrincipalReference, + manifest: &AiEgressManifest, + sources: &[crate::AiToolResultDisclosureContext], + ) -> Result { + if sources.iter().any(|source| { + source.current_principal_reference() != principal_reference + || !self.tool_catalog.descriptors().any(|descriptor| { + descriptor.fingerprint == source.execution_provenance().tool_fingerprint() + }) + }) { + return Err(AiError::EgressDenied); + } + if let Err(reason) = crate::tool_media::evaluate_tool_source_boundaries( + &self.deployment_egress, + &self.tool_result_egress_boundaries, + manifest, + sources, + ) { + return Ok(AiEgressDecision::deny( + manifest, + reason, + "deployment-tool-source-boundary", + &principal_reference.subject, + )); + } + let principal = self + .principal_resolver + .resolve(principal_reference) + .await + .map_err(|_| AiError::ReauthorizationFailed)?; + Ok(self + .egress_policy + .authorize_tool_sources(&principal, manifest, sources) + .await) + } + /// Executes an exact registered application tool through fresh host tool /// policy and the canonical current-principal request-context path. /// @@ -669,9 +749,14 @@ impl AiRuntime { if response_bytes > descriptor.maximum_result_bytes { return Err(AiError::ResultBudgetExceeded); } - let disclosure = disclosure_schema + let mut disclosure = disclosure_schema .evaluate_graphql_with_record_limit(&response.data, descriptor.maximum_result_records) .map_err(Self::map_disclosure_error)?; + if let Some(media) = &descriptor.result_media { + disclosure.maximum_classification = disclosure + .maximum_classification + .max(media.classification()); + } Ok(AiToolExecutionResult { response, disclosure, @@ -941,9 +1026,14 @@ impl AiRuntime { if response_bytes > descriptor.maximum_result_bytes { return Err(AiError::ResultBudgetExceeded); } - let disclosure = disclosure_schema + let mut disclosure = disclosure_schema .evaluate_graphql_with_record_limit(&response.data, descriptor.maximum_result_records) .map_err(Self::map_disclosure_error)?; + if let Some(media) = &descriptor.result_media { + disclosure.maximum_classification = disclosure + .maximum_classification + .max(media.classification()); + } Ok(AiToolExecutionResult { response, disclosure, @@ -1015,13 +1105,29 @@ impl AiRuntime { "provider registry kind mismatch".to_owned(), )); } + if !context.retained_tool_result_images().is_empty() + && (!provider.capabilities().image_input || !provider.supports_tool_result_images()) + { + return Err(ProviderError::ToolSourceUnavailable); + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + if context.tool_source_delivery_check().is_some() + && !provider.supports_tool_result_source_delivery() + { + return Err(ProviderError::ToolSourceUnavailable); + } provider .capabilities() .validate_reasoning_effort(&request.model, request.reasoning_effort)?; Ok(provider) }; match local() { - Ok(provider) => provider.dispatch(request, context).await, + Ok(provider) => { + if let Err(error) = context.authorize_tool_source_delivery().await { + return crate::AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } + provider.dispatch(request, context).await + } Err(error) => crate::AiProviderDispatchOutcome::RejectedBeforeDispatch(error), } } @@ -1078,6 +1184,32 @@ impl AiRuntime { "provider registry kind mismatch".to_owned(), )); } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + for definition in &request.tools { + let id = AiToolId::parse(definition.tool_id.clone()) + .map_err(|_| ProviderError::Rejected)?; + if let Some(descriptor) = self.tool_catalog.descriptor(&id) { + if descriptor.fingerprint != definition.fingerprint { + return Err(ProviderError::Rejected); + } + // Admission of a declared protected result requires the actual adapter's + // final source guards before launch. The owning callback repeats this + // check before any effect; ordinary definitions need no media capability. + self.preflight_tool_result_media(descriptor, provider_kind) + .map_err(|_| ProviderError::Unsupported)?; + } + } + if !context.retained_tool_result_images().is_empty() + && (!provider.capabilities().image_input || !provider.supports_tool_result_images()) + { + return Err(ProviderError::ToolSourceUnavailable); + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + if context.tool_source_delivery_check().is_some() + && !provider.supports_tool_result_source_delivery() + { + return Err(ProviderError::ToolSourceUnavailable); + } provider .capabilities() .validate_reasoning_effort(&request.model, request.reasoning_effort)?; @@ -1085,6 +1217,9 @@ impl AiRuntime { }; match local() { Ok(provider) => { + if let Err(error) = context.authorize_tool_source_delivery().await { + return crate::AiProviderDispatchOutcome::RejectedBeforeDispatch(error); + } provider .dispatch_with_dynamic_tools(request, context, responder) .await @@ -1644,6 +1779,10 @@ pub struct AiRuntimeBuilder { graphql_targets: Option, egress_policy: Option>, deployment_egress: Option, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_egress_boundaries: Vec, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_media_resolvers: BTreeMap>, maximum_tool_maturity: Option, generated_graphql_target_policy: AiGeneratedGraphqlTargetPolicySet, tool_catalog: AiToolCatalog, @@ -1706,6 +1845,46 @@ impl AiRuntimeBuilder { self } + /// Admits one immutable registered-tool/provider-profile disclosure boundary. It grants no + /// current principal/resource/grant permission and leaves general/user egress unchanged. + /// Aggregate request byte/image budgets remain intersected; per-source policies cannot add + /// together their ceilings. Build rejects unknown fingerprints and duplicate bindings. + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub fn tool_result_egress_boundary( + mut self, + boundary: crate::AiToolResultEgressBoundary, + ) -> Self { + self.tool_result_egress_boundaries.push(boundary); + self + } + + /// Registers a trusted owning opener under one immutable logical resolver identity. + /// Output JSON cannot select a resolver; only the registered fingerprint-bound profile can. + /// + /// # Errors + /// Returns a safe configuration error for duplicate or unsafe logical identities. + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub fn tool_result_media_resolver( + mut self, + id: impl Into, + resolver: Arc, + ) -> Result { + let id = id.into(); + if id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + || self.tool_result_media_resolvers.contains_key(&id) + { + return Err(AiError::InvalidConfiguration( + "invalid or duplicate tool-result media resolver".into(), + )); + } + self.tool_result_media_resolvers.insert(id, resolver); + Ok(self) + } + /// Sets immutable deployment tool-maturity cap. pub fn maximum_tool_maturity(mut self, maturity: ToolMaturity) -> Self { self.maximum_tool_maturity = Some(maturity); @@ -1833,12 +2012,51 @@ impl AiRuntimeBuilder { graphql_targets, ); + #[cfg(any(feature = "sqlite", feature = "postgres"))] + { + let mut bindings = std::collections::BTreeSet::new(); + for boundary in &self.tool_result_egress_boundaries { + if !self + .tool_catalog + .descriptors() + .any(|descriptor| descriptor.fingerprint == boundary.tool_fingerprint()) + || !bindings.insert(( + boundary.tool_fingerprint(), + boundary.provider_profile_id(), + boundary.destination(), + )) + { + return Err(AiError::InvalidConfiguration( + "unknown or duplicate tool-result egress boundary".into(), + )); + } + } + if self + .tool_catalog + .descriptors() + .filter_map(|descriptor| descriptor.result_media.as_ref()) + .any(|policy| { + !self + .tool_result_media_resolvers + .contains_key(policy.resolver_id()) + }) + { + return Err(AiError::InvalidConfiguration( + "declared tool-result media resolver is unavailable".into(), + )); + } + } + Ok(AiRuntime { principal_resolver, access_policy, tool_bridge, egress_policy, deployment_egress, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_egress_boundaries: self.tool_result_egress_boundaries, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_result_media_resolvers: self.tool_result_media_resolvers, maximum_tool_maturity, generated_graphql_target_policy: self.generated_graphql_target_policy, tool_catalog: self.tool_catalog, diff --git a/crates/graphql-orm-ai/src/tool_media.rs b/crates/graphql-orm-ai/src/tool_media.rs new file mode 100644 index 00000000..3b481094 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media.rs @@ -0,0 +1,47 @@ +//! Typed protected tool-result media and current source-disclosure boundaries. +//! Output references and hashes are descriptive; only the owning resolver/current policy can +//! admit a resource. A retained result never authorizes replay of its original application effect. + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod boundary; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod context; +mod reference; +mod resolved; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub(crate) use boundary::evaluate as evaluate_tool_source_boundaries; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use boundary::*; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use context::*; +pub use reference::*; +pub use resolved::*; + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use crate::{AiError, AiToolDescriptor}; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use agql_auth::ResolvedPrincipal; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use async_trait::async_trait; + +/// Trusted owning boundary for already retained output images. It must reopen original protected +/// resource records, verify current actor/tenant/grant and exact original call/content identities, +/// and read bounded protected bytes. It must never execute the tool, accept a URL/storage path, +/// refresh an action deadline, or treat output JSON/hash values as authorization. +#[cfg(any(feature = "sqlite", feature = "postgres"))] +#[async_trait] +pub trait AiToolResultMediaResolver: Send + Sync { + /// Opens exact retained bytes for current disclosure, under the provided freshly rehydrated + /// principal and immutable descriptor. Original execution and current disclosure fences differ. + /// + /// # Errors + /// Returns a safe error for cancellation, expired/revoked consent, wrong origin/resource, + /// unavailable or ambiguous custody, changed bytes, or exceeded time/size/decode bounds. + async fn resolve_for_provider( + &self, + principal: &ResolvedPrincipal, + descriptor: &AiToolDescriptor, + context: &AiToolResultDisclosureContext, + reference: &AiToolImageReference, + ) -> Result; +} diff --git a/crates/graphql-orm-ai/src/tool_media/boundary.rs b/crates/graphql-orm-ai/src/tool_media/boundary.rs new file mode 100644 index 00000000..ac4c9e0f --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/boundary.rs @@ -0,0 +1,197 @@ +use crate::{ + AiDeploymentEgressBoundary, AiEgressCapability, AiEgressManifest, AiEgressReason, AiError, + AiToolResultDisclosureContext, DataClassification, +}; + +/// Independently admitted hard disclosure boundary for one immutable registered tool and actual +/// selected provider profile. It cannot be selected by tool arguments/output JSON/source labels. +/// Ordinary/user/unproven sources remain under the general deployment boundary; Secret is always +/// denied, and current principal/resource/grant policy is still intersected on every disclosure. +#[derive(Clone, Debug)] +pub struct AiToolResultEgressBoundary { + tool_fingerprint: String, + provider_profile_id: String, + destination: String, + boundary: AiDeploymentEgressBoundary, +} +impl AiToolResultEgressBoundary { + /// Creates a static exact-tool/profile boundary using the existing deployment limit type. + /// Capability selection is restricted to ToolResult, ImageAnalysis and ModelInference. + /// + /// # Errors + /// Returns a configuration error for malformed fingerprint/profile, Secret permission, + /// unsupported capability, empty/excessive limits or image count above four. Runtime build + /// additionally requires this exact fingerprint in the registered immutable tool catalogue. + pub fn new( + tool_fingerprint: impl Into, + provider_profile_id: impl Into, + destination: impl Into, + boundary: AiDeploymentEgressBoundary, + ) -> Result { + let tool_fingerprint = tool_fingerprint.into(); + let provider_profile_id = provider_profile_id.into(); + let destination = destination.into(); + if tool_fingerprint.len() != 64 + || !tool_fingerprint + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || provider_profile_id.is_empty() + || provider_profile_id.len() > 256 + || !provider_profile_id + .bytes() + .all(|b| b.is_ascii_graphic() && !matches!(b, b'"' | b'\\')) + || boundary.maximum_classification == DataClassification::Secret + || destination.is_empty() + || destination.len() > 512 + || !destination + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'"' | b'\\')) + || boundary.maximum_bytes == 0 + || boundary.maximum_bytes > 64 * 1024 * 1024 + || boundary.maximum_attachments > 4 + || boundary.allowed_destination_trust.is_empty() + || boundary.allowed_capabilities.is_empty() + || boundary.allowed_capabilities.iter().any(|cap| { + !matches!( + cap, + AiEgressCapability::ToolResult + | AiEgressCapability::ImageAnalysis + | AiEgressCapability::ModelInference + ) + }) + { + return Err(AiError::InvalidConfiguration( + "invalid scoped tool-result egress boundary".into(), + )); + } + Ok(Self { + tool_fingerprint, + provider_profile_id, + destination, + boundary, + }) + } + /// Exact immutable registered tool fingerprint admitted by host configuration. + pub fn tool_fingerprint(&self) -> &str { + &self.tool_fingerprint + } + /// Actual selected provider profile admitted by host configuration. + pub fn provider_profile_id(&self) -> &str { + &self.provider_profile_id + } + /// Exact admitted redacted destination identifier; trust-class labels alone cannot select it. + pub fn destination(&self) -> &str { + &self.destination + } +} + +pub(crate) fn evaluate( + general: &AiDeploymentEgressBoundary, + boundaries: &[AiToolResultEgressBoundary], + manifest: &AiEgressManifest, + contexts: &[AiToolResultDisclosureContext], +) -> Result<(), AiEgressReason> { + if manifest.maximum_classification() == DataClassification::Secret { + return Err(AiEgressReason::SecretDataDenied); + } + // Scoped sources never multiply the aggregate request bytes or authorize another destination. + if manifest.estimated_bytes > general.maximum_bytes { + return Err(AiEgressReason::LimitExceeded); + } + if !general + .allowed_destination_trust + .contains(&manifest.destination_trust) + { + return Err(AiEgressReason::DeploymentDenied); + } + let mut ordinary = Vec::new(); + let mut media = 0_u32; + let mut scoped_media_ceiling = None::; + for source in &manifest.sources { + let matching = contexts + .iter() + .filter(|ctx| { + ctx.data_source() == *source + || (source.classification == ctx.classification() + && source.trust == ctx.source_trust() + && ctx.images().iter().any(|image| { + image.matches_egress_source(&ctx.source_reference(), source) + })) + }) + .collect::>(); + if matching.len() > 1 { + return Err(AiEgressReason::PolicyDenied); + } + let Some(context) = matching.first() else { + ordinary.push(source.clone()); + continue; + }; + if manifest.scope != *context.scope() + || manifest.session_id != Some(context.execution_provenance().session_id()) + || manifest.run_id != Some(context.current_run_id()) + { + return Err(AiEgressReason::PolicyDenied); + } + let matched = boundaries + .iter() + .filter(|boundary| { + boundary.tool_fingerprint == context.execution_provenance().tool_fingerprint() + && boundary.provider_profile_id == manifest.provider_profile_id + && boundary.destination == manifest.destination + }) + .collect::>(); + let [boundary] = matched.as_slice() else { + ordinary.push(source.clone()); + continue; + }; + let is_image = source.kind == "tool_result_image"; + let mut exact = manifest.clone(); + exact.sources = vec![source.clone()]; + exact.attachment_count = u32::from(is_image); + boundary.boundary.evaluate(&exact)?; + scoped_media_ceiling = Some( + scoped_media_ceiling.map_or(boundary.boundary.maximum_attachments, |cap| { + cap.min(boundary.boundary.maximum_attachments) + }), + ); + if is_image { + media = media.checked_add(1).ok_or(AiEgressReason::LimitExceeded)?; + } + } + if media > manifest.attachment_count || media > scoped_media_ceiling.unwrap_or(0) { + return Err(AiEgressReason::LimitExceeded); + } + // A scoped allowance changes the ceiling for these exact sources, never adds another + // independent batch to the ordinary request count allowance. + if manifest.attachment_count + > general + .maximum_attachments + .max(scoped_media_ceiling.unwrap_or(0)) + { + return Err(AiEgressReason::LimitExceeded); + } + let ordinary_attachments = manifest.attachment_count - media; + if ordinary_attachments > general.maximum_attachments { + return Err(AiEgressReason::LimitExceeded); + } + if !ordinary.is_empty() || ordinary_attachments > 0 || manifest.sources.is_empty() { + let mut remaining = manifest.clone(); + remaining.sources = ordinary; + remaining.attachment_count = ordinary_attachments; + general.evaluate(&remaining)?; + } + // Every supplied context must actually bind this transfer; extra proof markers are refused. + if contexts.iter().any(|context| { + !manifest.sources.iter().any(|source| { + context.data_source() == *source + || (source.classification == context.classification() + && source.trust == context.source_trust() + && context.images().iter().any(|image| { + image.matches_egress_source(&context.source_reference(), source) + })) + }) + }) { + return Err(AiEgressReason::PolicyDenied); + } + Ok(()) +} diff --git a/crates/graphql-orm-ai/src/tool_media/context.rs b/crates/graphql-orm-ai/src/tool_media/context.rs new file mode 100644 index 00000000..c67faf72 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -0,0 +1,238 @@ +use crate::{ + AiDataSourceRef, AiError, AiRunLease, AiScope, AiSourceTrust, AiToolExecutionProvenance, + AiToolImageReference, DataClassification, +}; +use agql_auth::PrincipalReference; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Fresh disclosure purpose for an existing completed result. None grants application execution, +/// refreshes action evidence or renews the original retained-content deadline. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiToolResultDisclosurePurpose { + /// First disclosure after the actual effect result was durably retained. + ToolResult, + /// Reopen for a following provider turn or provider-native continuation. + Continuation, + /// Reopen protected historical output included in model inference/history. + History, +} +// Decoding protected data grants no authority; the owning loader must compare its retained +// call/step, registered descriptor, original result checksum and every exact image reference. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RetainedToolResultSource { + pub(crate) format_version: u16, + pub(crate) execution: AiToolExecutionProvenance, + pub(crate) principal: PrincipalReference, + pub(crate) scope: AiScope, + pub(crate) result_sha256: String, + pub(crate) disclosure_fingerprint: String, + pub(crate) classification: DataClassification, + pub(crate) trust: AiSourceTrust, + pub(crate) delegation_reference: Option, + pub(crate) images: Vec, + pub(crate) provider_cursor_fingerprint: Option, + pub(crate) effect_attempt_id: Uuid, + pub(crate) effect_lease_generation: i64, +} + +/// Parses only a redacted owning call hint from an ordinary legacy or versioned source address. +/// It proves no authority; protected origin/content must be independently reopened and matched. +pub(crate) fn source_call_hint(reference: &str) -> Option { + if let Some(versioned) = reference.strip_prefix("v2:") { + let (id, digest) = versioned.split_once(':')?; + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return None; + } + Uuid::parse_str(id).ok().filter(|id| !id.is_nil()) + } else { + Uuid::parse_str(reference).ok().filter(|id| !id.is_nil()) + } +} +/// Crate-derived current context for one retained protected result. It has no public constructor +/// or deserializer. Caller JSON cannot become retained-call source evidence. This proves checked +/// correlation only; current principal/resource/grant, uncancelled run fence, egress decision and +/// atomic image budget remain separately mandatory. +#[derive(Clone, Debug)] +pub struct AiToolResultDisclosureContext { + pub(crate) source: RetainedToolResultSource, + current_principal: PrincipalReference, + current_run_id: crate::AiRunId, + current_attempt_id: Uuid, + current_lease_generation: i64, + purpose: AiToolResultDisclosurePurpose, +} +impl AiToolResultDisclosureContext { + pub(crate) fn from_retained( + source: RetainedToolResultSource, + lease: &AiRunLease, + purpose: AiToolResultDisclosurePurpose, + ) -> Result { + if source.format_version != 1 + || source.execution.session_id() != lease.session_id() + || source.execution.tool_call_id().0.is_nil() + || source + .principal + .tenant_id + .as_ref() + .is_some_and(|tenant| source.scope.tenant_id.as_ref() != Some(tenant)) + || source.classification == DataClassification::Secret + || source.effect_attempt_id.is_nil() + || source.effect_lease_generation <= 0 + || source.execution.effect_attempt_id() != Some(source.effect_attempt_id) + || source.execution.effect_lease_generation() != Some(source.effect_lease_generation) + || source.execution.provider_session_binding_id().is_some() + != source.provider_cursor_fingerprint.is_some() + { + return Err(AiError::Conflict); + } + Ok(Self { + source, + current_principal: lease.principal_reference().clone(), + current_run_id: lease.run_id(), + current_attempt_id: lease.attempt_id(), + current_lease_generation: lease.lease_generation(), + purpose, + }) + } + /// Original typed invocation from the independently matched retained call/step. + pub fn execution_provenance(&self) -> &AiToolExecutionProvenance { + &self.source.execution + } + /// Original safe actor reference. It is correlation only and contains no bearer token. + pub fn original_principal_reference(&self) -> &PrincipalReference { + &self.source.principal + } + /// Current disclosure actor reference, freshly resolved before the owning policy executes. + pub fn current_principal_reference(&self) -> &PrincipalReference { + &self.current_principal + } + /// Original tenant/application scope checked against retained session/call ownership. + pub fn scope(&self) -> &AiScope { + &self.source.scope + } + /// Canonical retained model-result checksum, independently compared after decryption. + pub fn result_sha256(&self) -> &str { + &self.source.result_sha256 + } + /// Immutable disclosure schema used for the protected original result. + pub fn disclosure_fingerprint(&self) -> &str { + &self.source.disclosure_fingerprint + } + /// Original confidentiality preserved during later history/model inference. + pub const fn classification(&self) -> DataClassification { + self.source.classification + } + /// Original source trust; images cannot become user-provided content via an attachment alias. + pub const fn source_trust(&self) -> AiSourceTrust { + self.source.trust + } + /// Original safe delegation/grant reference, never a credential or renewed grant. + pub fn delegation_reference(&self) -> Option<&str> { + self.source.delegation_reference.as_deref() + } + /// Exact declared image references from retained output, not current caller arguments. + pub fn images(&self) -> &[AiToolImageReference] { + &self.source.images + } + + /// Original opaque provider cursor digest from matched owning lifecycle metadata. It never + /// contains the cursor or grants remote thread reuse, and is unchanged by a later rebind. + pub fn original_provider_cursor_fingerprint(&self) -> Option<&str> { + self.source.provider_cursor_fingerprint.as_deref() + } + + /// Actual original effect execution attempt, which may follow a human approval wait and + /// differ from the originating provider request. No continuation changes this value. + pub const fn effect_attempt_id(&self) -> Uuid { + self.source.effect_attempt_id + } + + /// Actual original effect execution fence, separately retained from provider admission and + /// from the current disclosure fence. It grants no current execution authority. + pub const fn effect_lease_generation(&self) -> i64 { + self.source.effect_lease_generation + } + /// Current disclosure attempt, which may differ from the original completed effect. + pub const fn current_attempt_id(&self) -> Uuid { + self.current_attempt_id + } + /// Current disclosure run; historical results preserve their distinct original run identity. + pub const fn current_run_id(&self) -> crate::AiRunId { + self.current_run_id + } + /// Current disclosure fencing generation, checked around every owning I/O. + pub const fn current_lease_generation(&self) -> i64 { + self.current_lease_generation + } + /// Current read/disclosure purpose. It never permits invoking the original tool. + pub const fn purpose(&self) -> AiToolResultDisclosurePurpose { + self.purpose + } + /// Stable origin/content/resource checksum across disclosures. Current actor/fence differences + /// never relabel or refresh the original historical source. + pub fn source_reference(&self) -> String { + let value = + serde_json::to_value(&self.source).expect("retained source has serializable metadata"); + format!( + "v2:{}:{}", + self.source.execution.tool_call_id().0, + crate::tools::canonical_json_digest(&value) + ) + } + /// Exact redacted source metadata for current egress; this is not an allow proof. + pub fn data_source(&self) -> AiDataSourceRef { + AiDataSourceRef { + kind: "application_tool_result".into(), + reference: self.source_reference(), + classification: self.source.classification, + trust: self.source.trust, + } + } + + /// Whether this SDK-derived context binds the exact original text or declared image source. + /// + /// This uses the owning reference encoding and requires unchanged classification and trust. + /// It grants no disclosure authority: current principal/scope/resource/consent, egress and + /// atomic budget checks still apply on every delivery. Unknown or caller-labelled sources + /// must retain the host's ordinary policy rather than inheriting this context's allowance. + pub fn binds_egress_source(&self, source: &AiDataSourceRef) -> bool { + self.data_source() == *source + || (source.classification == self.classification() + && source.trust == self.source_trust() + && self + .images() + .iter() + .any(|image| image.matches_egress_source(&self.source_reference(), source))) + } +} + +/// Removes only sources independently bound by actual SDK-derived contexts. Unknown and mixed +/// caller sources retain the complete general policy check, aggregate bytes and count ceiling. +pub(crate) fn ordinary_source_component( + manifest: &crate::AiEgressManifest, + contexts: &[AiToolResultDisclosureContext], +) -> Result { + let mut ordinary = manifest.clone(); + let mut removed_images = 0_u32; + ordinary.sources.retain(|source| { + let matched = contexts + .iter() + .any(|context| context.binds_egress_source(source)); + if matched && source.kind == "tool_result_image" { + removed_images = removed_images.saturating_add(1); + } + !matched + }); + ordinary.attachment_count = ordinary + .attachment_count + .checked_sub(removed_images) + .ok_or(AiError::EgressDenied)?; + Ok(ordinary) +} diff --git a/crates/graphql-orm-ai/src/tool_media/reference.rs b/crates/graphql-orm-ai/src/tool_media/reference.rs new file mode 100644 index 00000000..7cd38304 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/reference.rs @@ -0,0 +1,208 @@ +use crate::{AiError, AiToolImageFieldBindings, AiToolResultMediaLimits}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Exact descriptive metadata for one opaque image in a retained tool result. Deserialization +/// does not prove source authority, ownership, grant validity, byte custody or image decoding. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiToolImageReference { + reference: Uuid, + sha256: String, + mime: String, + width: u32, + height: u32, + captured_at_ms: i64, + retained_until_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + action_expires_at_ms: Option, +} +impl AiToolImageReference { + #[cfg(any(feature = "sqlite", feature = "postgres"))] + fn source_prefix(&self, original: &str) -> String { + format!( + "v1:{original}:{}:{}:{}:{}:{}:{}:{}:{}:", + self.reference, + self.mime, + self.sha256, + self.width, + self.height, + self.captured_at_ms, + self.retained_until_ms, + self.action_expires_at_ms.unwrap_or(0) + ) + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn egress_source( + &self, + context: &crate::AiToolResultDisclosureContext, + bytes: u64, + ) -> crate::AiDataSourceRef { + crate::AiDataSourceRef { + kind: "tool_result_image".into(), + reference: format!("{}{bytes}", self.source_prefix(&context.source_reference())), + classification: context.classification(), + trust: context.source_trust(), + } + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(crate) fn matches_egress_source( + &self, + original: &str, + source: &crate::AiDataSourceRef, + ) -> bool { + source.kind == "tool_result_image" + && source + .reference + .strip_prefix(&self.source_prefix(original)) + .and_then(|count| count.parse::().ok()) + .is_some_and(|count| count > 0 && count <= 16 * 1024 * 1024) + } + /// Original opaque observation/resource identifier, never a key, URL or path. + pub const fn reference(&self) -> Uuid { + self.reference + } + /// SHA-256 of the original image bytes. + pub fn sha256(&self) -> &str { + &self.sha256 + } + /// Original detected MIME hint, independently checked by actual decoding. + pub fn mime(&self) -> &str { + &self.mime + } + /// Original decoded pixel width, independently checked before transport. + pub const fn width(&self) -> u32 { + self.width + } + /// Original decoded pixel height, independently checked before transport. + pub const fn height(&self) -> u32 { + self.height + } + /// Original capture time in Unix milliseconds. + pub const fn captured_at_ms(&self) -> i64 { + self.captured_at_ms + } + /// Original retained-content deadline in Unix milliseconds; no reopen renews it. + pub const fn retained_until_ms(&self) -> i64 { + self.retained_until_ms + } + /// Optional original action deadline. This historical label grants no execution or media access. + pub const fn action_expires_at_ms(&self) -> Option { + self.action_expires_at_ms + } + /// Whether the original action label has elapsed at the supplied trusted Unix milliseconds. + /// This is descriptive and must never replace current owning execution authority. + pub fn action_label_is_current(&self, now_ms: i64) -> bool { + self.captured_at_ms <= now_ms + && self + .action_expires_at_ms + .is_some_and(|expires| now_ms < expires) + } + pub(crate) fn from_output( + value: &serde_json::Value, + fields: &AiToolImageFieldBindings, + limits: AiToolResultMediaLimits, + now_ms: i64, + ) -> Result, AiError> { + let value = value.as_object().ok_or(AiError::Forbidden)?; + let hint = value.get(fields.reference()).ok_or(AiError::Forbidden)?; + if hint.is_null() { + if [ + fields.sha256(), + fields.mime(), + fields.width(), + fields.height(), + ] + .iter() + .any(|field| value.get(*field).is_some_and(|value| !value.is_null())) + { + return Err(AiError::Forbidden); + } + return Ok(None); + } + let reference = Uuid::parse_str(hint.as_str().ok_or(AiError::Forbidden)?) + .map_err(|_| AiError::Forbidden)?; + let string = |field: &str| { + value + .get(field) + .and_then(|value| value.as_str()) + .map(str::to_owned) + .ok_or(AiError::Forbidden) + }; + let number = |field: &str| { + value + .get(field) + .and_then(|value| value.as_i64()) + .ok_or(AiError::Forbidden) + }; + let image = Self { + reference, + sha256: string(fields.sha256())?, + mime: string(fields.mime())?, + width: u32::try_from(number(fields.width())?).map_err(|_| AiError::Forbidden)?, + height: u32::try_from(number(fields.height())?).map_err(|_| AiError::Forbidden)?, + captured_at_ms: number(fields.captured_at_ms())?, + retained_until_ms: number(fields.retained_until_ms())?, + action_expires_at_ms: fields.action_expires_at_ms().map(number).transpose()?, + }; + image.validate_at(limits, now_ms)?; + Ok(Some(image)) + } + pub(crate) fn validate_at( + &self, + limits: AiToolResultMediaLimits, + now_ms: i64, + ) -> Result<(), AiError> { + let age = self + .retained_until_ms + .checked_sub(self.captured_at_ms) + .ok_or(AiError::Forbidden)?; + if self.reference.is_nil() + || self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || !matches!(self.mime.as_str(), "image/jpeg" | "image/png") + || self.width == 0 + || self.height == 0 + || self.width > limits.maximum_edge() + || self.height > limits.maximum_edge() + || u64::from(self.width) * u64::from(self.height) > limits.maximum_pixels() + || self.captured_at_ms < 0 + || self.captured_at_ms > now_ms + || now_ms >= self.retained_until_ms + || age <= 0 + || u64::try_from(age).map_or(true, |age| age > limits.maximum_retention_ms()) + || self.action_expires_at_ms.is_some_and(|action| { + action <= self.captured_at_ms || action > self.retained_until_ms + }) + { + return Err(AiError::Forbidden); + } + Ok(()) + } +} + +/// Applies the immutable complete-result aggregate count and decoded-pixel ceiling. Reference +/// shape remains descriptive; actual codecs/digests/resources are independently verified. +pub(crate) fn validate_image_reference_batch( + images: &[AiToolImageReference], + limits: AiToolResultMediaLimits, +) -> Result<(), AiError> { + if u64::try_from(images.len()).map_err(|_| AiError::ResultBudgetExceeded)? + > u64::from(limits.maximum_images()) + { + return Err(AiError::ResultBudgetExceeded); + } + let pixels = images + .iter() + .try_fold(0_u64, |sum, image| { + sum.checked_add(u64::from(image.width()).checked_mul(u64::from(image.height()))?) + }) + .ok_or(AiError::ResultBudgetExceeded)?; + if pixels > limits.maximum_pixels() { + return Err(AiError::ResultBudgetExceeded); + } + Ok(()) +} diff --git a/crates/graphql-orm-ai/src/tool_media/resolved.rs b/crates/graphql-orm-ai/src/tool_media/resolved.rs new file mode 100644 index 00000000..fc6f7e89 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/resolved.rs @@ -0,0 +1,210 @@ +use crate::{AiError, AiToolImageReference, AiToolResultMediaLimits}; +use image::{ImageDecoder, ImageFormat, ImageReader}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fmt, io::Cursor, sync::Arc}; +use uuid::Uuid; + +#[cfg(test)] +mod tests; + +/// Logical owning resolver and opaque resource identity. This contains no storage locator or +/// credential and is descriptive correlation, never current resource authorization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiToolMediaResourceId { + owner: String, + reference: Uuid, +} +impl AiToolMediaResourceId { + /// Creates one bounded logical resource identity from trusted owning metadata. + /// + /// # Errors + /// Returns a safe error for a nil reference or unsafe owner identity. + pub fn new(owner: impl Into, reference: Uuid) -> Result { + let owner = owner.into(); + if reference.is_nil() + || owner.is_empty() + || owner.len() > 128 + || !owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err(AiError::Forbidden); + } + Ok(Self { owner, reference }) + } + /// Deployment-owned logical resolver identity. + pub fn owner(&self) -> &str { + &self.owner + } + /// Exact opaque owning resource reference. + pub const fn reference(&self) -> Uuid { + self.reference + } +} + +/// Exact decoded/digest-verified image payload returned by the trusted owning resolver. Its +/// constructor proves byte integrity and image shape only; runtime still requires current source +/// authority, retained original bindings, active run fence, exact egress and atomic budget. +#[derive(Clone, PartialEq, Eq)] +pub struct AiResolvedToolImage { + reference: AiToolImageReference, + resource: AiToolMediaResourceId, + bytes: Arc<[u8]>, +} +impl AiResolvedToolImage { + /// Decodes exact bytes on a bounded two-worker blocking pool under the static policy's + /// whole resolution deadline. Busy pools refuse immediately; cancelled/timed-out results + /// are discarded and their permit stays held until that finite bounded decode ends. + /// This avoids unbounded blocking queues and never admits a late positive result. + /// + /// # Errors + /// Returns a safe error for a busy/cancelled decode, wrong metadata/content, or exceeded + /// static time/size/pixel bounds. Use this for bytes reopened from an owning remote boundary. + pub async fn decode_bounded( + reference: AiToolImageReference, + resource: AiToolMediaResourceId, + bytes: impl Into>, + limits: AiToolResultMediaLimits, + ) -> Result { + let deadline = std::time::Instant::now() + + std::time::Duration::from_millis(limits.maximum_resolution_ms()); + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + let permit = POOL + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(2))) + .clone() + .try_acquire_owned() + .map_err(|_| AiError::ToolExecutionFailed)?; + let bytes = bytes.into(); + if resource.reference != reference.reference() { + return Err(AiError::Forbidden); + } + let work = tokio::task::spawn_blocking(move || -> Result { + let _permit = permit; + decode_exact(&reference, &bytes, limits)?; + Ok(Self { + reference, + resource, + bytes, + }) + }); + let resolved = tokio::time::timeout( + std::time::Duration::from_millis(limits.maximum_resolution_ms()), + work, + ) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + .map_err(|_| AiError::ToolExecutionFailed)??; + if std::time::Instant::now() >= deadline { + return Err(AiError::ToolExecutionFailed); + } + Ok(resolved) + } + /// Verifies exact JPEG/PNG bytes against descriptive metadata with hard dimension/allocation + /// ceilings. Never construct from a model-selected URL/path or cache between disclosures. + /// + /// # Errors + /// Returns a safe error for wrong resource, digest/MIME/dimensions, malformed encoding, + /// unsupported color depth, or excessive bytes/decoded allocation. + pub fn new( + reference: AiToolImageReference, + resource: AiToolMediaResourceId, + bytes: impl Into>, + ) -> Result { + let bytes = bytes.into(); + if resource.reference != reference.reference() { + return Err(AiError::Forbidden); + } + let limits = + AiToolResultMediaLimits::new(4, 16 * 1024 * 1024, 8192, 16_000_000, 86_400_000, 5000)?; + decode_exact(&reference, &bytes, limits)?; + Ok(Self { + reference, + resource, + bytes, + }) + } + /// Original exact reference and checksum verified against these bytes. + pub fn reference(&self) -> &AiToolImageReference { + &self.reference + } + /// Logical owning resource identity to compare with immutable resolver policy. + pub fn resource(&self) -> &AiToolMediaResourceId { + &self.resource + } + /// Actual encoded byte length, independently derived from reopened payload. + pub fn byte_count(&self) -> u64 { + self.bytes.len() as u64 + } + /// Sensitive verified bytes for exact authorized transport only. Do not log, retain, expose + /// through GraphQL or carry this payload into another delivery without reopening authority. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + pub(crate) fn validate_policy( + &self, + resolver: &str, + limits: AiToolResultMediaLimits, + now_ms: i64, + ) -> Result<(), AiError> { + self.reference.validate_at(limits, now_ms)?; + if self.resource.owner() != resolver || self.byte_count() > limits.maximum_raw_bytes() { + return Err(AiError::Forbidden); + } + // Construction decoded these exact immutable Arc bytes; narrower bounds are applied again. + Ok(()) + } +} +impl fmt::Debug for AiResolvedToolImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AiResolvedToolImage") + .field("reference", &self.reference) + .field("resource", &self.resource) + .field("byte_count", &self.byte_count()) + .field("bytes", &"[REDACTED]") + .finish() + } +} +fn decode_exact( + reference: &AiToolImageReference, + bytes: &[u8], + limits: AiToolResultMediaLimits, +) -> Result<(), AiError> { + if bytes.is_empty() + || bytes.len() as u64 > limits.maximum_raw_bytes() + || hex::encode(Sha256::digest(bytes)) != reference.sha256() + { + return Err(AiError::Forbidden); + } + let format = image::guess_format(bytes).map_err(|_| AiError::Forbidden)?; + if !matches!( + (format, reference.mime()), + (ImageFormat::Jpeg, "image/jpeg") | (ImageFormat::Png, "image/png") + ) { + return Err(AiError::Forbidden); + } + let mut reader = ImageReader::with_format(Cursor::new(bytes), format); + let mut bounds = image::Limits::default(); + bounds.max_image_width = Some(limits.maximum_edge()); + bounds.max_image_height = Some(limits.maximum_edge()); + bounds.max_alloc = Some( + limits + .maximum_pixels() + .checked_mul(4) + .ok_or(AiError::Forbidden)?, + ); + reader.limits(bounds); + let decoder = reader.into_decoder().map_err(|_| AiError::Forbidden)?; + let (width, height) = decoder.dimensions(); + if (width, height) != (reference.width(), reference.height()) + || width == 0 + || height == 0 + || u64::from(width) * u64::from(height) > limits.maximum_pixels() + || decoder.color_type().bits_per_pixel() > 32 + { + return Err(AiError::Forbidden); + } + image::DynamicImage::from_decoder(decoder).map_err(|_| AiError::Forbidden)?; + Ok(()) +} diff --git a/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs new file mode 100644 index 00000000..7d29ecef --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs @@ -0,0 +1,158 @@ +use super::*; +use serde_json::json; + +fn png() -> Vec { + let image = image::DynamicImage::new_rgba8(2, 3); + let mut bytes = Cursor::new(Vec::new()); + image.write_to(&mut bytes, ImageFormat::Png).unwrap(); + bytes.into_inner() +} +fn image_reference(bytes: &[u8]) -> AiToolImageReference { + serde_json::from_value(json!({ + "reference":Uuid::new_v4(),"sha256":format!("{:x}",Sha256::digest(bytes)), + "mime":"image/png","width":2,"height":3, + "captured_at_ms":1000,"action_expires_at_ms":1010,"retained_until_ms":2000 + })) + .unwrap() +} +fn resource(reference: &AiToolImageReference) -> AiToolMediaResourceId { + AiToolMediaResourceId::new("preview-v1", reference.reference()).unwrap() +} +#[test] +fn genuine_pixels_are_decoded_and_bytes_are_redacted() { + let bytes = png(); + let reference = image_reference(&bytes); + let image = + AiResolvedToolImage::new(reference.clone(), resource(&reference), bytes.clone()).unwrap(); + assert_eq!(image.byte_count(), bytes.len() as u64); + assert_eq!(image.bytes(), bytes); + let debug = format!("{image:?}"); + assert!(!debug.contains(&format!("{bytes:?}"))); + assert!(!reference.action_label_is_current(1500)); + image + .validate_policy( + "preview-v1", + AiToolResultMediaLimits::new(1, 4096, 10, 100, 1000, 100).unwrap(), + 1500, + ) + .unwrap(); + assert!( + image + .validate_policy( + "preview-v1", + AiToolResultMediaLimits::new(1, 4096, 10, 100, 1000, 100).unwrap(), + 2000 + ) + .is_err() + ); +} +#[test] +fn wrong_digest_dimensions_mime_and_resource_are_rejected() { + let bytes = png(); + let reference = image_reference(&bytes); + for (field, value) in [ + ("sha256", json!("0".repeat(64))), + ("width", json!(3)), + ("mime", json!("image/jpeg")), + ] { + let mut metadata = serde_json::to_value(&reference).unwrap(); + metadata[field] = value; + let wrong: AiToolImageReference = serde_json::from_value(metadata).unwrap(); + assert!( + AiResolvedToolImage::new(wrong.clone(), resource(&wrong), bytes.clone()).is_err(), + "{field}" + ); + } + assert!( + AiResolvedToolImage::new( + reference.clone(), + AiToolMediaResourceId::new("preview-v1", Uuid::new_v4()).unwrap(), + bytes + ) + .is_err() + ); +} +#[test] +fn actual_decode_and_narrow_policy_limits_are_required() { + let bytes = b"not an image"; + let reference = image_reference(bytes); + assert!(AiResolvedToolImage::new(reference.clone(), resource(&reference), &bytes[..]).is_err()); + let bytes = png(); + let reference = image_reference(&bytes); + let image = AiResolvedToolImage::new(reference.clone(), resource(&reference), bytes).unwrap(); + assert!( + image + .validate_policy( + "other-v1", + AiToolResultMediaLimits::new(1, 4096, 10, 100, 1000, 100).unwrap(), + 1500 + ) + .is_err() + ); + assert!( + image + .validate_policy( + "preview-v1", + AiToolResultMediaLimits::new(1, image.byte_count() - 1, 10, 100, 1000, 100) + .unwrap(), + 1500 + ) + .is_err() + ); + assert!( + image + .validate_policy( + "preview-v1", + AiToolResultMediaLimits::new(1, 4096, 2, 5, 1000, 100).unwrap(), + 1500 + ) + .is_err() + ); +} +#[tokio::test] +async fn bounded_decode_preserves_exact_immutable_payload() { + let bytes = png(); + let reference = image_reference(&bytes); + let decoded = AiResolvedToolImage::decode_bounded( + reference.clone(), + resource(&reference), + bytes.clone(), + AiToolResultMediaLimits::new(1, 4096, 10, 100, 1000, 5000).unwrap(), + ) + .await + .unwrap(); + assert_eq!(decoded.bytes(), bytes); + assert!( + AiResolvedToolImage::decode_bounded( + reference.clone(), + resource(&reference), + bytes, + AiToolResultMediaLimits::new(1, 4096, 1, 100, 1000, 5000).unwrap() + ) + .await + .is_err() + ); +} + +#[test] +fn two_genuine_pngs_must_fit_the_complete_result_pixel_budget() { + let bytes = png(); + let first = image_reference(&bytes); + let second = image_reference(&bytes); + let one = AiResolvedToolImage::new(first.clone(), resource(&first), bytes.clone()).unwrap(); + let two = AiResolvedToolImage::new(second.clone(), resource(&second), bytes).unwrap(); + let limits = AiToolResultMediaLimits::new(2, 8192, 10, 6, 1000, 5000).unwrap(); + one.validate_policy("preview-v1", limits, 1500).unwrap(); + two.validate_policy("preview-v1", limits, 1500).unwrap(); + assert!( + crate::tool_media::validate_image_reference_batch(&[first.clone(), second.clone()], limits) + .is_err() + ); + crate::tool_media::validate_image_reference_batch(std::slice::from_ref(&first), limits) + .unwrap(); + crate::tool_media::validate_image_reference_batch( + &[first, second], + AiToolResultMediaLimits::new(2, 8192, 10, 12, 1000, 5000).unwrap(), + ) + .unwrap(); +} diff --git a/crates/graphql-orm-ai/src/tools.rs b/crates/graphql-orm-ai/src/tools.rs index 11bf9cd8..e89cf974 100644 --- a/crates/graphql-orm-ai/src/tools.rs +++ b/crates/graphql-orm-ai/src/tools.rs @@ -557,6 +557,13 @@ impl AiToolCatalog { descriptor: AiToolDescriptor, disclosure_schema: Option, ) -> Result<(), AiError> { + if let Some(media) = &descriptor.result_media { + let disclosure = disclosure_schema.as_ref().ok_or(AiError::Forbidden)?; + media.validate_disclosure(&disclosure.root)?; + if media.classification() > descriptor.maximum_classification { + return Err(AiError::Forbidden); + } + } if !descriptor.has_valid_fingerprint() || descriptor .argument_schema diff --git a/crates/graphql-orm-ai/tests/schema_module.rs b/crates/graphql-orm-ai/tests/schema_module.rs index 305f8382..2a9d79bb 100644 --- a/crates/graphql-orm-ai/tests/schema_module.rs +++ b/crates/graphql-orm-ai/tests/schema_module.rs @@ -8,8 +8,8 @@ fn ai_schema_module_owns_only_reserved_namespace_tables() { assert_eq!(catalog.modules().len(), 1); assert_eq!(catalog.modules()[0].version, AI_SCHEMA_MODULE_VERSION); - assert_eq!(AI_SCHEMA_MODULE_VERSION, "0.71.0"); - assert_eq!(catalog.entities().len(), 48); + assert_eq!(AI_SCHEMA_MODULE_VERSION, "0.72.0"); + assert_eq!(catalog.entities().len(), 49); assert!( catalog .entities() diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 6ea5f90f..36ede548 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.43.1` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.106.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | -| `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm-ai` | `0.107.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai-tool-profiles` | `0.16.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.43.1` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) |