From 6ddbcf5f62c9851d9fa0f3894f85b3bf74d9496e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 11:25:38 +1100 Subject: [PATCH 01/52] Add typed tool media profiles and protected source disclosure foundation --- Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/src/lib.rs | 2 + .../src/profiles.rs | 36 +- .../src/tool_media.rs | 327 +++++++++++++ .../src/tool_media/tests.rs | 134 ++++++ .../graphql-orm-ai-tool-profiles/src/tools.rs | 21 +- crates/graphql-orm-ai/Cargo.toml | 5 +- crates/graphql-orm-ai/src/egress.rs | 27 ++ crates/graphql-orm-ai/src/lib.rs | 2 + crates/graphql-orm-ai/src/orm_checkpoints.rs | 39 +- .../src/orm_checkpoints/native.rs | 15 +- .../src/orm_checkpoints/tool_media.rs | 61 +++ crates/graphql-orm-ai/src/orm_coordinator.rs | 11 + .../src/orm_provider_session.rs | 1 + crates/graphql-orm-ai/src/orm_runs.rs | 37 +- .../graphql-orm-ai/src/orm_runs/tool_media.rs | 97 ++++ .../src/orm_session_retention.rs | 4 + .../src/orm_subscription_waits.rs | 1 + .../src/orm_supervised_coordinator/native.rs | 3 + crates/graphql-orm-ai/src/orm_tools.rs | 83 +++- crates/graphql-orm-ai/src/orm_tools/media.rs | 428 ++++++++++++++++++ crates/graphql-orm-ai/src/persistence.rs | 6 +- crates/graphql-orm-ai/src/provider.rs | 36 ++ crates/graphql-orm-ai/src/provider_calls.rs | 166 ++++++- .../tests/native_coordinator_tests.rs | 1 + .../src/providers/codex_app_server.rs | 19 +- crates/graphql-orm-ai/src/runtime.rs | 171 ++++++- crates/graphql-orm-ai/src/tool_media.rs | 47 ++ .../graphql-orm-ai/src/tool_media/boundary.rs | 188 ++++++++ .../graphql-orm-ai/src/tool_media/context.rs | 171 +++++++ .../src/tool_media/reference.rs | 185 ++++++++ .../graphql-orm-ai/src/tool_media/resolved.rs | 160 +++++++ crates/graphql-orm-ai/src/tools.rs | 7 + docs/reference/workspace-packages.md | 4 +- 35 files changed, 2452 insertions(+), 47 deletions(-) create mode 100644 crates/graphql-orm-ai-tool-profiles/src/tool_media.rs create mode 100644 crates/graphql-orm-ai-tool-profiles/src/tool_media/tests.rs create mode 100644 crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs create mode 100644 crates/graphql-orm-ai/src/orm_runs/tool_media.rs create mode 100644 crates/graphql-orm-ai/src/orm_tools/media.rs create mode 100644 crates/graphql-orm-ai/src/tool_media.rs create mode 100644 crates/graphql-orm-ai/src/tool_media/boundary.rs create mode 100644 crates/graphql-orm-ai/src/tool_media/context.rs create mode 100644 crates/graphql-orm-ai/src/tool_media/reference.rs create mode 100644 crates/graphql-orm-ai/src/tool_media/resolved.rs 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/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/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/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/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/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_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..83986c04 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); } 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..b9532392 --- /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.execution.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..7df15f4a 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, diff --git a/crates/graphql-orm-ai/src/orm_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index be384871..fe12cc40 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session.rs @@ -4539,6 +4539,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_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..746fcb99 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -0,0 +1,97 @@ +//! Fenced retention of actual effect output before source disclosure. + +use super::*; + +impl OrmAiRunService { + 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) + } + + 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, + ) -> 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) + .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.is_some() + || 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.attempt_id() != lease.attempt_id + || origin.lease_generation() != lease.lease_generation + || origin.argument_hash() != call.argument_hash + || origin.tool_fingerprint() != call.tool_fingerprint + { + 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)), + state: Some("source_retained".to_owned()), + ..Default::default() + }, + ) + .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..f3363679 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() }, 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/native.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs index 21f0d2cb..c6f6b7bf 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, diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 64d2ba8b..2ec9a567 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -2,6 +2,7 @@ #![cfg(any(feature = "sqlite", feature = "postgres"))] +mod media; mod native_preflight; pub(crate) use native_preflight::{ NativePreflightRefusal, NativeToolClassification, native_preflight_failure_code, @@ -2150,6 +2151,8 @@ impl OrmAiApplicationToolCallService { } else { None }; + self.runtime + .preflight_tool_result_media(&descriptor, provider_result.provider_kind())?; let protected_arguments = self .protect( &policy, @@ -2372,6 +2375,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( + 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 +2431,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 +2452,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 +2498,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 +2511,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 +2553,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 +2584,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( 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..9f659cee --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -0,0 +1,428 @@ +//! 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 { + #[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 { + self.current_access(lease, &call_context.scope).await?; + let images = extract_references( + descriptor, + output, + self.clock.now().unix_timestamp_nanos() / 1_000_000, + )?; + 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, + }; + 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?; + 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(), + ) + .await?; + Ok(RetainedDisclosure { + protected_result, + protected_source, + }) + } + + /// 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 { + 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 + || origin.lease_generation() != call.lease_generation + || 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)?; + let images = self.reopen_tool_images(lease, &context).await?; + if images.is_empty() { + return Ok(images); + } + let mut manifest = tool_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)?; + let resolver = self.runtime.tool_media_resolver(policy.resolver_id())?; + let operation = async { + let mut images = Vec::with_capacity(context.images().len()); + let mut bytes = 0_u64; + for reference in context.images() { + 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 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?; + images.push(image); + } + Ok(images) + }; + tokio::time::timeout( + std::time::Duration::from_millis(policy.limits().maximum_resolution_ms()), + 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); + } + } + Ok(images) +} diff --git a/crates/graphql-orm-ai/src/persistence.rs b/crates/graphql-orm-ai/src/persistence.rs index 7317e702..117fb038 100644 --- a/crates/graphql-orm-ai/src/persistence.rs +++ b/crates/graphql-orm-ai/src/persistence.rs @@ -1730,6 +1730,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 +2500,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_"; diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 737041be..88c03b38 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -2480,6 +2480,7 @@ pub struct ProviderDynamicToolResult { call_id: String, tool_id: String, output: serde_json::Value, + images: Vec, } impl std::fmt::Debug for ProviderDynamicToolResult { @@ -2500,6 +2501,7 @@ impl ProviderDynamicToolResult { call_id, tool_id, output: serde_json::json!({"ready": true}), + images: Vec::new(), } } @@ -2519,6 +2521,7 @@ impl ProviderDynamicToolResult { call_id: call.call_id.clone(), tool_id: call.tool_id.clone(), output, + images: Vec::new(), }) } @@ -2537,6 +2540,7 @@ impl ProviderDynamicToolResult { call_id, tool_id, output, + images: Vec::new(), }) } @@ -2554,6 +2558,31 @@ 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 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. @@ -3262,6 +3291,13 @@ pub trait AiProvider: Send + Sync { /// Adapter/model capabilities used before routing. fn capabilities(&self) -> ProviderCapabilities; + /// 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 + } + /// Starts a streaming request. The context can only be constructed from an /// allowed exact egress decision. async fn stream( diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index f540de4b..45a1521b 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -1985,6 +1985,7 @@ pub struct AiProviderUsageObservation { output_tokens: u64, cached_input_tokens: u64, builtin_usage: AiProviderBuiltinUsage, + tool_result_image_units: u64, } impl AiProviderUsageObservation { @@ -2007,6 +2008,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 +2033,7 @@ impl AiProviderUsageObservation { output_tokens, cached_input_tokens, builtin_usage, + tool_result_image_units: 0, } } @@ -2073,6 +2076,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 +2151,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 +2229,10 @@ pub trait AiProviderDynamicToolExecution: Send + Sync { } struct DynamicToolResponder { + runtime: Arc, + maximum_image_units: u64, + allocated_image_units: Mutex, + disclosed_image_units: Mutex, lease: Arc>, execution: Arc, session_id: crate::AiSessionId, @@ -2336,6 +2356,16 @@ impl DynamicToolResponder { && definition.fingerprint == call.tool_fingerprint() }) .ok_or(ProviderError::Rejected)?; + let media_ceiling = 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| u64::from(policy.limits().maximum_images())) + .unwrap_or(0); + // 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 @@ -2387,6 +2417,19 @@ impl DynamicToolResponder { } else { rejected_arguments.then_some(crate::AiApplicationToolFailureCode::InvalidArguments) }; + if failure.is_none() && media_ceiling > 0 { + if self.execution.tool_result_source_service().is_none() { + 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); + } + *allocated = next; + } let persisted = if let Some(code) = failure { let persisted = self .execution @@ -2443,6 +2486,41 @@ impl DynamicToolResponder { }) if call_id == call.call_id() && tool_id == call.tool_id().as_str() => output.clone(), _ => return Err(ProviderError::Rejected), }; + let images = if media_ceiling > 0 && failure.is_none() { + let AiProviderDynamicToolOutcome::Application(application) = &persisted else { + return Err(ProviderError::Rejected); + }; + 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_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 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,7 +2529,12 @@ 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) } } @@ -3160,6 +3243,7 @@ pub struct AiProviderCallExecutor { attachment_resolver: Option>, attachment_limits: AiProviderAttachmentResolutionLimits, failure_diagnostic_sink: Option>, + tool_result_source_service: Option>, } // Transport fragment boundaries carry no semantic identity. Compact only @@ -3239,7 +3323,27 @@ impl AiProviderCallExecutor { attachment_resolver: None, attachment_limits: AiProviderAttachmentResolutionLimits::default(), failure_diagnostic_sink: None, + tool_result_source_service: 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 !Arc::ptr_eq(&self.runtime, service.runtime()) { + return Err(AiError::InvalidConfiguration( + "tool-result source service belongs to another runtime".into(), + )); } + self.tool_result_source_service = Some(service); + Ok(self) } /// Enables content-free provider-failure operational diagnosis. @@ -3986,6 +4090,10 @@ impl AiProviderCallExecutor { .collect::>(); let dynamic_responder = dynamic_execution.map(|execution| { Arc::new(DynamicToolResponder { + runtime: self.runtime.clone(), + maximum_image_units: reservation.reserved().image_units, + allocated_image_units: Mutex::new(0), + disclosed_image_units: Mutex::new(0), lease: lease_state.clone(), execution, session_id: lease.session_id(), @@ -4443,11 +4551,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 => 0, + }, }; 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); } @@ -4614,10 +4727,44 @@ impl AiProviderCallExecutor { ) -> Result { 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?; + if context.data_source() != *source { + return Err(AiError::EgressDenied); + } + if !sources + .iter() + .any(|prior: &crate::AiToolResultDisclosureContext| { + prior.source_reference() == context.source_reference() + }) + { + sources.push(context); + } + } + } + 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? + }; self.egress_audit.record(manifest, &decision).await?; let proof = decision.authorize(manifest)?; authorized.push((manifest.clone(), proof)); @@ -13366,6 +13513,10 @@ mod tests { Arc::new(inner) }; let responder = DynamicToolResponder { + runtime: fixture.runtime.clone(), + maximum_image_units: 0, + allocated_image_units: Mutex::new(0), + disclosed_image_units: Mutex::new(0), lease: lease.clone(), execution, session_id: fixture.lease.session_id(), @@ -13506,6 +13657,10 @@ 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), + disclosed_image_units: Mutex::new(0), lease: lease.clone(), execution: Arc::new(PersistingDynamicToolExecution { service: automatic_mutation_service( @@ -14350,6 +14505,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/tests/native_coordinator_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/native_coordinator_tests.rs index 721fbc2c..bbe1ed65 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, 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..27e000d8 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}; @@ -1320,6 +1321,13 @@ impl AiProvider for AiCodexAppServerProvider { ProviderKind::LocalHarness } + fn supports_tool_result_images(&self) -> bool { + self.registration.experimental_dynamic_tools() + && self + .pool + .supports_launch_profile(self.registration.launch_profile()) + } + fn capabilities(&self) -> ProviderCapabilities { let dynamic_tools_available = self.registration.experimental_dynamic_tools() && self @@ -4227,8 +4235,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 +4257,15 @@ 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() { + 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, } }))?; diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index a4c16c8b..0b61967d 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,37 @@ 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 let Some(policy) = &descriptor.result_media { + self.tool_media_resolver(policy.resolver_id())?; + if !self + .providers + .get(kind) + .is_some_and(|provider| provider.supports_tool_result_images()) + { + return Err(AiError::ToolRejectedBeforeExecution( + crate::ToolPreExecutionRejectionReason::CapabilityUnsupported, + )); + } + } + Ok(()) + } + /// Applies deployment hard limits and scope policy to an exact egress /// manifest after current-principal rehydration. pub async fn authorize_egress( @@ -339,6 +374,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 +743,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 +1020,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, @@ -1644,6 +1728,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 +1794,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 +1961,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..dcb6a3c5 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/boundary.rs @@ -0,0 +1,188 @@ +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); + } + 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..fab6f564 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -0,0 +1,171 @@ +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, +} + +/// 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 + { + 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 + } + /// 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, + } + } +} 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..998f48ad --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/reference.rs @@ -0,0 +1,185 @@ +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(()) + } +} 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..a1a97954 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/resolved.rs @@ -0,0 +1,160 @@ +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; + +/// 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 { + /// 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/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/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) | From acffda175b1fc6365e062b1b71430db9b148a795 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 12:29:01 +1100 Subject: [PATCH 02/52] Retain protected media provenance and reauthorize native history delivery --- Cargo.lock | 79 ++- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 6 + .../graphql-orm-ai-tool-profiles/MIGRATION.md | 14 + crates/graphql-orm-ai-tool-profiles/README.md | 5 + .../graphql-orm-ai-tool-profiles/src/error.rs | 6 + crates/graphql-orm-ai/CHANGELOG.md | 13 + crates/graphql-orm-ai/MIGRATION.md | 27 + crates/graphql-orm-ai/README.md | 4 + crates/graphql-orm-ai/docs/README.md | 2 + .../graphql-orm-ai/docs/tool-result-media.md | 117 ++++ crates/graphql-orm-ai/src/execution.rs | 53 +- .../src/orm_checkpoints/tool_media.rs | 2 +- .../graphql-orm-ai/src/orm_runs/tool_media.rs | 55 +- crates/graphql-orm-ai/src/orm_tools.rs | 156 +++-- crates/graphql-orm-ai/src/orm_tools/media.rs | 295 ++++++++- crates/graphql-orm-ai/src/persistence.rs | 4 + .../src/persistence/tool_media.rs | 41 ++ crates/graphql-orm-ai/src/provider.rs | 24 + crates/graphql-orm-ai/src/provider_calls.rs | 294 ++++++++- .../provider_calls/tests/tool_media_tests.rs | 618 ++++++++++++++++++ .../src/provider_calls/tool_media.rs | 160 +++++ .../src/providers/codex_app_server.rs | 32 + crates/graphql-orm-ai/src/runtime.rs | 6 +- .../graphql-orm-ai/src/tool_media/context.rs | 25 + .../graphql-orm-ai/src/tool_media/resolved.rs | 44 ++ .../src/tool_media/resolved/tests.rs | 135 ++++ crates/graphql-orm-ai/tests/schema_module.rs | 4 +- 27 files changed, 2142 insertions(+), 79 deletions(-) create mode 100644 crates/graphql-orm-ai/docs/tool-result-media.md create mode 100644 crates/graphql-orm-ai/src/persistence/tool_media.rs create mode 100644 crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs create mode 100644 crates/graphql-orm-ai/src/provider_calls/tool_media.rs create mode 100644 crates/graphql-orm-ai/src/tool_media/resolved/tests.rs 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/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index e3c769de..c2e7546f 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -14,6 +14,12 @@ supersedes: [] ### 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. + - Trusted `ToolPreExecutionRejection` carries an exact invocation hash and one closed public reason without resolver text, credentials or request content. `RejectedBeforeExecution` and `ToolRejectedBeforeExecution` preserve this diff --git a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md index 2458a035..be4b0c75 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -10,6 +10,20 @@ supersedes: [] # Migration Guide +## 0.15.0 to 0.16.0 + +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..6b18670b 100644 --- a/crates/graphql-orm-ai-tool-profiles/README.md +++ b/crates/graphql-orm-ai-tool-profiles/README.md @@ -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..df3f4e84 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), diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 821c0ca9..271effff 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -31,6 +31,19 @@ checkpoint facts. For the current workspace baseline and active gates, use the ### Added +- 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. + +### Added + - `AiToolAuthorizationDecision::require_authentication` distinguishes current authentication renewal from permission denial without granting execution. Native classification returns protected, audited non-retryable diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index bef806aa..e1adfa9a 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,33 @@ 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 + +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. + +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; +metadata and original output remain protected. Existing rows need no source +backfill and gain no additional disclosure allowance. 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; diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 6a9c65c0..ceb67c3c 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -561,3 +561,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..730d6626 --- /dev/null +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -0,0 +1,117 @@ +--- +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 decoded pixels, 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 + +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. + +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 the actual completed-effect result and source +metadata before disclosure. 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. The existing atomic provider-turn reservation allocates image units +before callbacks. Callbacks share one count and 16 MiB raw-byte ceiling. 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 `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). diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs index fe6683b7..ab700197 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. @@ -167,6 +167,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, @@ -243,6 +247,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 @@ -326,6 +358,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 +386,20 @@ 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) } + 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 + } + 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/orm_checkpoints/tool_media.rs b/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs index b9532392..782b31d1 100644 --- a/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs +++ b/crates/graphql-orm-ai/src/orm_checkpoints/tool_media.rs @@ -31,7 +31,7 @@ impl OrmAiCoordinatorCheckpointService { || source.execution.tool_call_id().0 != call.id || source.execution.run_id().0 != call.run_id || source.execution.session_id() != lease.session_id() - || source.execution.lease_generation() != call.lease_generation + || 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 diff --git a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs index 746fcb99..63c80f3e 100644 --- a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -23,6 +23,7 @@ impl OrmAiRunService { .map_err(map_transaction) } + #[allow(clippy::too_many_arguments)] pub(crate) async fn retain_tool_result_source( &self, lease: &AiRunLease, @@ -30,6 +31,10 @@ impl OrmAiRunService { 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()); @@ -50,7 +55,7 @@ impl OrmAiRunService { .await .map_err(OrmPublicError::from)? .ok_or_else(OrmPublicError::not_found)?; - let expected = serde_json::to_value(&origin) + 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 @@ -64,13 +69,36 @@ impl OrmAiRunService { || origin.tool_call_id() != call_id || origin.run_id() != lease.run_id || origin.session_id() != lease.session_id - || origin.attempt_id() != lease.attempt_id - || origin.lease_generation() != lease.lease_generation || origin.argument_hash() != call.argument_hash || origin.tool_fingerprint() != call.tool_fingerprint { 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, @@ -79,12 +107,33 @@ impl OrmAiRunService { 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, + created_at: now.unix_timestamp(), + }) + .await + .map_err(OrmPublicError::from)?; match outcome { ConditionalUpdateOutcome::Updated(_) => Ok(()), _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 2ec9a567..98ad14d9 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -2151,6 +2151,9 @@ 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 @@ -3225,6 +3228,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, @@ -3750,11 +3760,27 @@ 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 retained_origin = provenance + .clone() + .map(|origin| { + origin + .with_consumed_approval(&approval, &binding) + .map_err(AiError::from) + }) + .transpose()?; let execution = if let Some(prepared) = prepared { tokio::time::timeout( self.application_tools @@ -3785,7 +3811,7 @@ impl OrmAiConsequentialToolCallService { request, &approval, &binding, - provenance, + provenance.clone(), ), ) .await @@ -3862,6 +3888,42 @@ impl OrmAiConsequentialToolCallService { ) .await; } + let retained = if failure_code.is_none() + && (descriptor.result_media.is_some() + || classification >= DataClassification::Restricted) + { + let origin = retained_origin.as_ref().ok_or(AiError::Forbidden)?; + Some( + self.application_tools + .retain_disclosure_source( + &running_lease, + &descriptor, + origin, + &context, + &policy, + &model_output, + &disclosure_schema_fingerprint, + classification, + source_trust, + ) + .await?, + ) + } else { + None + }; + let retained_context = if retained.is_some() { + Some( + self.application_tools + .reopen_tool_result_source( + &running_lease, + tool_call_id, + crate::AiToolResultDisclosurePurpose::ToolResult, + ) + .await?, + ) + } else { + None + }; let Some(outbound_bytes) = output_bytes .len() .checked_add(call.provider_call_id.len()) @@ -3889,12 +3951,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, @@ -3904,12 +3971,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 @@ -3957,29 +4034,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(); @@ -4049,7 +4130,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, @@ -4080,6 +4161,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 index 9f659cee..a2106e2f 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -13,6 +13,56 @@ pub(super) struct RetainedDisclosure { } impl OrmAiApplicationToolCallService { + 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, @@ -32,6 +82,13 @@ impl OrmAiApplicationToolCallService { 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(), @@ -43,7 +100,17 @@ impl OrmAiApplicationToolCallService { 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_result = self .protect( policy, @@ -75,6 +142,10 @@ impl OrmAiApplicationToolCallService { 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 { @@ -83,6 +154,185 @@ impl OrmAiApplicationToolCallService { }) } + 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), + 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), 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. @@ -96,6 +346,20 @@ impl OrmAiApplicationToolCallService { lease: &AiRunLease, id: AiToolCallId, purpose: AiToolResultDisclosurePurpose, + ) -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(30), + 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) @@ -177,7 +441,14 @@ impl OrmAiApplicationToolCallService { || origin.run_id().0 != call.run_id || origin.session_id() != lease.session_id() || origin.tool_call_id() != id - || origin.lease_generation() != call.lease_generation + || source.effect_lease_generation != call.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 @@ -266,21 +537,27 @@ impl OrmAiApplicationToolCallService { .record(tool_manifest, &text_decision) .await?; text_decision.authorize(tool_manifest)?; - let images = self.reopen_tool_images(lease, &context).await?; + 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 = tool_manifest.clone(); + 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()) - }) + .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 @@ -295,7 +572,7 @@ impl OrmAiApplicationToolCallService { .authorize_tool_source_egress( lease.principal_reference(), &manifest, - std::slice::from_ref(&context), + std::slice::from_ref(context), ) .await?; self.egress_audit.record(&manifest, &decision).await?; diff --git a/crates/graphql-orm-ai/src/persistence.rs b/crates/graphql-orm-ai/src/persistence.rs index 117fb038..64c7ee6b 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", @@ -2575,6 +2578,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 88c03b38..3cda9187 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -2755,6 +2755,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() { + Ok(()) + } else { + Err(ProviderError::Rejected) + } + } + /// Persists a schema-invalid request without executing its tool. /// /// The returned correction envelope must pass current authorization, @@ -3298,6 +3316,12 @@ pub trait AiProvider: Send + Sync { 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( diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 45a1521b..5fa72cad 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -26,6 +26,8 @@ use crate::{ ProviderKind, ProviderRequestContext, ToolMaturity, classify_safe_application_tool_error, }; +mod tool_media; + #[path = "provider_maintenance.rs"] pub(crate) mod maintenance; @@ -2232,6 +2234,7 @@ struct DynamicToolResponder { runtime: Arc, maximum_image_units: u64, allocated_image_units: Mutex, + allocated_image_bytes: Mutex, disclosed_image_units: Mutex, lease: Arc>, execution: Arc, @@ -2316,6 +2319,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, @@ -2356,14 +2427,15 @@ impl DynamicToolResponder { && definition.fingerprint == call.tool_fingerprint() }) .ok_or(ProviderError::Rejected)?; - let media_ceiling = self + 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| u64::from(policy.limits().maximum_images())) - .unwrap_or(0); + .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) = { @@ -2418,7 +2490,11 @@ impl DynamicToolResponder { rejected_arguments.then_some(crate::AiApplicationToolFailureCode::InvalidArguments) }; if failure.is_none() && media_ceiling > 0 { - if self.execution.tool_result_source_service().is_none() { + 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; @@ -2428,7 +2504,15 @@ impl DynamicToolResponder { 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 @@ -2505,6 +2589,13 @@ impl DynamicToolResponder { } 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); @@ -2516,6 +2607,10 @@ impl DynamicToolResponder { *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) @@ -3337,7 +3432,7 @@ impl AiProviderCallExecutor { mut self, service: Arc, ) -> Result { - if !Arc::ptr_eq(&self.runtime, service.runtime()) { + if !std::ptr::eq(self.runtime.as_ref(), service.runtime()) { return Err(AiError::InvalidConfiguration( "tool-result source service belongs to another runtime".into(), )); @@ -3492,8 +3587,15 @@ impl AiProviderCallExecutor { lease: &AiRunLease, plan: AiProviderCallPlan, ) -> Result { - self.execute_inner(Arc::new(Mutex::new(lease.clone())), plan, None, None, None) - .await + 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 @@ -3522,7 +3624,7 @@ impl AiProviderCallExecutor { { return Err(AiError::Conflict); } - self.execute_inner(lease, plan, Some(execution), None, None) + self.execute_inner(lease, plan, Some(execution), None, None, 0) .await } @@ -3531,7 +3633,7 @@ impl AiProviderCallExecutor { pub async fn execute_with_provider_session( &self, lease_state: Arc>, - plan: AiProviderCallPlan, + mut plan: AiProviderCallPlan, session_plan: crate::AiProviderSessionTurnPlan, session_service: Arc, dynamic_execution: Option>, @@ -3704,6 +3806,34 @@ impl AiProviderCallExecutor { return Err(AiError::ProviderSessionDeferred); } }; + 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, + ) => { + session_service + .require_cleanup(&claim, "retained_tool_source_withheld") + .await?; + return 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) => { @@ -3748,12 +3878,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; @@ -3797,6 +3929,20 @@ impl AiProviderCallExecutor { result.provider_session_claim = Some(current_claim); Ok(result) } + Err(AiError::PreTransportToolSourceUnavailable) => { + 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?; + self.execute_inner(lease_state, safe_plan, None, None, None, 0) + .await + } Err(error @ AiError::PreTransportBudgetDenied) => Err(error), Err(AiError::PreTransportProviderFailed) => { session_service @@ -3820,6 +3966,7 @@ impl AiProviderCallExecutor { dynamic_execution: Option>, provider_session: Option, native_session_claim: Option>>, + historical_image_units: u64, ) -> Result { let lease = lease_state.lock().await.clone(); if !self.runtime.start_gate().is_ready() @@ -3892,8 +4039,13 @@ impl AiProviderCallExecutor { } }; - let mut context = match self - .authorize_and_audit_transfers(&lease, &plan, authorized_budget) + let (mut context, historical_image_bytes) = match self + .authorize_and_audit_transfers( + &lease, + &plan, + authorized_budget, + native_session_claim.as_ref(), + ) .await { Ok(context) => context, @@ -4092,8 +4244,9 @@ impl AiProviderCallExecutor { Arc::new(DynamicToolResponder { runtime: self.runtime.clone(), maximum_image_units: reservation.reserved().image_units, - allocated_image_units: Mutex::new(0), - disclosed_image_units: Mutex::new(0), + allocated_image_units: Mutex::new(historical_image_units), + allocated_image_bytes: Mutex::new(historical_image_bytes), + disclosed_image_units: Mutex::new(historical_image_units), lease: lease_state.clone(), execution, session_id: lease.session_id(), @@ -4553,7 +4706,7 @@ impl AiProviderCallExecutor { builtin_usage, tool_result_image_units: match &dynamic_responder { Some(responder) => *responder.disclosed_image_units.lock().await, - None => 0, + None => historical_image_units, }, }; let actual = self.usage_accounting.settle(&observation).await?; @@ -4724,7 +4877,40 @@ impl AiProviderCallExecutor { lease: &AiRunLease, plan: &AiProviderCallPlan, budget: crate::AuthorizedBudgetReservation, - ) -> Result { + native_session_claim: Option<&Arc>>, + ) -> Result<(ProviderRequestContext, u64), AiError> { + tokio::time::timeout( + std::time::Duration::from_secs(30), + 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), 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_sources = BTreeSet::new(); let mut authorized = Vec::with_capacity(plan.transfers.len()); for manifest in &plan.transfers { let mut sources = Vec::new(); @@ -4742,10 +4928,72 @@ impl AiProviderCallExecutor { crate::AiToolCallId(id), crate::AiToolResultDisclosurePurpose::History, ) - .await?; + .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_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_retained_cursor { + return Err(AiError::EgressDenied); + } + if manifest.capability == AiEgressCapability::ModelInference + && !context.images().is_empty() + { + if 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)?; + if image_count > 4 + || image_count > plan.budget.estimate.image_units + || image_bytes > 16 * 1024 * 1024 + || std::time::Instant::now() >= deadline + { + return Err(source_failure(AiError::EgressDenied)); + } + } + } if !sources .iter() .any(|prior: &crate::AiToolResultDisclosureContext| { @@ -4763,7 +5011,8 @@ impl AiProviderCallExecutor { } else { self.runtime .authorize_tool_source_egress(lease.principal_reference(), manifest, &sources) - .await? + .await + .map_err(source_failure)? }; self.egress_audit.record(manifest, &decision).await?; let proof = decision.authorize(manifest)?; @@ -4783,7 +5032,13 @@ 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)?)?, + image_bytes, + )) } async fn release_unstarted( @@ -4823,6 +5078,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, @@ -13516,6 +13772,7 @@ mod tests { 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, @@ -13660,6 +13917,7 @@ mod tests { 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 { 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..bd440529 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs @@ -0,0 +1,618 @@ +//! 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; + +#[derive(Clone)] +struct Capture { + reference: crate::AiToolImageReference, + origin: crate::AiToolExecutionProvenance, + bytes: Arc<[u8]>, +} +#[derive(Default)] +struct Custody { + captures: Mutex>, + effects: AtomicUsize, + opens: AtomicUsize, + allowed: AtomicBool, + revoke_before_delivery: AtomicBool, +} +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); + Ok(ToolGraphqlResponse { + data: json!({"Preview":{ + "Reference":reference.reference(),"Sha256":reference.sha256(),"Mime":reference.mime(), + "Width":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.original_invocation() + != context.execution_provenance().original_invocation() + || descriptor.fingerprint != capture.origin.tool_fingerprint() + { + return Err(AiError::Conflict); + } + self.opens.fetch_add(1, Ordering::SeqCst); + 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 { + AiEgressDecision::allow( + manifest, + "preview-policy-v1", + principal.principal().subject(), + ) + } + async fn authorize_tool_sources( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + sources: &[crate::AiToolResultDisclosureContext], + ) -> AiEgressDecision { + if self.allowed.load(Ordering::SeqCst) + && 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 { + MockProvider::new(Vec::new()).capabilities() + } + fn supports_tool_result_images(&self) -> bool { + self.supported + } + fn supports_tool_result_image_policy(&self, _: &AiToolDescriptor) -> bool { + self.supported + } + async fn stream( + &self, + _: ModelRequest, + _: ProviderRequestContext, + ) -> Result { + Err(ProviderError::Rejected) + } + async fn stream_with_dynamic_tools( + &self, + request: ModelRequest, + _: ProviderRequestContext, + responder: Arc, + ) -> Result { + let definition = request.tools.first().ok_or(ProviderError::Rejected)?; + let call = ProviderDynamicToolCall::from_definition( + "preview-turn", + "preview-call", + definition, + json!({}), + ) + .unwrap(); + let result = responder.respond(call).await?; + if self.custody.revoke_before_delivery.load(Ordering::SeqCst) { + self.custody.allowed.store(false, Ordering::SeqCst); + } + responder.authorize_delivery(&result).await?; + if result.images().len() != 1 || result.images()[0].bytes().is_empty() { + return Err(ProviderError::Rejected); + } + Ok(Box::pin(futures_util::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()), + }), + ]))) + } +} +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) { + let mut fixture = fixture(Vec::new()).await; + let custody = Arc::new(Custody::default()); + custody.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.clone(), + [( + "Preview".into(), + AiDisclosureShape::object( + rule.clone(), + fields + .into_iter() + .map(|name| (name.into(), AiDisclosureShape::scalar(rule.clone()))), + ), + )], + ), + ) + .unwrap(); + let document = "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, 5000).unwrap(), + DataClassification::Restricted, + ) + .unwrap(); + let descriptor = AiToolDescriptor::new( + "records.read", + "Read a bounded retained preview", + AiToolOperationKind::Query, + document, + json!({"type":"object","properties":{},"additionalProperties":false}), + ) + .unwrap() + .with_result_projection("preview-projection-v1") + .with_graphql_contract(contract) + .with_result_media(media) + .unwrap(); + 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 runtime = AiRuntime::builder() + .principal_resolver(Arc::new(CountingResolver { + principal: fixture.principal.clone(), + resolutions: fixture.principal_resolutions.clone(), + })) + .access_policy(Arc::new(AllowAccess)) + .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(DatabaseManagedContentProtector)) + .provider(Arc::new(MediaProvider { + custody: custody.clone(), + supported, + })) + .unwrap() + .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.request.tools[0].parameters = + json!({"type":"object","properties":{},"additionalProperties":false}); + 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()); + let reopened = service + .authorize_tool_images(call.lease(), call.id(), call.egress_manifest().unwrap()) + .await + .unwrap(); + assert_eq!(reopened.len(), 1); + 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()); + assert!( + !serde_json::to_string(&saved.protected_result) + .unwrap() + .contains("image/png") + ); +} +#[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); + } +} +#[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); +} 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..5a694d9b --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -0,0 +1,160 @@ +//! 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; + 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)?; + } + 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); + } + // 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 { .. })); + 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::Json { value: notice }); + 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; + 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) + ); + // Original estimate/counters are preserved as an upper bound; no budget or general + // source boundary is widened by removing unavailable content or resetting context. + Ok(plan) + } +} 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 27e000d8..1a1619d8 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -1328,6 +1328,23 @@ impl AiProvider for AiCodexAppServerProvider { .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 @@ -2074,6 +2091,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, @@ -4259,6 +4286,11 @@ impl AiCodexAppServerProtocolActor { } 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())})); } diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index 0b61967d..4da88b55 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -341,11 +341,9 @@ impl AiRuntime { if !self .providers .get(kind) - .is_some_and(|provider| provider.supports_tool_result_images()) + .is_some_and(|provider| provider.supports_tool_result_image_policy(descriptor)) { - return Err(AiError::ToolRejectedBeforeExecution( - crate::ToolPreExecutionRejectionReason::CapabilityUnsupported, - )); + return Err(AiError::Forbidden); } } Ok(()) diff --git a/crates/graphql-orm-ai/src/tool_media/context.rs b/crates/graphql-orm-ai/src/tool_media/context.rs index fab6f564..c552946f 100644 --- a/crates/graphql-orm-ai/src/tool_media/context.rs +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -33,6 +33,9 @@ pub(crate) struct RetainedToolResultSource { 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. @@ -80,6 +83,10 @@ impl AiToolResultDisclosureContext { .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.provider_session_binding_id().is_some() + != source.provider_cursor_fingerprint.is_some() { return Err(AiError::Conflict); } @@ -132,6 +139,24 @@ impl AiToolResultDisclosureContext { 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 diff --git a/crates/graphql-orm-ai/src/tool_media/resolved.rs b/crates/graphql-orm-ai/src/tool_media/resolved.rs index a1a97954..bedfb44c 100644 --- a/crates/graphql-orm-ai/src/tool_media/resolved.rs +++ b/crates/graphql-orm-ai/src/tool_media/resolved.rs @@ -5,6 +5,9 @@ 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)] @@ -51,6 +54,47 @@ pub struct AiResolvedToolImage { 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 { + 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 || { + let _permit = permit; + decode_exact(&reference, &bytes, limits)?; + Ok(Self { + reference, + resource, + bytes, + }) + }); + tokio::time::timeout( + std::time::Duration::from_millis(limits.maximum_resolution_ms()), + work, + ) + .await + .map_err(|_| AiError::ToolExecutionFailed)? + .map_err(|_| AiError::ToolExecutionFailed)? + } /// 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. /// 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..c6aad2c5 --- /dev/null +++ b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs @@ -0,0 +1,135 @@ +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() + ); +} 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() From 2f5c30b0f4dc096b7e62b3c31cbc48889cc05479 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 12:33:07 +1100 Subject: [PATCH 03/52] Require fresh source delivery for restricted text and preserve aggregate limits --- crates/graphql-orm-ai/src/provider.rs | 12 +++++++++++- crates/graphql-orm-ai/src/provider_calls.rs | 18 ++++++++++++++++++ .../src/provider_calls/tool_media.rs | 11 +++++++++++ .../graphql-orm-ai/src/tool_media/boundary.rs | 9 +++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 3cda9187..55208821 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -2481,6 +2481,7 @@ pub struct ProviderDynamicToolResult { tool_id: String, output: serde_json::Value, images: Vec, + requires_source_delivery: bool, } impl std::fmt::Debug for ProviderDynamicToolResult { @@ -2502,6 +2503,7 @@ impl ProviderDynamicToolResult { tool_id, output: serde_json::json!({"ready": true}), images: Vec::new(), + requires_source_delivery: false, } } @@ -2522,6 +2524,7 @@ impl ProviderDynamicToolResult { tool_id: call.tool_id.clone(), output, images: Vec::new(), + requires_source_delivery: false, }) } @@ -2541,6 +2544,7 @@ impl ProviderDynamicToolResult { tool_id, output, images: Vec::new(), + requires_source_delivery: false, }) } @@ -2566,6 +2570,12 @@ impl ProviderDynamicToolResult { &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, @@ -2766,7 +2776,7 @@ pub trait ProviderDynamicToolResponder: Send + Sync { &self, result: &ProviderDynamicToolResult, ) -> Result<(), ProviderError> { - if result.images().is_empty() { + if result.images().is_empty() && !result.requires_source_delivery { Ok(()) } else { Err(ProviderError::Rejected) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 5fa72cad..f9cffec1 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -2481,6 +2481,14 @@ impl DynamicToolResponder { None => 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. @@ -2489,6 +2497,15 @@ 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 @@ -2630,6 +2647,7 @@ impl DynamicToolResponder { output, )? .with_images(images) + .map(|result| result.require_source_delivery(requires_source && failure.is_none())) } } diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs index 5a694d9b..ab182ff9 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -54,6 +54,7 @@ impl AiProviderCallExecutor { return Ok(0); } let mut images = 0_u64; + let mut labels = Vec::new(); for source in &contexts { let descriptor = self .runtime @@ -74,6 +75,7 @@ impl AiProviderCallExecutor { .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) { @@ -83,6 +85,15 @@ impl AiProviderCallExecutor { if images > plan.budget.estimate.image_units { return Err(AiError::EgressDenied); } + if !labels.is_empty() { + plan.request.input.push(ModelInputBlock::Json { + value: serde_json::json!({"retainedToolImageCaptureLabels":labels}), + }); + 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 diff --git a/crates/graphql-orm-ai/src/tool_media/boundary.rs b/crates/graphql-orm-ai/src/tool_media/boundary.rs index dcb6a3c5..ac4c9e0f 100644 --- a/crates/graphql-orm-ai/src/tool_media/boundary.rs +++ b/crates/graphql-orm-ai/src/tool_media/boundary.rs @@ -161,6 +161,15 @@ pub(crate) fn evaluate( 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); From 835e89b658b43d7938873b75faabe3ede641b1c4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 12:40:01 +1100 Subject: [PATCH 04/52] Complete unavailable-source error code and assert image custody separation --- crates/graphql-orm-ai-tool-profiles/src/error.rs | 1 + .../src/provider_calls/tests/tool_media_tests.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/graphql-orm-ai-tool-profiles/src/error.rs b/crates/graphql-orm-ai-tool-profiles/src/error.rs index df3f4e84..12b12258 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/error.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/error.rs @@ -125,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/src/provider_calls/tests/tool_media_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs index bd440529..74bb33e1 100644 --- 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 @@ -512,11 +512,13 @@ async fn typed_pixels_charge_atomic_budget_and_reopen_without_effect_replay() { .unwrap() .unwrap(); assert!(saved.protected_result_source.is_some()); - assert!( - !serde_json::to_string(&saved.protected_result) - .unwrap() - .contains("image/png") - ); + // 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 absent_image_capacity_and_unsupported_adapter_refuse_before_effect() { From 12de658e8315f87bc42c101d7045821c0782e98e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 12:56:07 +1100 Subject: [PATCH 05/52] Revalidate protected sources at transport and enforce aggregate pixels --- .../graphql-orm-ai/docs/tool-result-media.md | 9 +- .../graphql-orm-ai/src/orm_runs/tool_media.rs | 1 - crates/graphql-orm-ai/src/orm_tools.rs | 2 +- crates/graphql-orm-ai/src/orm_tools/media.rs | 10 ++ crates/graphql-orm-ai/src/provider.rs | 40 +++++ crates/graphql-orm-ai/src/provider_calls.rs | 58 ++++++- .../provider_calls/tests/tool_media_tests.rs | 2 +- .../provider_calls/tool_source_delivery.rs | 143 ++++++++++++++++++ crates/graphql-orm-ai/src/runtime.rs | 10 +- .../graphql-orm-ai/src/tool_media/context.rs | 29 ++++ .../src/tool_media/reference.rs | 21 +++ .../src/tool_media/resolved/tests.rs | 22 +++ 12 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 730d6626..05229920 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -31,7 +31,8 @@ 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 decoded pixels, original retention at most one +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 @@ -86,6 +87,12 @@ 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. 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, diff --git a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs index 63c80f3e..7621e3e7 100644 --- a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -130,7 +130,6 @@ impl OrmAiRunService { source_reference, classification, image_count, - created_at: now.unix_timestamp(), }) .await .map_err(OrmPublicError::from)?; diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 98ad14d9..29ce0f14 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -3778,7 +3778,7 @@ impl OrmAiConsequentialToolCallService { .map(|origin| { origin .with_consumed_approval(&approval, &binding) - .map_err(AiError::from) + .map_err(|_| AiError::Conflict) }) .transpose()?; let execution = if let Some(prepared) = prepared { diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index a2106e2f..275003ab 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -13,6 +13,14 @@ pub(super) struct RetainedDisclosure { } 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 } @@ -604,6 +612,7 @@ impl OrmAiApplicationToolCallService { 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 operation = async { let mut images = Vec::with_capacity(context.images().len()); @@ -701,5 +710,6 @@ fn extract_references_inner( images.push(image); } } + crate::tool_media::validate_image_reference_batch(&images, policy.limits())?; Ok(images) } diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 55208821..5de49ff2 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1360,6 +1360,8 @@ pub struct ProviderRequestContext { budget: AuthorizedBudgetReservation, transfers: Vec, resolved_attachments: Vec, + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_source_delivery: Option>, } impl ProviderRequestContext { @@ -1386,10 +1388,36 @@ impl ProviderRequestContext { budget, transfers: Vec::new(), resolved_attachments: Vec::new(), + #[cfg(any(feature = "sqlite", feature = "postgres"))] + tool_source_delivery: None, } .with_authorized_transfer(manifest, proof) } + /// 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 @@ -1955,6 +1983,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 +2029,7 @@ impl ProviderError { | Self::BudgetDenied | Self::Unsupported | Self::Rejected + | Self::ToolSourceUnavailable | Self::StatelessNativeItemRejected | Self::NewlyBoundTurnRejected(_) => AiProviderFailureCategory::ProviderRejection, Self::RetainedTurnRejected(_) => AiProviderFailureCategory::RetainedResumeRejection, @@ -3366,6 +3400,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), @@ -3403,6 +3440,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 f9cffec1..1203ce24 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -27,6 +27,8 @@ use crate::{ }; mod tool_media; +mod tool_source_delivery; +pub(crate) use tool_source_delivery::ToolSourceDelivery; #[path = "provider_maintenance.rs"] pub(crate) mod maintenance; @@ -4189,6 +4191,31 @@ impl AiProviderCallExecutor { } }; } + if 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, + ) + }) { + 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(); @@ -4306,7 +4333,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); @@ -5033,7 +5064,30 @@ impl AiProviderCallExecutor { .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 mut authorized = authorized.into_iter(); 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 index 74bb33e1..6fd0661f 100644 --- 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 @@ -201,7 +201,7 @@ impl crate::AiProvider for MediaProvider { if result.images().len() != 1 || result.images()[0].bytes().is_empty() { return Err(ProviderError::Rejected); } - Ok(Box::pin(futures_util::stream::iter([ + Ok(Box::pin(futures::stream::iter([ Ok(ProviderEvent::ResponseStarted { response_id: Some("preview-turn".into()), }), 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..1f124891 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -0,0 +1,143 @@ +//! 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, +} +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, + ) -> Result>, AiError> { + let manifests = manifests + .iter() + .filter(|manifest| { + 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, + }))) + } + } + 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)?; + let mut seen = BTreeSet::new(); + let mut count = 0_u64; + let mut bytes = 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); + } + 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)?; + if count > 4 || count > self.maximum_image_units || bytes > 16 * 1024 * 1024 + { + return Err(ProviderError::BudgetDenied); + } + } + contexts.push(context); + } + 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), operation) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)? + } +} diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index 4da88b55..0e11c687 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -1103,7 +1103,12 @@ impl AiRuntime { 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), } } @@ -1167,6 +1172,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 diff --git a/crates/graphql-orm-ai/src/tool_media/context.rs b/crates/graphql-orm-ai/src/tool_media/context.rs index c552946f..05cfb456 100644 --- a/crates/graphql-orm-ai/src/tool_media/context.rs +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -194,3 +194,32 @@ impl AiToolResultDisclosureContext { } } } + +/// 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.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) + })) + }); + 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 index 998f48ad..bed8796d 100644 --- a/crates/graphql-orm-ai/src/tool_media/reference.rs +++ b/crates/graphql-orm-ai/src/tool_media/reference.rs @@ -183,3 +183,24 @@ impl AiToolImageReference { 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 images.len() > usize::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/tests.rs b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs index c6aad2c5..574c8b83 100644 --- a/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs +++ b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs @@ -133,3 +133,25 @@ async fn bounded_decode_preserves_exact_immutable_payload() { .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(&[first.clone()], limits).unwrap(); + crate::tool_media::validate_image_reference_batch( + &[first, second], + AiToolResultMediaLimits::new(2, 8192, 10, 12, 1000, 5000).unwrap(), + ) + .unwrap(); +} From 5c07ad658a26b7faa2d807c02e5f1379881fb480 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 13:10:52 +1100 Subject: [PATCH 06/52] Deliver reopened original pixels as typed continuation input --- .../graphql-orm-ai/docs/tool-result-media.md | 7 +- crates/graphql-orm-ai/src/provider.rs | 41 ++++++++++ crates/graphql-orm-ai/src/provider_calls.rs | 7 +- .../provider_calls/tests/tool_media_tests.rs | 55 ++++++++++++-- .../provider_calls/tool_source_delivery.rs | 24 +++++- .../src/providers/codex_app_server.rs | 74 +++++++++++++++++-- crates/graphql-orm-ai/src/runtime.rs | 19 +++-- .../src/tool_media/reference.rs | 4 +- 8 files changed, 207 insertions(+), 24 deletions(-) diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 05229920..6cfca197 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -90,7 +90,12 @@ 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. A general/ordinary denial remains +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 diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index 5de49ff2..e7f16be2 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1360,6 +1360,7 @@ pub struct ProviderRequestContext { budget: AuthorizedBudgetReservation, transfers: Vec, resolved_attachments: Vec, + retained_tool_images: Vec, #[cfg(any(feature = "sqlite", feature = "postgres"))] tool_source_delivery: Option>, } @@ -1388,12 +1389,52 @@ impl ProviderRequestContext { budget, transfers: Vec::new(), resolved_attachments: Vec::new(), + retained_tool_images: Vec::new(), #[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 diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 1203ce24..051492e9 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4960,6 +4960,7 @@ impl AiProviderCallExecutor { let mut image_bytes = 0_u64; let mut image_count = 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 mut sources = Vec::new(); @@ -5041,6 +5042,7 @@ impl AiProviderCallExecutor { { return Err(source_failure(AiError::EgressDenied)); } + retained_images.extend(images); } } if !sources @@ -5108,7 +5110,10 @@ impl AiProviderCallExecutor { return Err(source_failure(AiError::ToolExecutionFailed)); } Ok(( - context.with_run_binding(crate::AiProviderRunBinding::from_lease(lease)?)?, + context + .with_run_binding(crate::AiProviderRunBinding::from_lease(lease)?)? + .with_retained_tool_result_images(retained_images) + .map_err(source_failure)?, image_bytes, )) } 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 index 6fd0661f..ec77e09e 100644 --- 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 @@ -16,6 +16,7 @@ struct Custody { effects: AtomicUsize, opens: AtomicUsize, allowed: AtomicBool, + ordinary_allowed: AtomicBool, revoke_before_delivery: AtomicBool, } struct PreviewExecutor(Arc); @@ -120,11 +121,20 @@ impl AiEgressPolicy for Custody { principal: &ResolvedPrincipal, manifest: &AiEgressManifest, ) -> AiEgressDecision { - AiEgressDecision::allow( - manifest, - "preview-policy-v1", - principal.principal().subject(), - ) + 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, @@ -164,7 +174,10 @@ impl crate::AiProvider for MediaProvider { ProviderKind::OpenAiCompatible } fn capabilities(&self) -> crate::ProviderCapabilities { - MockProvider::new(Vec::new()).capabilities() + crate::ProviderCapabilities { + image_input: self.supported, + ..MockProvider::new(Vec::new()).capabilities() + } } fn supports_tool_result_images(&self) -> bool { self.supported @@ -303,6 +316,7 @@ async fn media_fixture( 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); let rule = AiDisclosureRule::exportable(DataClassification::Restricted); let fields = [ "Reference", @@ -618,3 +632,32 @@ async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { ); 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, + ) + .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); +} 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 index 1f124891..b39fbc7d 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -83,10 +83,22 @@ impl ToolSourceDelivery { 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) + .authorize_retained_image_analysis(&lease, context, manifest) .await .map_err(|_| ProviderError::ToolSourceUnavailable)?; count = count @@ -101,7 +113,6 @@ impl ToolSourceDelivery { return Err(ProviderError::BudgetDenied); } } - contexts.push(context); } let decision = self .service @@ -141,3 +152,12 @@ impl ToolSourceDelivery { .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/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 1a1619d8..d4d2c813 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -741,15 +741,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, @@ -799,6 +802,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, @@ -809,6 +815,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. /// @@ -1368,6 +1421,7 @@ impl AiProvider for AiCodexAppServerProvider { .pool .supports_launch_profile(self.registration.launch_profile()), provider_retained_continuation: true, + image_input: dynamic_tools_available, local: true, reasoning_effort_profiles: self .registration @@ -1407,7 +1461,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 => { @@ -1483,7 +1539,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 => { @@ -4372,10 +4430,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 @@ -4432,7 +4490,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() { diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index 0e11c687..e966f098 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -338,11 +338,10 @@ impl AiRuntime { ) -> Result<(), AiError> { if let Some(policy) = &descriptor.result_media { self.tool_media_resolver(policy.resolver_id())?; - if !self - .providers - .get(kind) - .is_some_and(|provider| provider.supports_tool_result_image_policy(descriptor)) - { + if !self.providers.get(kind).is_some_and(|provider| { + provider.capabilities().image_input + && provider.supports_tool_result_image_policy(descriptor) + }) { return Err(AiError::Forbidden); } } @@ -1097,6 +1096,11 @@ 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); + } provider .capabilities() .validate_reasoning_effort(&request.model, request.reasoning_effort)?; @@ -1165,6 +1169,11 @@ 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); + } provider .capabilities() .validate_reasoning_effort(&request.model, request.reasoning_effort)?; diff --git a/crates/graphql-orm-ai/src/tool_media/reference.rs b/crates/graphql-orm-ai/src/tool_media/reference.rs index bed8796d..7cd38304 100644 --- a/crates/graphql-orm-ai/src/tool_media/reference.rs +++ b/crates/graphql-orm-ai/src/tool_media/reference.rs @@ -190,7 +190,9 @@ pub(crate) fn validate_image_reference_batch( images: &[AiToolImageReference], limits: AiToolResultMediaLimits, ) -> Result<(), AiError> { - if images.len() > usize::from(limits.maximum_images()) { + if u64::try_from(images.len()).map_err(|_| AiError::ResultBudgetExceeded)? + > u64::from(limits.maximum_images()) + { return Err(AiError::ResultBudgetExceeded); } let pixels = images From b2c581f63074334f60ce1156c900f0854ea92426 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 13:27:33 +1100 Subject: [PATCH 07/52] Keep unavailable protected sources closed in background retrieval --- crates/graphql-orm-ai/src/orm_background.rs | 1 + 1 file changed, 1 insertion(+) 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(_) From fe5bf70417b7c62bc73316562094b2db5d9e9cf7 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 13:39:32 +1100 Subject: [PATCH 08/52] Test retained effect reopening and genuine continuation image content --- .../provider_calls/tests/tool_media_tests.rs | 70 ++++++++++++++++++- .../src/providers/codex_app_server.rs | 8 ++- .../src/providers/codex_tool_media.rs | 60 ++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 crates/graphql-orm-ai/src/providers/codex_tool_media.rs 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 index ec77e09e..7edc382a 100644 --- 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 @@ -18,6 +18,34 @@ struct Custody { allowed: AtomicBool, ordinary_allowed: AtomicBool, revoke_before_delivery: AtomicBool, + fail_next_source_open: AtomicBool, +} +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] @@ -425,7 +453,7 @@ async fn media_fixture( .tool_catalog(catalog) .secret_store(Arc::new(EnvironmentSecretStore::new())) .content_protection_policy_resolver(Arc::new(ProtectionPolicy)) - .content_protector(Arc::new(DatabaseManagedContentProtector)) + .content_protector(Arc::new(SourceProtector(custody.clone()))) .provider(Arc::new(MediaProvider { custody: custody.clone(), supported, @@ -661,3 +689,43 @@ async fn delivery_history_race_denies_protected_sources_but_preserves_general_de )); 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); +} 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 d4d2c813..c807a2bd 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -6841,6 +6841,10 @@ fn registration_identity(registration: &AiCodexAppServerRegistration) -> String 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}; @@ -9896,7 +9900,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()], @@ -9913,7 +9917,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()], 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..4e8b304c --- /dev/null +++ b/crates/graphql-orm-ai/src/providers/codex_tool_media.rs @@ -0,0 +1,60 @@ +//! 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::Json { + value: json!({"ImageReference":uuid::Uuid::new_v4(),"ImageMimeType":"image/png"}), + }]; + 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()); +} From 4017bb554af7c54f697e8b4f964f229396d12142 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 13:45:15 +1100 Subject: [PATCH 09/52] Use admitted JSON Schema in media owner fixture --- .../src/provider_calls/tests/tool_media_tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 7edc382a..0f5fd826 100644 --- 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 @@ -406,7 +406,7 @@ async fn media_fixture( "Read a bounded retained preview", AiToolOperationKind::Query, document, - json!({"type":"object","properties":{},"additionalProperties":false}), + json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{},"additionalProperties":false}), ) .unwrap() .with_result_projection("preview-projection-v1") @@ -494,8 +494,6 @@ async fn preview_turn( image_units: u64, ) -> Result { let mut plan = tool_plan(fixture); - plan.request.tools[0].parameters = - json!({"type":"object","properties":{},"additionalProperties":false}); plan.budget.estimate.image_units = image_units; let executor = AiProviderCallExecutor::new( fixture.runtime.clone(), From 801b7e8fd726dd15328fbf8004cee4db70830664 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 13:52:17 +1100 Subject: [PATCH 10/52] Exercise approved media custody and bound complete history pixels --- crates/graphql-orm-ai/src/provider_calls.rs | 12 + .../provider_calls/tests/tool_media_tests.rs | 244 +++++++++++++++++- .../provider_calls/tool_source_delivery.rs | 15 +- .../src/providers/codex_tool_media.rs | 7 +- 4 files changed, 270 insertions(+), 8 deletions(-) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 051492e9..9793c9f3 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4959,6 +4959,7 @@ impl AiProviderCallExecutor { 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()); @@ -5035,9 +5036,20 @@ impl AiProviderCallExecutor { 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)); 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 index 0f5fd826..81013ebc 100644 --- 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 @@ -215,10 +215,31 @@ impl crate::AiProvider for MediaProvider { } async fn stream( &self, - _: ModelRequest, + request: ModelRequest, _: ProviderRequestContext, ) -> Result { - Err(ProviderError::Rejected) + 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, @@ -340,6 +361,12 @@ fn boundary(classification: DataClassification, attachments: u32) -> AiDeploymen } 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) { let mut fixture = fixture(Vec::new()).await; let custody = Arc::new(Custody::default()); @@ -372,7 +399,11 @@ async fn media_fixture( ), ) .unwrap(); - let document = "query Preview { Preview { Reference Sha256 Mime Width Height CapturedAtMs RetainedUntilMs ActionExpiresAtMs } }"; + 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", @@ -404,7 +435,7 @@ async fn media_fixture( let descriptor = AiToolDescriptor::new( "records.read", "Read a bounded retained preview", - AiToolOperationKind::Query, + if supervised { AiToolOperationKind::Mutation } else { AiToolOperationKind::Query }, document, json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{},"additionalProperties":false}), ) @@ -413,6 +444,14 @@ async fn media_fixture( .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", @@ -436,6 +475,7 @@ async fn media_fixture( }) .unwrap(); let runtime = AiRuntime::builder() + .maximum_tool_maturity(ToolMaturity::SupervisedWrite) .principal_resolver(Arc::new(CountingResolver { principal: fixture.principal.clone(), resolutions: fixture.principal_resolutions.clone(), @@ -727,3 +767,199 @@ async fn post_effect_source_open_failure_retains_actual_output_for_later_read_wi assert_eq!(images.len(), 1); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } + +struct MediaActionPreview; +#[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}), + }) + } +} + +#[tokio::test] +async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_executing_again() { + let (fixture, custody, application) = media_fixture_mode(true, true).await; + 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 offered = AiProviderCallPlan::new_with_supervised_tools( + base.provider_kind, + base.request, + base.budget, + base.transfers, + "approved-media-test", + fixture.runtime.tool_catalog(), + &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 provider = executor.execute(&fixture.lease, offered).await.unwrap(); + 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(TestRuleResolver::default()), + Arc::new(SystemClock), + AiCoordinatorCheckpointLimits::new(256 * 1024, Duration::minutes(5)).unwrap(), + )); + let (rules, usage) = test_rule_checkpoint(&fixture.scope, &[&provider], 0); + let protected = checkpoints + .persist_provider_turn( + &fixture.lease, + &provider, + &fixture.scope, + "approved-media-test", + &preview_route(), + &rules, + usage, + 1, + 1, + ) + .await + .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(), + )); + let requested = consequential + .request_approval( + &protected, + &provider, + AiApplicationToolCallContext::new( + 0, + 0, + fixture.scope.clone(), + "approved-media-test", + provider.budget_reservation_id().0.to_string(), + ) + .unwrap(), + 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, consequential); + let completed = resume.execute_claimed(&claim).await.unwrap(); + 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.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); +} 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 index b39fbc7d..75dd25c4 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -61,6 +61,7 @@ impl ToolSourceDelivery { 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 { @@ -108,7 +109,19 @@ impl ToolSourceDelivery { .iter() .try_fold(bytes, |sum, image| sum.checked_add(image.byte_count())) .ok_or(ProviderError::BudgetDenied)?; - if count > 4 || count > self.maximum_image_units || bytes > 16 * 1024 * 1024 + 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); } diff --git a/crates/graphql-orm-ai/src/providers/codex_tool_media.rs b/crates/graphql-orm-ai/src/providers/codex_tool_media.rs index 4e8b304c..36d0ce7f 100644 --- a/crates/graphql-orm-ai/src/providers/codex_tool_media.rs +++ b/crates/graphql-orm-ai/src/providers/codex_tool_media.rs @@ -41,10 +41,11 @@ fn reopened_original_pixels_are_real_image_input_and_keep_original_action_label( assert!(!debug.contains("data:image")); } #[test] -fn JSON_reference_is_text_only_and_unadmitted_image_batches_are_refused() { +fn json_reference_is_text_only_and_unadmitted_image_batches_are_refused() { let mut request = model_request(); - request.input = vec![ModelInputBlock::Json { - value: json!({"ImageReference":uuid::Uuid::new_v4(),"ImageMimeType":"image/png"}), + 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!( From 554add92c07bd3ead73494b3d4db961cc2e1fb1e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:00:50 +1100 Subject: [PATCH 11/52] Retain completed outputs before media admission and use genuine approval parking --- .../graphql-orm-ai/docs/tool-result-media.md | 9 +- .../src/orm_run_cancellation.rs | 1 + .../graphql-orm-ai/src/orm_runs/tool_media.rs | 95 ++++++- crates/graphql-orm-ai/src/orm_tools/media.rs | 27 +- .../provider_calls/tests/tool_media_tests.rs | 239 ++++++++++++++++-- .../src/provider_calls/tool_media.rs | 10 +- 6 files changed, 342 insertions(+), 39 deletions(-) diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 6cfca197..ab0e1ef3 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -68,8 +68,13 @@ static boundary on every disclosure. ## Durable results, provider delivery and budgets -The application service protects the actual completed-effect result and source -metadata before disclosure. A small internal source-address index bounds retained +The application service protects and saves the actual bounded completed-effect +result before parsing descriptive image metadata or opening resources. 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. 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 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/tool_media.rs b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs index 7621e3e7..9690c087 100644 --- a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -3,6 +3,99 @@ 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 + { + 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, @@ -63,7 +156,7 @@ impl OrmAiRunService { || step.lease_generation != call.lease_generation || step.state != "running" || call.state != "executing" - || call.protected_result.is_some() + || 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 diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index 275003ab..a6851397 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -85,6 +85,21 @@ impl OrmAiApplicationToolCallService { trust: AiSourceTrust, ) -> Result { self.current_access(lease, &call_context.scope).await?; + 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?; let images = extract_references( descriptor, output, @@ -119,18 +134,6 @@ impl OrmAiApplicationToolCallService { )?; let image_count = i64::try_from(source_context.images().len()).map_err(|_| AiError::PersistenceFailed)?; - 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?; let protected_source = self .protect( policy, 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 index 81013ebc..ea306bdb 100644 --- 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 @@ -19,6 +19,11 @@ struct Custody { 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, } struct SourceProtector(Arc); #[async_trait] @@ -91,7 +96,7 @@ impl AuthenticatedGraphqlExecutor for PreviewExecutor { Ok(ToolGraphqlResponse { data: json!({"Preview":{ "Reference":reference.reference(),"Sha256":reference.sha256(),"Mime":reference.mime(), - "Width":reference.width(),"Height":reference.height(),"CapturedAtMs":reference.captured_at_ms(), + "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(), @@ -170,7 +175,12 @@ impl AiEgressPolicy for Custody { manifest: &AiEgressManifest, sources: &[crate::AiToolResultDisclosureContext], ) -> AiEgressDecision { + 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 @@ -204,6 +214,7 @@ impl crate::AiProvider for MediaProvider { fn capabilities(&self) -> crate::ProviderCapabilities { crate::ProviderCapabilities { image_input: self.supported, + provider_retained_continuation: true, ..MockProvider::new(Vec::new()).capabilities() } } @@ -213,11 +224,38 @@ impl crate::AiProvider for MediaProvider { 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", "preview-thread") + .map_err(|_| ProviderError::InvalidRequest) + } async fn stream( &self, request: ModelRequest, - _: ProviderRequestContext, + 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() { + return Ok(Box::pin(futures::stream::iter([ + 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 { @@ -768,6 +806,43 @@ async fn post_effect_source_open_failure_retains_actual_output_for_later_read_wi 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; #[async_trait] impl AiCanonicalActionPreviewBuilder for MediaActionPreview { @@ -790,6 +865,128 @@ impl AiCanonicalActionPreviewBuilder for MediaActionPreview { } } +async fn bind_original_media_session( + fixture: &Fixture, + provider: &mut AiProviderCallResult, +) -> 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), + ..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 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(); + let sessions = 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(), + ); + let descriptor = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + "a".repeat(64), + "mock/v1", + "b".repeat(64), + ) + .unwrap(); + let cursor = fixture + .runtime + .create_empty_provider_session( + &ProviderKind::OpenAiCompatible, + &crate::AiProviderRunBinding::from_lease(&fixture.lease).unwrap(), + &descriptor, + &provider.request_snapshot, + ) + .await + .unwrap(); + provider.provider_session_claim = Some( + sessions + .bind_for_run( + &fixture.lease, + AiProviderSessionBindRequest::new(descriptor, cursor, "d".repeat(64), None) + .unwrap(), + ) + .await + .unwrap(), + ); + sessions +} + #[tokio::test] async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_executing_again() { let (fixture, custody, application) = media_fixture_mode(true, true).await; @@ -834,7 +1031,8 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec Arc::new(SystemClock), AiProviderCallLimits::new(64, 8192, 65536).unwrap(), ); - let provider = executor.execute(&fixture.lease, offered).await.unwrap(); + let mut provider = executor.execute(&fixture.lease, offered).await.unwrap(); + let sessions = bind_original_media_session(&fixture, &mut provider).await; assert_eq!(custody.effects.load(Ordering::SeqCst), 0); let checkpoints = Arc::new(OrmAiCoordinatorCheckpointService::new( fixture.run_service.clone(), @@ -862,23 +1060,26 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec .await .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), + 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(), ) - .unwrap(), - )); + .with_provider_session_service(sessions), + ); let requested = consequential .request_approval( &protected, diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs index ab182ff9..f861e085 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -86,8 +86,8 @@ impl AiProviderCallExecutor { return Err(AiError::EgressDenied); } if !labels.is_empty() { - plan.request.input.push(ModelInputBlock::Json { - value: serde_json::json!({"retainedToolImageCaptureLabels":labels}), + plan.request.input.push(ModelInputBlock::Text { + text: serde_json::json!({"retainedToolImageCaptureLabels":labels}).to_string(), }); inference.estimated_bytes = plan .request @@ -121,9 +121,9 @@ impl AiProviderCallExecutor { 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::Json { value: notice }); + plan.request.input.push(ModelInputBlock::Text { + text: notice.to_string(), + }); plan.request.continuation = None; plan.request.continuation_mode = ModelContinuationMode::ProviderRetained; plan.request.tools.clear(); From 2c7f2e2ed48384abe94ea814a50077626510a3f7 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:08:11 +1100 Subject: [PATCH 12/52] Qualify native approval source history and current owner cancellation --- .../provider_calls/tests/tool_media_tests.rs | 235 ++++++++++++++++-- 1 file changed, 213 insertions(+), 22 deletions(-) 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 index ea306bdb..c5a6086a 100644 --- 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 @@ -989,6 +989,16 @@ async fn bind_original_media_session( #[tokio::test] async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_executing_again() { + approved_media_lifecycle(false).await; +} + +#[tokio::test] +async fn native_history_normal_deny_after_preflight_withholds_saved_effect_and_releases_unstarted_budget() + { + approved_media_lifecycle(true).await; +} + +async fn approved_media_lifecycle(deny_history: bool) { let (fixture, custody, application) = media_fixture_mode(true, true).await; let descriptor = fixture .runtime @@ -1040,25 +1050,10 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec Arc::new(AllowAccess), Arc::new(ProtectionPolicy), Arc::new(DatabaseManagedContentProtector), - Arc::new(TestRuleResolver::default()), + Arc::new(NativeTestRuleResolver), Arc::new(SystemClock), AiCoordinatorCheckpointLimits::new(256 * 1024, Duration::minutes(5)).unwrap(), )); - let (rules, usage) = test_rule_checkpoint(&fixture.scope, &[&provider], 0); - let protected = checkpoints - .persist_provider_turn( - &fixture.lease, - &provider, - &fixture.scope, - "approved-media-test", - &preview_route(), - &rules, - usage, - 1, - 1, - ) - .await - .unwrap(); let (_, approvals) = consequential_test_service(&fixture); let consequential = Arc::new( OrmAiConsequentialToolCallService::new( @@ -1078,11 +1073,11 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec ) .unwrap(), ) - .with_provider_session_service(sessions), + .with_provider_session_service(sessions.clone()), ); - let requested = consequential - .request_approval( - &protected, + let prepared = consequential + .prepare_native_approval( + &fixture.lease, &provider, AiApplicationToolCallContext::new( 0, @@ -1092,6 +1087,36 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec provider.budget_reservation_id().0.to_string(), ) .unwrap(), + ) + .await + .unwrap(); + let receipt = consequential + .prepare_native_approval_receipt(prepared.lease(), &prepared, &preview_route()) + .await + .unwrap(); + let active = receipt.lease().clone(); + provider.native_control_receipts = vec![receipt]; + 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, ) @@ -1121,8 +1146,11 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec .unwrap() .unwrap(); assert!(claim.lease().lease_generation() > fixture.lease.lease_generation()); - let resume = - OrmAiSupervisedResumeService::new(fixture.run_service.clone(), checkpoints, consequential); + let resume = OrmAiSupervisedResumeService::new( + fixture.run_service.clone(), + checkpoints.clone(), + consequential, + ); let completed = resume.execute_claimed(&claim).await.unwrap(); let current = completed.checkpointed().unwrap().lease(); let source = application @@ -1163,4 +1191,167 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec .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(); + 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")); + } else { + assert_eq!(continued.usage().image_units, 1); + assert_eq!(custody.delivered_images.load(Ordering::SeqCst), 1); + } +} + +#[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); } From 0c9e808ebd29201784aa4272cd34c085c4c4ca80 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:11:29 +1100 Subject: [PATCH 13/52] Box bounded source passes and align native fixture with admitted provider profile --- crates/graphql-orm-ai/src/orm_tools.rs | 29 +++++++++---------- crates/graphql-orm-ai/src/orm_tools/media.rs | 13 ++++++--- crates/graphql-orm-ai/src/provider_calls.rs | 8 ++++- .../provider_calls/tool_source_delivery.rs | 9 ++++-- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 29ce0f14..aca62c47 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -2384,7 +2384,7 @@ impl OrmAiApplicationToolCallService { || classification >= DataClassification::Restricted => { Some( - self.retain_disclosure_source( + Box::pin(self.retain_disclosure_source( lease, &descriptor, origin, @@ -2394,7 +2394,7 @@ impl OrmAiApplicationToolCallService { &disclosure_fingerprint, classification, source_trust, - ) + )) .await?, ) } @@ -3894,19 +3894,18 @@ impl OrmAiConsequentialToolCallService { { let origin = retained_origin.as_ref().ok_or(AiError::Forbidden)?; Some( - self.application_tools - .retain_disclosure_source( - &running_lease, - &descriptor, - origin, - &context, - &policy, - &model_output, - &disclosure_schema_fingerprint, - classification, - source_trust, - ) - .await?, + Box::pin(self.application_tools.retain_disclosure_source( + &running_lease, + &descriptor, + origin, + &context, + &policy, + &model_output, + &disclosure_schema_fingerprint, + classification, + source_trust, + )) + .await?, ) } else { None diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index a6851397..ae82dd03 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -174,7 +174,12 @@ impl OrmAiApplicationToolCallService { ) -> Result, AiError> { tokio::time::timeout( std::time::Duration::from_secs(30), - self.retained_session_sources_inner(lease, inference, binding_id, cursor_fingerprint), + Box::pin(self.retained_session_sources_inner( + lease, + inference, + binding_id, + cursor_fingerprint, + )), ) .await .map_err(|_| AiError::ToolExecutionFailed)? @@ -257,7 +262,7 @@ impl OrmAiApplicationToolCallService { .await?; Ok(sources) }; - tokio::time::timeout(std::time::Duration::from_secs(30), operation) + tokio::time::timeout(std::time::Duration::from_secs(30), Box::pin(operation)) .await .map_err(|_| AiError::ToolExecutionFailed)? } @@ -360,7 +365,7 @@ impl OrmAiApplicationToolCallService { ) -> Result { tokio::time::timeout( std::time::Duration::from_secs(30), - self.reopen_tool_result_source_inner(lease, id, purpose), + Box::pin(self.reopen_tool_result_source_inner(lease, id, purpose)), ) .await .map_err(|_| AiError::ToolExecutionFailed)? @@ -653,7 +658,7 @@ impl OrmAiApplicationToolCallService { }; tokio::time::timeout( std::time::Duration::from_millis(policy.limits().maximum_resolution_ms()), - operation, + Box::pin(operation), ) .await .map_err(|_| AiError::ToolExecutionFailed)? diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 9793c9f3..f20ff6ca 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4930,7 +4930,12 @@ impl AiProviderCallExecutor { ) -> Result<(ProviderRequestContext, u64), AiError> { tokio::time::timeout( std::time::Duration::from_secs(30), - self.authorize_and_audit_transfers_inner(lease, plan, budget, native_session_claim), + Box::pin(self.authorize_and_audit_transfers_inner( + lease, + plan, + budget, + native_session_claim, + )), ) .await .map_err(|_| { @@ -13049,6 +13054,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| { 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 index 75dd25c4..0a4b6482 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -160,9 +160,12 @@ impl ToolSourceDelivery { .map_err(|_| ProviderError::Cancelled)?; Ok(()) }; - tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), operation) - .await - .map_err(|_| ProviderError::ToolSourceUnavailable)? + tokio::time::timeout_at( + tokio::time::Instant::from_std(deadline), + Box::pin(operation), + ) + .await + .map_err(|_| ProviderError::ToolSourceUnavailable)? } } From 91a19003c49bb74e65b51234ff4b5df017c528ed Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:14:35 +1100 Subject: [PATCH 14/52] Reject owning image results that return beyond the resolution deadline --- crates/graphql-orm-ai/src/orm_tools/media.rs | 11 +++++++ .../provider_calls/tests/tool_media_tests.rs | 33 ++++++++++++++++++- .../graphql-orm-ai/src/tool_media/resolved.rs | 12 +++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index ae82dd03..aa222e8a 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -622,10 +622,15 @@ impl OrmAiApplicationToolCallService { 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?; @@ -636,6 +641,9 @@ impl OrmAiApplicationToolCallService { 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); } @@ -652,6 +660,9 @@ impl OrmAiApplicationToolCallService { self.run_service .validate_tool_disclosure_fence(lease) .await?; + if std::time::Instant::now() >= deadline { + return Err(AiError::ToolExecutionFailed); + } images.push(image); } Ok(images) 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 index c5a6086a..dc27f601 100644 --- 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 @@ -24,6 +24,7 @@ struct Custody { deny_model_disclosure_at: AtomicUsize, requests: Mutex>, delivered_images: AtomicUsize, + delay_open: AtomicBool, } struct SourceProtector(Arc); #[async_trait] @@ -134,6 +135,11 @@ impl crate::AiToolResultMediaResolver for Custody { 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())?, @@ -405,6 +411,13 @@ async fn media_fixture( 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()); @@ -466,7 +479,8 @@ async fn media_fixture_mode( .unwrap() .with_action_expires_at_ms("ActionExpiresAtMs") .unwrap(), - AiToolResultMediaLimits::new(1, 4 * 1024 * 1024, 1536, 4_000_000, 300_000, 5000).unwrap(), + AiToolResultMediaLimits::new(1, 4 * 1024 * 1024, 1536, 4_000_000, 300_000, resolution_ms) + .unwrap(), DataClassification::Restricted, ) .unwrap(); @@ -844,6 +858,23 @@ async fn invalid_post_effect_media_is_saved_but_cannot_be_admitted_or_replayed() } struct MediaActionPreview; +#[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( diff --git a/crates/graphql-orm-ai/src/tool_media/resolved.rs b/crates/graphql-orm-ai/src/tool_media/resolved.rs index bedfb44c..fc6f7e89 100644 --- a/crates/graphql-orm-ai/src/tool_media/resolved.rs +++ b/crates/graphql-orm-ai/src/tool_media/resolved.rs @@ -68,6 +68,8 @@ impl AiResolvedToolImage { 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))) @@ -78,7 +80,7 @@ impl AiResolvedToolImage { if resource.reference != reference.reference() { return Err(AiError::Forbidden); } - let work = tokio::task::spawn_blocking(move || { + let work = tokio::task::spawn_blocking(move || -> Result { let _permit = permit; decode_exact(&reference, &bytes, limits)?; Ok(Self { @@ -87,13 +89,17 @@ impl AiResolvedToolImage { bytes, }) }); - tokio::time::timeout( + let resolved = tokio::time::timeout( std::time::Duration::from_millis(limits.maximum_resolution_ms()), work, ) .await .map_err(|_| AiError::ToolExecutionFailed)? - .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. From df759acb185d5c962f7d8c903b80d098571143dd Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:41:48 +1100 Subject: [PATCH 15/52] Bind actual effect fences and close queued protected media delivery --- crates/graphql-orm-ai/CHANGELOG.md | 6 + crates/graphql-orm-ai/MIGRATION.md | 10 + .../graphql-orm-ai/docs/tool-result-media.md | 15 +- crates/graphql-orm-ai/src/execution.rs | 38 +++ .../graphql-orm-ai/src/orm_runs/tool_media.rs | 4 + crates/graphql-orm-ai/src/orm_tools.rs | 79 +++-- crates/graphql-orm-ai/src/orm_tools/media.rs | 2 + crates/graphql-orm-ai/src/provider.rs | 9 + crates/graphql-orm-ai/src/provider_calls.rs | 39 +-- .../provider_calls/tests/tool_media_tests.rs | 278 ++++++++++++++---- .../src/provider_calls/tool_media.rs | 15 +- .../src/providers/codex_app_server.rs | 7 + .../src/providers/grok_acp_provider.rs | 6 + .../src/providers/grok_acp_wire.rs | 1 + crates/graphql-orm-ai/src/runtime.rs | 21 ++ .../graphql-orm-ai/src/tool_media/context.rs | 2 + 16 files changed, 426 insertions(+), 106 deletions(-) diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 271effff..0e64e006 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -41,6 +41,12 @@ checkpoint facts. For the current workspace baseline and active gates, use the 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. +- 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. ### Added diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index e1adfa9a..5820e576 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -26,6 +26,16 @@ 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. + +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; diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index ab0e1ef3..3c102139 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -57,6 +57,14 @@ is captured from the owning admitted binding before effect admission, rather tha 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. 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 @@ -85,7 +93,12 @@ 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. The existing atomic provider-turn reservation allocates image units +admission. Adapters also default-deny source delivery for declared +Restricted/media effects unless they implement the final current source guard; +the reviewed Codex adapter preserves that guard through its process 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. Unused static capacity is released only after a definite completed result; ambiguous provider delivery keeps the reservation uncertain. Settlement must include diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs index ab700197..c9138640 100644 --- a/crates/graphql-orm-ai/src/execution.rs +++ b/crates/graphql-orm-ai/src/execution.rs @@ -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, @@ -207,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 @@ -345,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 @@ -397,6 +433,8 @@ impl AiToolExecutionProvenance { 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 } diff --git a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs index 9690c087..ace1323c 100644 --- a/crates/graphql-orm-ai/src/orm_runs/tool_media.rs +++ b/crates/graphql-orm-ai/src/orm_runs/tool_media.rs @@ -46,6 +46,8 @@ impl OrmAiRunService { || 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)); } @@ -164,6 +166,8 @@ impl OrmAiRunService { || 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)); } diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index aca62c47..9287fc87 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -2270,6 +2270,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 { @@ -3773,6 +3776,9 @@ impl OrmAiConsequentialToolCallService { .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| { @@ -3892,34 +3898,59 @@ impl OrmAiConsequentialToolCallService { && (descriptor.result_media.is_some() || classification >= DataClassification::Restricted) { - let origin = retained_origin.as_ref().ok_or(AiError::Forbidden)?; - Some( - Box::pin(self.application_tools.retain_disclosure_source( - &running_lease, - &descriptor, - origin, - &context, - &policy, - &model_output, - &disclosure_schema_fingerprint, - classification, - source_trust, - )) - .await?, - ) + 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 }; let retained_context = if retained.is_some() { - Some( - self.application_tools - .reopen_tool_result_source( - &running_lease, - tool_call_id, - crate::AiToolResultDisclosurePurpose::ToolResult, - ) - .await?, - ) + 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 }; diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index aa222e8a..4ea95fcc 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -458,6 +458,8 @@ impl OrmAiApplicationToolCallService { || 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" diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index e7f16be2..e29859d2 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -3394,6 +3394,15 @@ 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. diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index f20ff6ca..e88925ae 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -2589,9 +2589,10 @@ impl DynamicToolResponder { }) if call_id == call.call_id() && tool_id == call.tool_id().as_str() => output.clone(), _ => return Err(ProviderError::Rejected), }; - let images = if media_ceiling > 0 && failure.is_none() { + let application_result = matches!(&persisted, AiProviderDynamicToolOutcome::Application(_)); + let images = if media_ceiling > 0 && failure.is_none() && application_result { let AiProviderDynamicToolOutcome::Application(application) = &persisted else { - return Err(ProviderError::Rejected); + unreachable!() }; self.execution .tool_result_source_service() @@ -2649,7 +2650,10 @@ impl DynamicToolResponder { output, )? .with_images(images) - .map(|result| result.require_source_delivery(requires_source && failure.is_none())) + .map(|result| { + result + .require_source_delivery(requires_source && failure.is_none() && application_result) + }) } } @@ -3607,14 +3611,14 @@ impl AiProviderCallExecutor { lease: &AiRunLease, plan: AiProviderCallPlan, ) -> Result { - self.execute_inner( + Box::pin(self.execute_inner( Arc::new(Mutex::new(lease.clone())), plan, None, None, None, 0, - ) + )) .await } @@ -3644,8 +3648,7 @@ impl AiProviderCallExecutor { { return Err(AiError::Conflict); } - self.execute_inner(lease, plan, Some(execution), None, None, 0) - .await + Box::pin(self.execute_inner(lease, plan, Some(execution), None, None, 0)).await } /// Executes through one exact durable provider-session binding. @@ -3841,16 +3844,15 @@ impl AiProviderCallExecutor { session_service .require_cleanup(&claim, "retained_tool_source_withheld") .await?; - return self - .execute_inner( - lease_state, - self.withheld_tool_history_plan(&lease, plan).await?, - None, - None, - None, - 0, - ) - .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), }; @@ -3960,8 +3962,7 @@ impl AiProviderCallExecutor { let safe_plan = self .withheld_tool_history_plan(¤t_lease, withheld_plan) .await?; - self.execute_inner(lease_state, safe_plan, None, None, None, 0) - .await + Box::pin(self.execute_inner(lease_state, safe_plan, None, None, None, 0)).await } Err(error @ AiError::PreTransportBudgetDenied) => Err(error), Err(AiError::PreTransportProviderFailed) => { 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 index dc27f601..adc5ef4a 100644 --- 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 @@ -25,6 +25,7 @@ struct Custody { requests: Mutex>, delivered_images: AtomicUsize, delay_open: AtomicBool, + source_delivery_allowed: AtomicBool, } struct SourceProtector(Arc); #[async_trait] @@ -128,8 +129,7 @@ impl crate::AiToolResultMediaResolver for Custody { .cloned() .ok_or(AiError::NotFound)?; if capture.reference != *reference - || capture.origin.original_invocation() - != context.execution_provenance().original_invocation() + || &capture.origin != context.execution_provenance() || descriptor.fingerprint != capture.origin.tool_fingerprint() { return Err(AiError::Conflict); @@ -227,6 +227,9 @@ impl crate::AiProvider for MediaProvider { 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 } @@ -304,7 +307,9 @@ impl crate::AiProvider for MediaProvider { self.custody.allowed.store(false, Ordering::SeqCst); } responder.authorize_delivery(&result).await?; - if result.images().len() != 1 || result.images()[0].bytes().is_empty() { + if result.output()["status"] != "ApprovalPending" + && (result.images().len() != 1 || result.images()[0].bytes().is_empty()) + { return Err(ProviderError::Rejected); } Ok(Box::pin(futures::stream::iter([ @@ -423,6 +428,9 @@ async fn media_fixture_resolution( 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", @@ -626,6 +634,14 @@ async fn typed_pixels_charge_atomic_budget_and_reopen_without_effect_replay() { ); 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 @@ -660,6 +676,70 @@ async fn absent_image_capacity_and_unsupported_adapter_refuse_before_effect() { 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; @@ -858,6 +938,61 @@ async fn invalid_post_effect_media_is_saved_but_cannot_be_admitted_or_replayed() } 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; @@ -896,10 +1031,7 @@ impl AiCanonicalActionPreviewBuilder for MediaActionPreview { } } -async fn bind_original_media_session( - fixture: &Fixture, - provider: &mut AiProviderCallResult, -) -> Arc { +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) @@ -986,50 +1118,26 @@ async fn bind_original_media_session( ) .unwrap(), ); - let descriptor = AiProviderSessionDescriptor::new( - ProviderKind::OpenAiCompatible, - "mock-profile", - "mock-model", - "a".repeat(64), - "mock/v1", - "b".repeat(64), - ) - .unwrap(); - let cursor = fixture - .runtime - .create_empty_provider_session( - &ProviderKind::OpenAiCompatible, - &crate::AiProviderRunBinding::from_lease(&fixture.lease).unwrap(), - &descriptor, - &provider.request_snapshot, - ) - .await - .unwrap(); - provider.provider_session_claim = Some( - sessions - .bind_for_run( - &fixture.lease, - AiProviderSessionBindRequest::new(descriptor, cursor, "d".repeat(64), None) - .unwrap(), - ) - .await - .unwrap(), - ); sessions } #[tokio::test] async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_executing_again() { - approved_media_lifecycle(false).await; + approved_media_lifecycle(false, false).await; } #[tokio::test] async fn native_history_normal_deny_after_preflight_withholds_saved_effect_and_releases_unstarted_budget() { - approved_media_lifecycle(true).await; + approved_media_lifecycle(true, false).await; } -async fn approved_media_lifecycle(deny_history: bool) { +#[tokio::test] +async fn approved_effect_with_invalid_media_keeps_output_and_enters_recovery_without_replay() { + approved_media_lifecycle(false, true).await; +} + +async fn approved_media_lifecycle(deny_history: bool, invalid_metadata: bool) { let (fixture, custody, application) = media_fixture_mode(true, true).await; let descriptor = fixture .runtime @@ -1054,7 +1162,7 @@ async fn approved_media_lifecycle(deny_history: bool) { fingerprint: descriptor.fingerprint.clone(), enabled: true, }); - let offered = AiProviderCallPlan::new_with_supervised_tools( + let offered = AiProviderCallPlan::new_with_classified_tools( base.provider_kind, base.request, base.budget, @@ -1062,6 +1170,7 @@ async fn approved_media_lifecycle(deny_history: bool) { "approved-media-test", fixture.runtime.tool_catalog(), &policy, + &fixture.generated_target_policy, ) .unwrap(); let executor = AiProviderCallExecutor::new( @@ -1072,8 +1181,7 @@ async fn approved_media_lifecycle(deny_history: bool) { Arc::new(SystemClock), AiProviderCallLimits::new(64, 8192, 65536).unwrap(), ); - let mut provider = executor.execute(&fixture.lease, offered).await.unwrap(); - let sessions = bind_original_media_session(&fixture, &mut provider).await; + 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(), @@ -1106,27 +1214,37 @@ async fn approved_media_lifecycle(deny_history: bool) { ) .with_provider_session_service(sessions.clone()), ); - let prepared = consequential - .prepare_native_approval( - &fixture.lease, - &provider, - AiApplicationToolCallContext::new( - 0, - 0, - fixture.scope.clone(), - "approved-media-test", - provider.budget_reservation_id().0.to_string(), - ) - .unwrap(), + 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", + "a".repeat(64), + "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 receipt = consequential - .prepare_native_approval_receipt(prepared.lease(), &prepared, &preview_route()) - .await - .unwrap(); - let active = receipt.lease().clone(); - provider.native_control_receipts = vec![receipt]; + 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( @@ -1182,7 +1300,31 @@ async fn approved_media_lifecycle(deny_history: bool) { checkpoints.clone(), consequential, ); + custody + .invalid_image_metadata + .store(invalid_metadata, Ordering::SeqCst); let completed = resume.execute_claimed(&claim).await.unwrap(); + if invalid_metadata { + 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()); + 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( @@ -1197,6 +1339,14 @@ async fn approved_media_lifecycle(deny_history: bool) { 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() @@ -1301,6 +1451,14 @@ async fn approved_media_lifecycle(deny_history: bool) { 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); diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs index f861e085..96986406 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -129,6 +129,17 @@ impl AiProviderCallExecutor { 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| { @@ -164,8 +175,8 @@ impl AiProviderCallExecutor { "withheld:{}", crate::tools::canonical_json_digest(&idempotency) ); - // Original estimate/counters are preserved as an upper bound; no budget or general - // source boundary is widened by removing unavailable content or resetting context. + // Remaining ordinary estimates stay conservative; removing unavailable source media + // never widens any budget or general source boundary. Ok(plan) } } 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 c807a2bd..2f666610 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -1381,6 +1381,13 @@ impl AiProvider for AiCodexAppServerProvider { .supports_launch_profile(self.registration.launch_profile()) } + fn supports_tool_result_source_delivery(&self) -> bool { + self.registration.experimental_dynamic_tools() + && 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; 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 e966f098..301c36aa 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -336,6 +336,15 @@ impl AiRuntime { 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| { @@ -1101,6 +1110,12 @@ impl AiRuntime { { 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)?; @@ -1174,6 +1189,12 @@ impl AiRuntime { { 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)?; diff --git a/crates/graphql-orm-ai/src/tool_media/context.rs b/crates/graphql-orm-ai/src/tool_media/context.rs index 05cfb456..6b6c5ac3 100644 --- a/crates/graphql-orm-ai/src/tool_media/context.rs +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -85,6 +85,8 @@ impl AiToolResultDisclosureContext { || 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() { From 09b6f0d2416e72dbaf6e8887a728fdfd00db9987 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 14:49:36 +1100 Subject: [PATCH 16/52] Keep retained lifecycle state out of native coordinator stack frames --- crates/graphql-orm-ai/src/provider_calls.rs | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index e88925ae..e07b864e 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -3654,6 +3654,28 @@ impl AiProviderCallExecutor { /// Executes through one exact durable provider-session binding. #[allow(clippy::too_many_arguments)] pub async fn execute_with_provider_session( + &self, + lease_state: Arc>, + plan: AiProviderCallPlan, + session_plan: crate::AiProviderSessionTurnPlan, + 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, From 29dc3e6a74ef41bf629ce7384f4a8912a0981576 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:02:33 +1100 Subject: [PATCH 17/52] Fence Codex protected transport and retain completed output before access checks --- crates/graphql-orm-ai/CHANGELOG.md | 4 + crates/graphql-orm-ai/MIGRATION.md | 8 +- .../graphql-orm-ai/docs/tool-result-media.md | 12 ++- crates/graphql-orm-ai/src/orm_tools.rs | 28 +++--- crates/graphql-orm-ai/src/orm_tools/media.rs | 4 +- .../provider_calls/tests/tool_media_tests.rs | 83 ++++++++++++++-- .../src/providers/codex_app_server.rs | 95 +++++++++++++++++++ 7 files changed, 210 insertions(+), 24 deletions(-) diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 0e64e006..bbded4f6 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -44,6 +44,10 @@ checkpoint facts. For the current workspace baseline and active gates, use the - 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. +- 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. diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 5820e576..92db60bc 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -28,7 +28,13 @@ 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. +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 diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 3c102139..c97b5f82 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -77,7 +77,10 @@ static boundary on every disclosure. ## Durable results, provider delivery and budgets The application service protects and saves the actual bounded completed-effect -result before parsing descriptive image metadata or opening resources. Invalid +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. Owner cancellation also closes @@ -95,7 +98,12 @@ loader to the same runtime. Dynamic execution supplies the same owning service. Media-required tools fail adapter/policy/capacity preflight before effect admission. Adapters also default-deny source delivery for declared Restricted/media effects unless they implement the final current source guard; -the reviewed Codex adapter preserves that guard through its process queue. +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 diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index 9287fc87..37a99512 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -3863,20 +3863,6 @@ impl OrmAiConsequentialToolCallService { } }; let provider_response_id = call.provider_response_id.clone(); - if self - .application_tools - .current_access(&running_lease, &scope) - .await - .is_err() - { - return self - .mark_consequential_recovery( - &running_lease, - tool_call_id, - provider_response_id.clone(), - ) - .await; - } let output_bytes = match serde_json::to_vec(&model_output) { Ok(output) => output, Err(_) => { @@ -3930,6 +3916,20 @@ impl OrmAiConsequentialToolCallService { } else { None }; + if self + .application_tools + .current_access(&running_lease, &scope) + .await + .is_err() + { + return self + .mark_consequential_recovery( + &running_lease, + tool_call_id, + provider_response_id.clone(), + ) + .await; + } let retained_context = if retained.is_some() { let reopened = self .application_tools diff --git a/crates/graphql-orm-ai/src/orm_tools/media.rs b/crates/graphql-orm-ai/src/orm_tools/media.rs index 4ea95fcc..15fc4b14 100644 --- a/crates/graphql-orm-ai/src/orm_tools/media.rs +++ b/crates/graphql-orm-ai/src/orm_tools/media.rs @@ -84,7 +84,8 @@ impl OrmAiApplicationToolCallService { classification: DataClassification, trust: AiSourceTrust, ) -> Result { - self.current_access(lease, &call_context.scope).await?; + // 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, @@ -100,6 +101,7 @@ impl OrmAiApplicationToolCallService { 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, 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 index adc5ef4a..2c88391f 100644 --- 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 @@ -26,6 +26,39 @@ struct Custody { delivered_images: AtomicUsize, delay_open: AtomicBool, source_delivery_allowed: AtomicBool, + withdraw_access_after_effect: AtomicUsize, + access_withdrawn: AtomicBool, +} +#[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") + } + } } struct SourceProtector(Arc); #[async_trait] @@ -95,6 +128,10 @@ impl AuthenticatedGraphqlExecutor for PreviewExecutor { }, ); 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(), @@ -540,7 +577,7 @@ async fn media_fixture_resolution( principal: fixture.principal.clone(), resolutions: fixture.principal_resolutions.clone(), })) - .access_policy(Arc::new(AllowAccess)) + .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()))) @@ -1123,21 +1160,28 @@ async fn media_input_and_sessions(fixture: &Fixture) -> Arc, + guarded_tool_result_source_delivery: bool, capability_session_bindings: BTreeMap, identity: String, } @@ -464,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(), }; @@ -485,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 @@ -1376,6 +1391,8 @@ impl AiProvider for AiCodexAppServerProvider { 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()) @@ -1383,6 +1400,8 @@ impl AiProvider for AiCodexAppServerProvider { 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()) @@ -2021,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 @@ -2269,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, @@ -6845,6 +6877,11 @@ 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()) } @@ -7909,6 +7946,64 @@ 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_effect_preflight() { + 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.supports_custom_tools()); + assert!(!provider.supports_tool_result_source_delivery()); + assert!(!provider.supports_tool_result_images()); + let runtime = crate::AiRuntime::builder() + .provider(provider) + .unwrap() + .build() + .unwrap(); + let mut descriptor = crate::AiToolDescriptor::new("preview.read", "Read preview", crate::AiToolOperationKind::Query, "query Preview { Preview }", json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{},"additionalProperties":false})).unwrap(); + descriptor.maximum_classification = crate::DataClassification::Restricted; + assert!( + runtime + .preflight_tool_result_media(&descriptor, &ProviderKind::LocalHarness) + .is_err() + ); + descriptor.maximum_classification = crate::DataClassification::Public; + assert!( + runtime + .preflight_tool_result_media(&descriptor, &ProviderKind::LocalHarness) + .is_ok() + ); + 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"); From 0bfa4df51b3be5ba1701ce5bf515c7b0f39dcc4c Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:06:19 +1100 Subject: [PATCH 18/52] Use actual adapter capabilities in legacy factory regression --- crates/graphql-orm-ai/src/providers/codex_app_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92addb28..304e7438 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -7979,7 +7979,7 @@ pub(crate) mod tests { Arc::new(reviewed), pool(counters.clone(), 1, 2), )); - assert!(provider.supports_custom_tools()); + assert!(provider.capabilities().custom_tools); assert!(!provider.supports_tool_result_source_delivery()); assert!(!provider.supports_tool_result_images()); let runtime = crate::AiRuntime::builder() From 8251673692f26ee98c4d7eaedc0b2863400737ac Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:17:32 +1100 Subject: [PATCH 19/52] Bind complete media transport and retained executor provenance --- crates/graphql-orm-ai/CHANGELOG.md | 4 + .../graphql-orm-ai/docs/tool-result-media.md | 11 +- crates/graphql-orm-ai/src/execution.rs | 13 ++ .../src/orm_supervised_coordinator/native.rs | 37 +++-- crates/graphql-orm-ai/src/orm_tools.rs | 34 +++-- crates/graphql-orm-ai/src/provider.rs | 19 ++- crates/graphql-orm-ai/src/provider_calls.rs | 77 ++++++++++- .../provider_calls/tests/tool_media_tests.rs | 128 ++++++++++++------ .../src/provider_calls/tool_media.rs | 54 ++++++++ .../src/providers/codex_app_server.rs | 2 +- 10 files changed, 301 insertions(+), 78 deletions(-) diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index bbded4f6..e11dcb04 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -44,6 +44,10 @@ checkpoint facts. For the current workspace baseline and active gates, use the - 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. +- 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 diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index c97b5f82..93d6e757 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -60,7 +60,9 @@ 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. They remain fixed +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. @@ -107,7 +109,12 @@ 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. Unused +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 diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs index c9138640..21a818da 100644 --- a/crates/graphql-orm-ai/src/execution.rs +++ b/crates/graphql-orm-ai/src/execution.rs @@ -427,6 +427,19 @@ impl AiToolExecutionProvenance { 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. + 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; 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 c6f6b7bf..09278e1d 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator/native.rs @@ -130,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( @@ -148,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 37a99512..a745f87e 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -944,15 +944,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.execute_unapproved( + lease, + provider_result, + context, + route, + UnapprovedToolMode::ReadOnly, + )) + .await { Ok(AiConsequentialToolCallOutcome::Persisted(call)) => Ok(*call), Ok(AiConsequentialToolCallOutcome::RecoveryRequired { .. }) => Err(AiError::Conflict), @@ -1928,13 +1927,13 @@ impl OrmAiApplicationToolCallService { context: AiApplicationToolCallContext, route: AiToolResultEgressRoute, ) -> Result { - self.execute_unapproved( + Box::pin(self.execute_unapproved( lease, provider_result, context, route, UnapprovedToolMode::AutomaticMutation, - ) + )) .await } @@ -2352,6 +2351,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) @@ -2368,7 +2376,7 @@ impl OrmAiApplicationToolCallService { Err(_) => Err(error), }; } - let finalization: Result = async { + let finalization: Result = Box::pin(async { #[cfg(test)] self.inject_automatic_mutation_fault( mode, @@ -2604,7 +2612,7 @@ impl OrmAiApplicationToolCallService { lease: renewed, }, ))) - } + }) .await; match finalization { Ok(outcome) => Ok(outcome), diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index e29859d2..e06e1f24 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1641,14 +1641,31 @@ 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 transfers = self diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index e07b864e..83ad9ae6 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -27,6 +27,10 @@ use crate::{ }; mod tool_media; +use tool_media::{ + ordinary_attachment_bytes, ordinary_attachment_count, retained_image_egress_bytes, + validate_combined_media_batch, +}; mod tool_source_delivery; pub(crate) use tool_source_delivery::ToolSourceDelivery; @@ -4082,7 +4086,7 @@ impl AiProviderCallExecutor { } }; - let (mut context, historical_image_bytes) = match self + let (mut context, historical_image_bytes, effective_transfers) = match self .authorize_and_audit_transfers( &lease, &plan, @@ -4101,6 +4105,8 @@ impl AiProviderCallExecutor { } }; + plan.transfers = effective_transfers; + let mut current = match self .runtime .resolve_current_principal(lease.principal_reference()) @@ -4311,9 +4317,16 @@ impl AiProviderCallExecutor { let dynamic_responder = dynamic_execution.map(|execution| { Arc::new(DynamicToolResponder { runtime: self.runtime.clone(), - maximum_image_units: reservation.reserved().image_units, - allocated_image_units: Mutex::new(historical_image_units), - allocated_image_bytes: Mutex::new(historical_image_bytes), + 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, @@ -4950,7 +4963,7 @@ impl AiProviderCallExecutor { plan: &AiProviderCallPlan, budget: crate::AuthorizedBudgetReservation, native_session_claim: Option<&Arc>>, - ) -> Result<(ProviderRequestContext, u64), AiError> { + ) -> Result<(ProviderRequestContext, u64, Vec), AiError> { tokio::time::timeout( std::time::Duration::from_secs(30), Box::pin(self.authorize_and_audit_transfers_inner( @@ -4976,7 +4989,7 @@ impl AiProviderCallExecutor { plan: &AiProviderCallPlan, budget: crate::AuthorizedBudgetReservation, native_session_claim: Option<&Arc>>, - ) -> Result<(ProviderRequestContext, u64), AiError> { + ) -> Result<(ProviderRequestContext, u64, Vec), AiError> { let source_failure = |error| { if native_session_claim.is_some() { AiError::PreTransportToolSourceUnavailable @@ -5095,6 +5108,53 @@ impl AiProviderCallExecutor { } } } + // 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) @@ -5132,6 +5192,10 @@ impl AiProviderCallExecutor { }; 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)?; @@ -5155,6 +5219,7 @@ impl AiProviderCallExecutor { .with_retained_tool_result_images(retained_images) .map_err(source_failure)?, image_bytes, + effective_transfers, )) } 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 index 2c88391f..d718983f 100644 --- 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 @@ -328,48 +328,36 @@ impl crate::AiProvider for MediaProvider { async fn stream_with_dynamic_tools( &self, request: ModelRequest, - _: ProviderRequestContext, + context: ProviderRequestContext, responder: Arc, ) -> Result { - let definition = request.tools.first().ok_or(ProviderError::Rejected)?; - let call = ProviderDynamicToolCall::from_definition( - "preview-turn", - "preview-call", - definition, - json!({}), - ) - .unwrap(); - let result = responder.respond(call).await?; - if self.custody.revoke_before_delivery.load(Ordering::SeqCst) { - self.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()) - { - return Err(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()), - }), - ]))) + context.authorize_tool_source_delivery().await?; + let definition = request + .tools + .first() + .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; @@ -705,6 +693,46 @@ async fn typed_pixels_charge_atomic_budget_and_reopen_without_effect_replay() { 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(), + 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)] { @@ -1533,6 +1561,26 @@ async fn approved_media_lifecycle(deny_history: bool, invalid_metadata: bool, wi } 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); } } diff --git a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs index 96986406..3b2d07a7 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -180,3 +180,57 @@ impl AiProviderCallExecutor { 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/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 304e7438..8bc7f472 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -1447,7 +1447,7 @@ impl AiProvider for AiCodexAppServerProvider { .pool .supports_launch_profile(self.registration.launch_profile()), provider_retained_continuation: true, - image_input: dynamic_tools_available, + image_input: self.supports_tool_result_images(), local: true, reasoning_effort_profiles: self .registration From c865e1c085d39d27f16d310611459d2b8a5cbcff Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:19:28 +1100 Subject: [PATCH 20/52] Match owned attachment IDs and persistence feature bounds --- crates/graphql-orm-ai/src/execution.rs | 1 + .../graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs index 21a818da..5e6e342c 100644 --- a/crates/graphql-orm-ai/src/execution.rs +++ b/crates/graphql-orm-ai/src/execution.rs @@ -430,6 +430,7 @@ impl AiToolExecutionProvenance { // 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, 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 index d718983f..53e3a733 100644 --- 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 @@ -704,7 +704,7 @@ async fn ordinary_attachments_and_reopened_pixels_share_complete_request_limits( .unwrap(); let mut request = plan(&fixture).request; let attachment = ModelInputBlock::Attachment { - attachment_id: Uuid::new_v4(), + attachment_id: Uuid::new_v4().to_string(), mime: "image/png".into(), byte_count: images[0].byte_count(), sha256: images[0].reference().sha256().into(), From 3cfb4b3e5f193f214369ac0aa5888443874b901e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:22:49 +1100 Subject: [PATCH 21/52] Retain effective complete transport proof on mutable owning plan --- crates/graphql-orm-ai/src/provider_calls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 83ad9ae6..7ed0068e 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4009,7 +4009,7 @@ 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>>, From 8b9a7bb5980a4e0d76554853c1a2f93878a35257 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:38:02 +1100 Subject: [PATCH 22/52] Authorize native approved source wrappers and isolate effect future construction --- crates/graphql-orm-ai/CHANGELOG.md | 2 ++ .../graphql-orm-ai/docs/tool-result-media.md | 5 ++- .../src/orm_native_approvals.rs | 33 ++++++++++++++++--- crates/graphql-orm-ai/src/orm_tools.rs | 18 ++++++++-- .../provider_calls/tests/tool_media_tests.rs | 7 ++-- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index e11dcb04..4940a6da 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -44,6 +44,8 @@ checkpoint facts. For the current workspace baseline and active gates, use the - 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 diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 93d6e757..40543019 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -85,7 +85,10 @@ 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. Owner cancellation also closes +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 diff --git a/crates/graphql-orm-ai/src/orm_native_approvals.rs b/crates/graphql-orm-ai/src/orm_native_approvals.rs index 44a45d8f..361c9a74 100644 --- a/crates/graphql-orm-ai/src/orm_native_approvals.rs +++ b/crates/graphql-orm-ai/src/orm_native_approvals.rs @@ -427,10 +427,35 @@ impl crate::OrmAiConsequentialToolCallService { ) .map_err(|_| AiError::PersistenceFailed)?, ); - 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)?; let plaintext = serde_json::json!({ diff --git a/crates/graphql-orm-ai/src/orm_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index a745f87e..c6e4fa87 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -944,7 +944,7 @@ impl OrmAiApplicationToolCallService { ) -> Result { let fallback_context = context.clone(); let fallback_route = route.clone(); - match Box::pin(self.execute_unapproved( + match Box::pin(self.boxed_unapproved( lease, provider_result, context, @@ -1927,7 +1927,7 @@ impl OrmAiApplicationToolCallService { context: AiApplicationToolCallContext, route: AiToolResultEgressRoute, ) -> Result { - Box::pin(self.execute_unapproved( + Box::pin(self.boxed_unapproved( lease, provider_result, context, @@ -1937,6 +1937,20 @@ impl OrmAiApplicationToolCallService { .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, 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 index 53e3a733..465c86ee 100644 --- 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 @@ -1399,10 +1399,13 @@ async fn approved_media_lifecycle(deny_history: bool, invalid_metadata: bool, wi let captures = custody.captures.lock().await; let capture = captures.values().next().unwrap(); assert_eq!( - value["Preview"]["Reference"], + value["data"]["Preview"]["Reference"], json!(capture.reference.reference()) ); - assert_eq!(value["Preview"]["Sha256"], capture.reference.sha256()); + 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); From 45c49e8d6ee37f3990eb54844396ea597e49a2a5 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 15:45:22 +1100 Subject: [PATCH 23/52] Scope protected-source guard to actual approved continuation --- .../src/orm_native_approvals.rs | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_native_approvals.rs b/crates/graphql-orm-ai/src/orm_native_approvals.rs index 361c9a74..cfb54835 100644 --- a/crates/graphql-orm-ai/src/orm_native_approvals.rs +++ b/crates/graphql-orm-ai/src/orm_native_approvals.rs @@ -427,35 +427,10 @@ impl crate::OrmAiConsequentialToolCallService { ) .map_err(|_| AiError::PersistenceFailed)?, ); - 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? - }; + let decision = tools + .runtime() + .authorize_egress(lease.principal_reference(), &manifest) + .await?; tools.egress_audit().record(&manifest, &decision).await?; decision.authorize(&manifest)?; let plaintext = serde_json::json!({ @@ -605,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(( From 982d421a3e45240fe0e5746c7ccef711efe3c3ae Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 16:00:21 +1100 Subject: [PATCH 24/52] Preflight exact protected tool outputs before legacy factory launch --- .../graphql-orm-ai/docs/tool-result-media.md | 4 +- .../provider_calls/tests/tool_media_tests.rs | 129 +++++++++++++++++- .../src/providers/codex_app_server.rs | 20 +-- crates/graphql-orm-ai/src/runtime.rs | 15 ++ 4 files changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 40543019..3320bbd9 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -101,7 +101,9 @@ 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. Adapters also default-deny source delivery for declared +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`. 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 index 465c86ee..9b2b68bf 100644 --- 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 @@ -28,6 +28,8 @@ struct Custody { 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 { @@ -60,6 +62,53 @@ impl crate::AiAccessPolicy for Custody { } } } +#[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 { @@ -559,7 +608,7 @@ async fn media_fixture_resolution( schema_fingerprint: "schema-v1".into(), }) .unwrap(); - let runtime = AiRuntime::builder() + let builder = AiRuntime::builder() .maximum_tool_maturity(ToolMaturity::SupervisedWrite) .principal_resolver(Arc::new(CountingResolver { principal: fixture.principal.clone(), @@ -583,9 +632,12 @@ async fn media_fixture_resolution( custody: custody.clone(), supported, })) - .unwrap() - .build() .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 { @@ -742,6 +794,77 @@ async fn absent_image_capacity_and_unsupported_adapter_refuse_before_effect() { } } +#[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; + 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; 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 8bc7f472..d8e031a1 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -7970,7 +7970,7 @@ pub(crate) mod tests { } #[test] - fn legacy_dynamic_factory_refuses_protected_effect_preflight() { + fn legacy_dynamic_factory_refuses_protected_source_capability() { let counters = Arc::new(Counters::new()); let reviewed = (*dynamic_registration("1.0.0")) .clone() @@ -7982,24 +7982,6 @@ pub(crate) mod tests { assert!(provider.capabilities().custom_tools); assert!(!provider.supports_tool_result_source_delivery()); assert!(!provider.supports_tool_result_images()); - let runtime = crate::AiRuntime::builder() - .provider(provider) - .unwrap() - .build() - .unwrap(); - let mut descriptor = crate::AiToolDescriptor::new("preview.read", "Read preview", crate::AiToolOperationKind::Query, "query Preview { Preview }", json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{},"additionalProperties":false})).unwrap(); - descriptor.maximum_classification = crate::DataClassification::Restricted; - assert!( - runtime - .preflight_tool_result_media(&descriptor, &ProviderKind::LocalHarness) - .is_err() - ); - descriptor.maximum_classification = crate::DataClassification::Public; - assert!( - runtime - .preflight_tool_result_media(&descriptor, &ProviderKind::LocalHarness) - .is_ok() - ); assert_eq!(counters.launches.load(Ordering::SeqCst), 0); assert_eq!(counters.turns.load(Ordering::SeqCst), 0); } diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs index 301c36aa..55e5b096 100644 --- a/crates/graphql-orm-ai/src/runtime.rs +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -1184,6 +1184,21 @@ 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()) { From af7d39e6feecfc0088a8794dacc273f5b525df9b Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 16:23:54 +1100 Subject: [PATCH 25/52] Use a fresh valid legacy ordinary transport plan in preflight regression --- .../graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs | 2 ++ 1 file changed, 2 insertions(+) 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 index 9b2b68bf..3168a4ca 100644 --- 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 @@ -849,6 +849,8 @@ async fn legacy_codex_dynamic_factory_refuses_protected_tool_before_launch_or_ef 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(); } From e92b55d8e2f96377d563cf31269d055d8b4daf81 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 16:54:17 +1100 Subject: [PATCH 26/52] Expose owning source matcher and keep image admission strict lint clean --- .../graphql-orm-ai/docs/tool-result-media.md | 6 ++ crates/graphql-orm-ai/src/provider_calls.rs | 71 +++++++++---------- .../provider_calls/tests/tool_media_tests.rs | 23 ++++++ .../graphql-orm-ai/src/tool_media/context.rs | 27 ++++--- 4 files changed, 82 insertions(+), 45 deletions(-) diff --git a/crates/graphql-orm-ai/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 3320bbd9..181aa4d6 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -40,6 +40,12 @@ 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 diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 7ed0068e..a1737366 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -5058,45 +5058,42 @@ impl AiProviderCallExecutor { } if manifest.capability == AiEgressCapability::ModelInference && !context.images().is_empty() + && image_sources.insert(context.source_reference()) { - if 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); + 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() 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 index 3168a4ca..3d8faf86 100644 --- 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 @@ -724,6 +724,29 @@ async fn typed_pixels_charge_atomic_budget_and_reopen_without_effect_replay() { .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) diff --git a/crates/graphql-orm-ai/src/tool_media/context.rs b/crates/graphql-orm-ai/src/tool_media/context.rs index 6b6c5ac3..c67faf72 100644 --- a/crates/graphql-orm-ai/src/tool_media/context.rs +++ b/crates/graphql-orm-ai/src/tool_media/context.rs @@ -195,6 +195,22 @@ impl AiToolResultDisclosureContext { 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 @@ -206,14 +222,9 @@ pub(crate) fn ordinary_source_component( let mut ordinary = manifest.clone(); let mut removed_images = 0_u32; ordinary.sources.retain(|source| { - let matched = contexts.iter().any(|context| { - 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) - })) - }); + 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); } From 42b2279d161cf6c2f0e360e218b081f5c09b9fec Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 17:07:10 +1100 Subject: [PATCH 27/52] test(ai): fix typed media scoped lint diagnostics --- .../provider_calls/tests/tool_media_tests.rs | 161 ++++++++---------- .../src/tool_media/resolved/tests.rs | 109 ++++++------ 2 files changed, 120 insertions(+), 150 deletions(-) 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 index 3d8faf86..03ddd8aa 100644 --- 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 @@ -519,14 +519,14 @@ async fn media_fixture_resolution( let disclosure = AiDisclosureSchema::new( "preview-disclosure-v1", AiDisclosureShape::object( - rule.clone(), + rule, [( "Preview".into(), AiDisclosureShape::object( - rule.clone(), + rule, fields .into_iter() - .map(|name| (name.into(), AiDisclosureShape::scalar(rule.clone()))), + .map(|name| (name.into(), AiDisclosureShape::scalar(rule))), ), )], ), @@ -796,15 +796,13 @@ async fn ordinary_attachments_and_reopened_pixels_share_complete_request_limits( request .input .extend([attachment.clone(), attachment.clone(), attachment]); - assert!( - validate_combined_media_batch( - &request, - &images, - AiProviderAttachmentResolutionLimits::default(), - 5 - ) - .is_err() - ); + assert!(validate_combined_media_batch( + &request, + &images, + AiProviderAttachmentResolutionLimits::default(), + 5 + ) + .is_err()); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } @@ -940,16 +938,14 @@ async fn absent_or_changed_retained_effect_fence_is_refused_without_reexecution( ) .await .unwrap(); - assert!( - service - .reopen_tool_result_source( - call.lease(), - call.id(), - crate::AiToolResultDisclosurePurpose::Continuation - ) - .await - .is_err() - ); + assert!(service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err()); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } } @@ -1002,19 +998,17 @@ async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { 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() - ); + 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() @@ -1031,16 +1025,14 @@ async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { ) .await .unwrap(); - assert!( - service - .reopen_tool_result_source( - call.lease(), - call.id(), - crate::AiToolResultDisclosurePurpose::History - ) - .await - .is_err() - ); + assert!(service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::History + ) + .await + .is_err()); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } @@ -1127,24 +1119,20 @@ async fn invalid_post_effect_media_is_saved_but_cannot_be_admitted_or_replayed() 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!(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); @@ -1318,7 +1306,7 @@ async fn media_input_and_sessions(fixture: &Fixture) -> Arc Arc Date: Tue, 13 Oct 2026 17:07:43 +1100 Subject: [PATCH 28/52] test(ai): preserve edition 2024 fixture formatting --- .../provider_calls/tests/tool_media_tests.rs | 150 ++++++++++-------- .../src/tool_media/resolved/tests.rs | 106 +++++++------ 2 files changed, 143 insertions(+), 113 deletions(-) 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 index 03ddd8aa..a3f9f1cf 100644 --- 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 @@ -796,13 +796,15 @@ async fn ordinary_attachments_and_reopened_pixels_share_complete_request_limits( request .input .extend([attachment.clone(), attachment.clone(), attachment]); - assert!(validate_combined_media_batch( - &request, - &images, - AiProviderAttachmentResolutionLimits::default(), - 5 - ) - .is_err()); + assert!( + validate_combined_media_batch( + &request, + &images, + AiProviderAttachmentResolutionLimits::default(), + 5 + ) + .is_err() + ); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } @@ -938,14 +940,16 @@ async fn absent_or_changed_retained_effect_fence_is_refused_without_reexecution( ) .await .unwrap(); - assert!(service - .reopen_tool_result_source( - call.lease(), - call.id(), - crate::AiToolResultDisclosurePurpose::Continuation - ) - .await - .is_err()); + assert!( + service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err() + ); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } } @@ -998,17 +1002,19 @@ async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { 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()); + 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() @@ -1025,14 +1031,16 @@ async fn retained_origin_body_and_mixed_source_boundary_cannot_be_forged() { ) .await .unwrap(); - assert!(service - .reopen_tool_result_source( - call.lease(), - call.id(), - crate::AiToolResultDisclosurePurpose::History - ) - .await - .is_err()); + assert!( + service + .reopen_tool_result_source( + call.lease(), + call.id(), + crate::AiToolResultDisclosurePurpose::History + ) + .await + .is_err() + ); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } @@ -1119,20 +1127,24 @@ async fn invalid_post_effect_media_is_saved_but_cannot_be_admitted_or_replayed() 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!( + 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); @@ -1327,8 +1339,8 @@ async fn approved_media_effect_reopens_under_new_lease_without_consuming_or_exec } #[tokio::test] -async fn native_history_normal_deny_after_preflight_withholds_saved_effect_and_releases_unstarted_budget( -) { +async fn native_history_normal_deny_after_preflight_withholds_saved_effect_and_releases_unstarted_budget() + { approved_media_lifecycle(true, false, 0).await; } @@ -1544,11 +1556,13 @@ async fn approved_media_lifecycle(deny_history: bool, invalid_metadata: bool, wi 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()); + 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 @@ -1744,9 +1758,11 @@ async fn ordinary_text_with_zero_image_budget_does_not_offer_or_execute_media() 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()); + assert!( + preview_turn(&fixture, application.clone(), 4) + .await + .is_err() + ); let call = AiToolCallRecord::query(fixture.database.pool()) .limit(1) .fetch_one() @@ -1785,13 +1801,15 @@ async fn owner_cancellation_closes_source_retained_call_and_preserves_effect_cus 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!( + application + .reopen_tool_result_source( + &fixture.lease, + AiToolCallId(call.id), + crate::AiToolResultDisclosurePurpose::Continuation + ) + .await + .is_err() + ); assert_eq!(custody.effects.load(Ordering::SeqCst), 1); } diff --git a/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs index cfb35b25..7d29ecef 100644 --- a/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs +++ b/crates/graphql-orm-ai/src/tool_media/resolved/tests.rs @@ -36,13 +36,15 @@ fn genuine_pixels_are_decoded_and_bytes_are_redacted() { 1500, ) .unwrap(); - assert!(image - .validate_policy( - "preview-v1", - AiToolResultMediaLimits::new(1, 4096, 10, 100, 1000, 100).unwrap(), - 2000 - ) - .is_err()); + 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() { @@ -61,12 +63,14 @@ fn wrong_digest_dimensions_mime_and_resource_are_rejected() { "{field}" ); } - assert!(AiResolvedToolImage::new( - reference.clone(), - AiToolMediaResourceId::new("preview-v1", Uuid::new_v4()).unwrap(), - bytes - ) - .is_err()); + 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() { @@ -76,27 +80,34 @@ fn actual_decode_and_narrow_policy_limits_are_required() { 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()); + 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() { @@ -111,14 +122,16 @@ async fn bounded_decode_preserves_exact_immutable_payload() { .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()); + assert!( + AiResolvedToolImage::decode_bounded( + reference.clone(), + resource(&reference), + bytes, + AiToolResultMediaLimits::new(1, 4096, 1, 100, 1000, 5000).unwrap() + ) + .await + .is_err() + ); } #[test] @@ -131,11 +144,10 @@ fn two_genuine_pngs_must_fit_the_complete_result_pixel_budget() { 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()); + 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( From 67927be2db171140b98dc5ffbdc5ea600afd4dc4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 17:29:29 +1100 Subject: [PATCH 29/52] feat(ai): checkpoint fenced static offering continuation --- crates/graphql-orm-ai/CHANGELOG.md | 7 + crates/graphql-orm-ai/MIGRATION.md | 14 + .../graphql-orm-ai/docs/tool-result-media.md | 40 ++ crates/graphql-orm-ai/src/agent_loop.rs | 115 ++++- .../graphql-orm-ai/src/capability_delivery.rs | 228 +++++++++- .../src/orm_checkpoints/native.rs | 19 +- crates/graphql-orm-ai/src/orm_coordinator.rs | 2 + .../src/orm_provider_session.rs | 75 +++- .../orm_provider_session/static_offering.rs | 341 ++++++++++++++ .../src/orm_session_retention.rs | 1 + .../src/orm_supervised_coordinator.rs | 134 ++++++ crates/graphql-orm-ai/src/orm_tools.rs | 1 + .../src/orm_tools/task_context.rs | 172 +++++++ crates/graphql-orm-ai/src/persistence.rs | 2 + crates/graphql-orm-ai/src/provider.rs | 54 ++- crates/graphql-orm-ai/src/provider_calls.rs | 221 ++++++++- .../src/provider_calls/static_offering.rs | 419 ++++++++++++++++++ .../provider_calls/tests/tool_media_tests.rs | 115 +++++ .../provider_calls/tool_source_delivery.rs | 23 +- crates/graphql-orm-ai/src/provider_session.rs | 124 ++++++ 20 files changed, 2076 insertions(+), 31 deletions(-) create mode 100644 crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs create mode 100644 crates/graphql-orm-ai/src/orm_tools/task_context.rs create mode 100644 crates/graphql-orm-ai/src/provider_calls/static_offering.rs diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 4940a6da..e4cec7e5 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -29,6 +29,13 @@ checkpoint facts. For the current workspace baseline and active gates, use the ## [Unreleased] +- 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. + + ### Added - Fingerprint-bound protected tool-result JPEG/PNG media, trusted owner resolvers, diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 92db60bc..525488cd 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -5781,3 +5781,17 @@ 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. +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/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index 181aa4d6..ddbe5773 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -171,3 +171,43 @@ 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. Another denial remains terminal. Saved historical content that is +no longer admitted is withheld truthfully; 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..a6661c30 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 @@ -483,6 +589,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 +722,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 +870,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..020bd3f8 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,79 @@ 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(()) + } + + pub(crate) fn same_shared_offering(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.tools, &other.tools) && self.session_binding == other.session_binding + } + + /// 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 +2356,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 +2931,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 +3360,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 +3380,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 +3422,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/orm_checkpoints/native.rs b/crates/graphql-orm-ai/src/orm_checkpoints/native.rs index 83986c04..a449ced5 100644 --- a/crates/graphql-orm-ai/src/orm_checkpoints/native.rs +++ b/crates/graphql-orm-ai/src/orm_checkpoints/native.rs @@ -1457,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) @@ -1473,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_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index 7df15f4a..7d3e2760 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -3032,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, }) } @@ -4496,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_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index fe12cc40..ebf9afcb 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, @@ -670,6 +708,7 @@ mod service { transcript_fingerprint: planned .transcript_fingerprint() .to_owned(), + static_offering_reset: None, }, ))); } @@ -689,8 +728,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 +856,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 +921,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 +942,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 +986,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 @@ -1025,6 +1083,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), @@ -3453,6 +3514,7 @@ mod service { cleanup_generation: record.cleanup_generation, cleanup_expires_at: parse_time(record.cleanup_lease_expires_at)?, row_version: record.row_version, + installed_offering_fingerprint: record.installed_offering_fingerprint.clone(), }) } @@ -3518,6 +3580,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 +4509,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), 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..95ec380a --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs @@ -0,0 +1,341 @@ +// 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 + .find_by_id::(&evidence.checkpoint_id) + .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_session_retention.rs b/crates/graphql-orm-ai/src/orm_session_retention.rs index f3363679..b7ef38d2 100644 --- a/crates/graphql-orm-ai/src/orm_session_retention.rs +++ b/crates/graphql-orm-ai/src/orm_session_retention.rs @@ -8718,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_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index d57c0273..07a438b9 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -280,6 +280,23 @@ 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. + 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 +1002,30 @@ 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 { + 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 +1034,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 +1208,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 +1335,83 @@ 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 replacement = match self + .planner + .replan_unstarted_static_offering( + &lease, + guard.provider_turns(), + context.clone(), + ) + .await + { + 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") @@ -2264,6 +2397,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_tools.rs b/crates/graphql-orm-ai/src/orm_tools.rs index c6e4fa87..99bebdc8 100644 --- a/crates/graphql-orm-ai/src/orm_tools.rs +++ b/crates/graphql-orm-ai/src/orm_tools.rs @@ -4,6 +4,7 @@ mod media; mod native_preflight; +mod task_context; pub(crate) use native_preflight::{ NativePreflightRefusal, NativeToolClassification, native_preflight_failure_code, }; 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..9b8b8918 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_tools/task_context.rs @@ -0,0 +1,172 @@ +//! 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, 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 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 64c7ee6b..456c4e11 100644 --- a/crates/graphql-orm-ai/src/persistence.rs +++ b/crates/graphql-orm-ai/src/persistence.rs @@ -754,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)] diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index e06e1f24..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) } @@ -1361,6 +1367,8 @@ pub struct ProviderRequestContext { transfers: Vec, resolved_attachments: Vec, retained_tool_images: Vec, + static_offering_reset_receipt: + Option>, #[cfg(any(feature = "sqlite", feature = "postgres"))] tool_source_delivery: Option>, } @@ -1390,6 +1398,7 @@ impl ProviderRequestContext { 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, } @@ -1522,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 @@ -1667,7 +1696,28 @@ impl ProviderRequestContext { attachment_count, 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() diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index a1737366..09785b00 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -31,6 +31,8 @@ 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; @@ -256,6 +258,12 @@ 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>, + static_offering_reset_receipt: + Option>, } #[derive(Clone, Debug)] @@ -363,6 +371,11 @@ 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, + static_offering_reset_receipt: None, }) } @@ -810,8 +823,11 @@ impl AiProviderCallPlan { static_policy: &AiToolPolicySet, generated_targets: &crate::AiGeneratedGraphqlTargetPolicySet, ) -> Result { + 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_transfers(&mut request)?); - Self::new_with_bound_capability_surface( + let mut plan = Self::new_with_bound_capability_surface( provider_kind, request, budget, @@ -822,7 +838,22 @@ impl AiProviderCallPlan { static_policy, generated_targets, true, - ) + )?; + plan.native_reset_evidence = native_reset_evidence; + if let Some(context) = &task_context { + let inference = plan + .transfers + .iter_mut() + .find(|manifest| manifest.capability == AiEgressCapability::ModelInference) + .ok_or(AiError::EgressDenied)?; + inference.sources.extend(context.sources.iter().cloned()); + 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 @@ -977,6 +1008,7 @@ impl AiProviderCallPlan { )?; plan.tool_rule_bindings = bindings; plan.classified_native = classified; + plan.capability_offering = surface.freeze_offering()?; Ok(plan) } @@ -1688,6 +1720,11 @@ 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, + static_offering_reset_receipt: None, tool_rule_bindings: vec![AiPlanToolRuleBinding { fingerprint: "test-fingerprint".to_owned(), maturity: ToolMaturity::ReadOnly, @@ -1910,6 +1947,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. @@ -2486,6 +2525,7 @@ impl DynamicToolResponder { Some(claim) => Some(claim.lock().await.clone()), None => None, }, + static_offering_reset_receipt: None, }; let requires_source = self .runtime @@ -2662,6 +2702,28 @@ impl DynamicToolResponder { } impl AiProviderCallResult { + 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, @@ -3150,6 +3212,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } @@ -3258,6 +3321,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } @@ -3312,6 +3376,7 @@ impl AiProviderCallResult { interactive_tool_results: Vec::new(), native_control_receipts: Vec::new(), provider_session_claim: None, + static_offering_reset_receipt: None, } } } @@ -3367,6 +3432,7 @@ pub struct AiProviderCallExecutor { 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 @@ -3447,6 +3513,7 @@ impl AiProviderCallExecutor { attachment_limits: AiProviderAttachmentResolutionLimits::default(), failure_diagnostic_sink: None, tool_result_source_service: None, + static_offering_deletion: None, } } @@ -3469,6 +3536,20 @@ impl AiProviderCallExecutor { 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, @@ -3687,6 +3768,9 @@ impl AiProviderCallExecutor { 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() @@ -3707,7 +3791,14 @@ 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?; + } + let (mut claim, mut newly_bound_cursor) = match session_service .disposition_for_run(&lease, &session_plan) .await? { @@ -3759,7 +3850,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) => { @@ -3802,7 +3894,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 @@ -3846,6 +3939,24 @@ 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) = 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 @@ -3855,6 +3966,37 @@ 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 = 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; @@ -3990,7 +4132,26 @@ impl AiProviderCallExecutor { .await?; Box::pin(self.execute_inner(lease_state, safe_plan, None, None, None, 0)).await } - Err(error @ AiError::PreTransportBudgetDenied) => Err(error), + 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. + 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") @@ -4015,7 +4176,17 @@ impl AiProviderCallExecutor { 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() @@ -4220,11 +4391,22 @@ impl AiProviderCallExecutor { } }; } - if plan.transfers.iter().any(|manifest| { - manifest.sources.iter().any(|source| { - source.kind == "application_tool_result" && source.reference.starts_with("v2:") + 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() @@ -4235,6 +4417,7 @@ impl AiProviderCallExecutor { lease_state.clone(), &plan.transfers, reservation.reserved().image_units, + plan.task_context.clone(), ) }) { Ok(delivery) => delivery, @@ -4846,6 +5029,7 @@ impl AiProviderCallExecutor { interactive_tool_results, native_control_receipts, provider_session_claim: None, + static_offering_reset_receipt: plan.static_offering_reset_receipt, }) } @@ -5033,6 +5217,18 @@ impl AiProviderCallExecutor { 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()) { @@ -5053,7 +5249,7 @@ impl AiProviderCallExecutor { } else { false }; - if !exact_output && !exact_retained_cursor { + if !exact_output && !exact_native_outcome && !exact_retained_cursor { return Err(AiError::EgressDenied); } if manifest.capability == AiEgressCapability::ModelInference @@ -11710,6 +11906,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)), @@ -13970,6 +14167,7 @@ mod tests { maximum_tool_calls, allows_native_control: false, provider_session_claim: None, + static_offering_reset_receipt: None, callbacks: Mutex::new(()), calls: Mutex::new(Vec::new()), results: Mutex::new(Vec::new()), @@ -14126,6 +14324,7 @@ mod tests { maximum_tool_calls: 8, allows_native_control: false, provider_session_claim: None, + static_offering_reset_receipt: None, callbacks: Mutex::new(()), calls: Mutex::new(Vec::new()), results: Mutex::new(Vec::new()), 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..cd8ffcb8 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs @@ -0,0 +1,419 @@ +//! 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?; + if plan.request.native_approved_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 = 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(), + cleanup_generation, + offering_fingerprint: desired_offering_fingerprint, + outcome_hash: evidence.outcome_hash.clone(), + }, + )); + Ok((claim, cursor)) + } + + 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 + } + + /// 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) 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.same_shared_offering(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/tool_media_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs index a3f9f1cf..49776d63 100644 --- 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 @@ -1813,3 +1813,118 @@ async fn owner_cancellation_closes_source_retained_call_and_preserves_effect_cus ); 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); +} 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 index 0a4b6482..04f03b30 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_source_delivery.rs @@ -6,6 +6,7 @@ pub(crate) struct ToolSourceDelivery { 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 { @@ -18,13 +19,17 @@ impl ToolSourceDelivery { lease: Arc>, manifests: &[AiEgressManifest], maximum_image_units: u64, + task_context: Option>, ) -> Result>, AiError> { let manifests = manifests .iter() .filter(|manifest| { - manifest.sources.iter().any(|source| { - source.kind == "application_tool_result" && source.reference.starts_with("v2:") - }) + (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::>(); @@ -47,6 +52,7 @@ impl ToolSourceDelivery { lease, manifests, maximum_image_units, + task_context, }))) } } @@ -58,6 +64,17 @@ impl ToolSourceDelivery { .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; diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index b2144043..080f47cb 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. @@ -419,6 +420,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 +440,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 +455,14 @@ impl AiProviderSessionBindRequest { AiProviderSessionCursor, String, Option, + Option, ) { ( self.descriptor, self.cursor, self.transcript_fingerprint, self.provider_expires_at, + self.installed_offering_fingerprint, ) } } @@ -483,6 +495,63 @@ 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)] +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) 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)] +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 +580,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 +1408,60 @@ 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. + 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 From a6bb44480aca53aed8515b84ac01ff05a986547a Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:00:15 +1100 Subject: [PATCH 30/52] feat(ai): preserve withheld native task context through verified cleanup --- crates/graphql-orm-ai/src/agent_loop.rs | 12 + .../graphql-orm-ai/src/capability_delivery.rs | 4 - .../src/orm_supervised_coordinator.rs | 18 +- crates/graphql-orm-ai/src/provider_calls.rs | 35 +- .../src/provider_calls/static_offering.rs | 102 ++++- .../tests/native_coordinator_tests.rs | 2 +- .../provider_calls/tests/tool_media_tests.rs | 351 +++++++++++++++++- .../src/provider_calls/tool_media.rs | 7 +- crates/graphql-orm-ai/src/provider_session.rs | 1 + .../src/providers/codex_app_server.rs | 80 ++++ 10 files changed, 583 insertions(+), 29 deletions(-) diff --git a/crates/graphql-orm-ai/src/agent_loop.rs b/crates/graphql-orm-ai/src/agent_loop.rs index a6661c30..76fba9f8 100644 --- a/crates/graphql-orm-ai/src/agent_loop.rs +++ b/crates/graphql-orm-ai/src/agent_loop.rs @@ -568,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); diff --git a/crates/graphql-orm-ai/src/capability_delivery.rs b/crates/graphql-orm-ai/src/capability_delivery.rs index 020bd3f8..24b77010 100644 --- a/crates/graphql-orm-ai/src/capability_delivery.rs +++ b/crates/graphql-orm-ai/src/capability_delivery.rs @@ -2243,10 +2243,6 @@ impl AiCapabilityDeliveryTurn { Ok(()) } - pub(crate) fn same_shared_offering(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.tools, &other.tools) && self.session_binding == other.session_binding - } - /// Selects the static bootstrap definitions offered on the next provider turn. /// /// Only fingerprints in the original immutable session binding may be selected. The diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index 07a438b9..e18ca94d 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -1716,12 +1716,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, diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 09785b00..dee5296e 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -262,6 +262,7 @@ pub struct AiProviderCallPlan { native_reset_evidence: Option>, task_context: Option>, replan_continuation: Option>, + task_context_only: bool, static_offering_reset_receipt: Option>, } @@ -375,6 +376,7 @@ impl AiProviderCallPlan { native_reset_evidence: None, task_context: None, replan_continuation: None, + task_context_only: false, static_offering_reset_receipt: None, }) } @@ -826,7 +828,7 @@ impl AiProviderCallPlan { 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_transfers(&mut request)?); + transfers.extend(continuation.apply_with_bound_task_context(&mut request)?); let mut plan = Self::new_with_bound_capability_surface( provider_kind, request, @@ -1724,6 +1726,7 @@ impl AiProviderCallPlan { 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(), @@ -2702,6 +2705,12 @@ impl DynamicToolResponder { } 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() @@ -4009,6 +4018,17 @@ impl AiProviderCallExecutor { | AiError::NotFound | AiError::Conflict, ) => { + if plan.native_reset_evidence.is_some() && plan.task_context.is_some() { + return 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?; @@ -4120,6 +4140,19 @@ impl AiProviderCallExecutor { Ok(result) } Err(AiError::PreTransportToolSourceUnavailable) => { + if withheld_plan.native_reset_evidence.is_some() + && withheld_plan.task_context.is_some() + { + return self + .execute_withheld_native_task_context( + lease_state, + withheld_plan, + &session_plan, + &session_service, + ¤t_claim, + ) + .await; + } session_service .require_cleanup( ¤t_claim, diff --git a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs index cd8ffcb8..873370b9 100644 --- a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs +++ b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs @@ -41,8 +41,20 @@ impl AiProviderCallExecutor { sources .validate_task_continuation_context(lease, &plan.budget.scope, context) .await?; - if plan.request.native_approved_outcome_hash().as_deref() - != Some(evidence.outcome_hash.as_str()) + 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() @@ -51,12 +63,18 @@ impl AiProviderCallExecutor { { return Err(AiError::Conflict); } - let original_continuation_reference = plan - .request - .continuation - .as_ref() - .and_then(|continuation| continuation.chain_reference(&plan.request.input)) - .ok_or(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(); @@ -202,6 +220,7 @@ impl AiProviderCallExecutor { 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(), @@ -210,6 +229,64 @@ impl AiProviderCallExecutor { 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, @@ -309,6 +386,13 @@ impl AiUnstartedStaticOfferingReplan { &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 @@ -401,7 +485,7 @@ impl AiUnstartedStaticOfferingReplan { || old.correlation_id != next.correlation_id || !new_tools.is_subset(&old_tools) || new_tools == old_tools - || !self.delivery.same_shared_offering(delivery) + || !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() 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 bbe1ed65..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 @@ -665,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/tool_media_tests.rs b/crates/graphql-orm-ai/src/provider_calls/tests/tool_media_tests.rs index 49776d63..3e3352ea 100644 --- 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 @@ -14,6 +14,7 @@ struct Capture { struct Custody { captures: Mutex>, effects: AtomicUsize, + created_threads: AtomicUsize, opens: AtomicUsize, allowed: AtomicBool, ordinary_allowed: AtomicBool, @@ -325,8 +326,14 @@ impl crate::AiProvider for MediaProvider { _: &AiProviderSessionDescriptor, _: &ModelRequest, ) -> Result { - AiProviderSessionCursor::new("test.thread", "preview-thread") - .map_err(|_| ProviderError::InvalidRequest) + AiProviderSessionCursor::new( + "test.thread", + format!( + "preview-thread-{}", + self.custody.created_threads.fetch_add(1, Ordering::SeqCst) + ), + ) + .map_err(|_| ProviderError::InvalidRequest) } async fn stream( &self, @@ -1258,6 +1265,7 @@ async fn media_input_and_sessions(fixture: &Fixture) -> Arc Arc Arc, +) { let (fixture, custody, application) = media_fixture_mode(true, true).await; let descriptor = fixture .runtime @@ -1633,6 +1671,19 @@ async fn approved_media_lifecycle(deny_history: bool, invalid_metadata: bool, wi .consume_supervised_before_provider(current, &adopted) .await .unwrap(); + if let Some(mode) = reset_mode { + exercise_owned_static_reset( + &fixture, + &custody, + application, + sessions, + ¤t, + &adopted, + mode, + ) + .await; + return; + } let mut next = plan(&fixture); next.budget.attempt_id = current.attempt_id(); next.budget.lease_generation = current.lease_generation(); @@ -1928,3 +1979,289 @@ async fn prepared_original_task_survives_a_later_queued_message_without_new_huma ); 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, 4, 5] { + 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, + 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(); + base.budget.estimate.input_tokens = 10000; + 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 == 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 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 index 3b2d07a7..47ea68e4 100644 --- a/crates/graphql-orm-ai/src/provider_calls/tool_media.rs +++ b/crates/graphql-orm-ai/src/provider_calls/tool_media.rs @@ -118,7 +118,12 @@ impl AiProviderCallExecutor { plan.request .input .retain(|input| matches!(input, ModelInputBlock::Attachment { .. })); - plan.request.input.insert(0, ModelInputBlock::Text { text }); + 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 { diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index 080f47cb..b014956c 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -524,6 +524,7 @@ pub(crate) struct AiStaticOfferingResetReceipt { 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, 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 d8e031a1..0c48abc2 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -15170,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( From fc38838bfc2b7766e9b2d041bc5a4e280bb925c5 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:07:47 +1100 Subject: [PATCH 31/52] fix(ai): correct frozen static continuation type diagnostics --- crates/graphql-orm-ai/src/orm_provider_session.rs | 2 +- .../src/orm_provider_session/static_offering.rs | 11 ++++++++++- crates/graphql-orm-ai/src/provider_session.rs | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index ebf9afcb..b34b1bb3 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session.rs @@ -3514,7 +3514,6 @@ mod service { cleanup_generation: record.cleanup_generation, cleanup_expires_at: parse_time(record.cleanup_lease_expires_at)?, row_version: record.row_version, - installed_offering_fingerprint: record.installed_offering_fingerprint.clone(), }) } @@ -3538,6 +3537,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(), }) } 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 index 95ec380a..966988ef 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session/static_offering.rs @@ -24,7 +24,16 @@ async fn validate_static_reset_graph( return Err(conflict()); } let checkpoint = tx - .find_by_id::(&evidence.checkpoint_id) + .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)?; diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index b014956c..f02f05bb 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -500,7 +500,7 @@ pub struct AiProviderSessionBindingView { // 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)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AiVerifiedNativeContinuation { pub(crate) session_id: AiSessionId, pub(crate) run_id: AiRunId, @@ -535,7 +535,7 @@ pub(crate) struct AiStaticOfferingResetReceipt { /// 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)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct AiProviderStaticOfferingResetRequest { pub(crate) evidence: std::sync::Arc, pub(crate) planned: AiProviderSessionTurnPlan, From 08f09257b91bb692740adc5c3e1dc1bbfcabcfd0 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:28:37 +1100 Subject: [PATCH 32/52] Bind reset continuation to its original task after later messages --- .../src/orm_provider_session.rs | 30 ++++++- crates/graphql-orm-ai/src/provider_calls.rs | 7 +- .../provider_calls/tests/tool_media_tests.rs | 88 ++++++++++++++++++- crates/graphql-orm-ai/src/provider_session.rs | 2 + 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_provider_session.rs b/crates/graphql-orm-ai/src/orm_provider_session.rs index b34b1bb3..ca582e03 100644 --- a/crates/graphql-orm-ai/src/orm_provider_session.rs +++ b/crates/graphql-orm-ai/src/orm_provider_session.rs @@ -534,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 @@ -542,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)); @@ -1011,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)); diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index dee5296e..a780fb4c 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -3773,7 +3773,7 @@ impl AiProviderCallExecutor { &self, lease_state: Arc>, mut plan: AiProviderCallPlan, - session_plan: crate::AiProviderSessionTurnPlan, + mut session_plan: crate::AiProviderSessionTurnPlan, session_service: Arc, dynamic_execution: Option>, ) -> Result { @@ -3807,6 +3807,11 @@ impl AiProviderCallExecutor { .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? 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 index 3e3352ea..bcc5ea8f 100644 --- 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 @@ -2014,7 +2014,7 @@ impl crate::AiProviderSessionDeletionService for ConfirmPreviewDeletion { #[tokio::test] async fn owning_approved_static_reset_requires_confirmed_cleanup_and_current_source_without_effect_replay() { - for mode in [0, 1, 2, 4, 5] { + for mode in [0, 1, 2, 3, 4, 5, 6] { Box::pin(approved_media_lifecycle(false, false, 0, Some(mode))).await; } } @@ -2134,6 +2134,92 @@ async fn exercise_owned_static_reset( .with_tool_result_source_service(application.clone()) .unwrap() .with_static_offering_reset_deletion_service(deletion.clone()); + if mode == 3 { + 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()) + .count() + .await + .unwrap(); + // 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(); + let result = executor + .execute_with_provider_session( + Arc::new(Mutex::new(lease.clone())), + next, + session_plan, + sessions, + None, + ) + .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); + let sent = custody.requests.lock().await; + assert_eq!(sent.len(), 1); + let contents = serde_json::to_string(&sent[0].input).unwrap(); + assert!(contents.contains("FrameworkTaskContinuationContext")); + assert!(!contents.contains("A later independent task")); + assert_eq!( + AiSessionEventRecord::query(fixture.database.pool()) + .count() + .await + .unwrap(), + events_before, + ); + 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 diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index f02f05bb..543160cb 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -361,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 { @@ -378,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( From 5d629c281770a003054c9d7b4ab4fd20eb1e8df4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:38:40 +1100 Subject: [PATCH 33/52] Exercise owning quota retry after one approved retained effect --- .../tests/static_offering_media_tests.rs | 375 ++++++++++++++++++ .../provider_calls/tests/tool_media_tests.rs | 80 +++- 2 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 crates/graphql-orm-ai/src/provider_calls/tests/static_offering_media_tests.rs 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..373f7faf --- /dev/null +++ b/crates/graphql-orm-ai/src/provider_calls/tests/static_offering_media_tests.rs @@ -0,0 +1,375 @@ +//! Actual approval, SQLite budget denial and same-run native subset retry fixtures. +use super::*; +use crate::{ + AiSupervisedAgentCoordinator, AiSupervisedAgentCoordinatorLimits, AiSupervisedAgentRunOutcome, + AiSupervisedAgentTurnPlan, AiSupervisedAgentTurnPlanner, OrmAiProviderOutputService, +}; + +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(); + base.budget.estimate.input_tokens = 8000; + 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 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, + )?; + 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 self.mode == 11 { + 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?; + } + 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 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(0)), + maximum_input_tokens: Some(Some(100_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 result = Box::pin(coordinator.execute_approved_claim(&approved)).await; + 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 => { + assert!( + matches!(result, Ok(AiSupervisedAgentRunOutcome::Completed { .. })), + "{result:?}" + ); + assert_eq!(planner.retries.load(Ordering::SeqCst), 1); + assert_eq!(requests.len(), 1); + assert!(requests[0].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")); + // Approval thread, denied empty thread, reduced empty thread, and the exact + // tool-free withheld projection each have real retained custody. + assert!(custody.created_threads.load(Ordering::SeqCst) >= 3); + assert!(deletion.requests.lock().await.len() >= 2); + } + 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 => { + assert!( + matches!(result, Ok(AiSupervisedAgentRunOutcome::Failed { .. })), + "{result:?}" + ); + assert_eq!(planner.retries.load(Ordering::SeqCst), 1); + assert!(requests.is_empty()); + } + 11 => { + 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; + } +} 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 index bcc5ea8f..0277dbb6 100644 --- 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 @@ -4,6 +4,9 @@ use crate::{AiToolImageFieldBindings, AiToolResultMediaLimits, AiToolResultMedia 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, @@ -15,6 +18,7 @@ struct Custody { captures: Mutex>, effects: AtomicUsize, created_threads: AtomicUsize, + complete_after_capture: AtomicBool, opens: AtomicUsize, allowed: AtomicBool, ordinary_allowed: AtomicBool, @@ -346,8 +350,11 @@ impl crate::AiProvider for MediaProvider { Ordering::SeqCst, ); self.custody.requests.lock().await.push(request.clone()); - if request.tools.is_empty() { + 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, @@ -388,9 +395,13 @@ impl crate::AiProvider for MediaProvider { 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 - .first() + .iter() + .find(|definition| definition.tool_id == "records.read") .ok_or(ProviderError::Rejected)? .clone(); let custody = self.custody.clone(); @@ -1419,16 +1430,36 @@ async fn approved_media_lifecycle( fingerprint: descriptor.fingerprint.clone(), enabled: true, }); - let offered = 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, - ) + 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(), @@ -1483,7 +1514,10 @@ async fn approved_media_lifecycle( ProviderKind::OpenAiCompatible, "mock-profile", "mock-model", - "a".repeat(64), + coordinator_delivery.as_ref().map_or_else( + || "a".repeat(64), + |delivery| delivery.session_binding().fingerprint().to_owned(), + ), "mock/v1", "b".repeat(64), ) @@ -1555,8 +1589,24 @@ async fn approved_media_lifecycle( let resume = OrmAiSupervisedResumeService::new( fixture.run_service.clone(), checkpoints.clone(), - consequential, + consequential.clone(), ); + if let Some(delivery) = coordinator_delivery { + 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); @@ -2070,7 +2120,7 @@ async fn exercise_owned_static_reset( base.budget.attempt_id = lease.attempt_id(); base.budget.lease_generation = lease.lease_generation(); base.budget.idempotency_key = "owned-static-reset".into(); - base.budget.estimate.input_tokens = 10000; + base.budget.estimate.input_tokens = 8000; base.budget.estimate.image_units = 4; assert!( AiProviderCallPlan::new_continuation_with_classified_tools( From 4822d1a855d08ccc78971a2f281b9aab6e51bbbc Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:43:46 +1100 Subject: [PATCH 34/52] Require exact confirmed unstarted cleanup before offering retry --- .../src/orm_supervised_coordinator.rs | 19 +++++++++ .../src/provider_calls/static_offering.rs | 39 +++++++++++++++++++ .../provider_calls/tests/tool_media_tests.rs | 30 +++++++++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index e18ca94d..6f12d64d 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -1357,6 +1357,25 @@ impl AiSupervisedAgentCoordinator { 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 = match self .planner .replan_unstarted_static_offering( diff --git a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs index 873370b9..6841aa9f 100644 --- a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs +++ b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs @@ -428,6 +428,45 @@ impl AiUnstartedStaticOfferingReplan { 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()?; + 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, 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 index 0277dbb6..acddc3b6 100644 --- 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 @@ -2064,7 +2064,7 @@ impl crate::AiProviderSessionDeletionService for ConfirmPreviewDeletion { #[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] { + for mode in [0, 1, 2, 3, 4, 5, 6, 7] { Box::pin(approved_media_lifecycle(false, false, 0, Some(mode))).await; } } @@ -2184,6 +2184,34 @@ async fn exercise_owned_static_reset( .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 owning_sessions = OrmAiSessionService::new( fixture.database.clone(), From 60389457be49b2f7dd8e81bec6b9ac09c43329e8 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:47:00 +1100 Subject: [PATCH 35/52] Count actual admitted pixels after native cursor reset --- crates/graphql-orm-ai/src/provider_calls.rs | 10 ++++++++++ .../src/provider_calls/tests/tool_media_tests.rs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index a780fb4c..d4b5f8bc 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4315,6 +4315,16 @@ 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 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 index acddc3b6..8cdf953e 100644 --- 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 @@ -1592,7 +1592,7 @@ async fn approved_media_lifecycle( consequential.clone(), ); if let Some(delivery) = coordinator_delivery { - static_offering_tests::owning_coordinator_reset( + Box::pin(static_offering_tests::owning_coordinator_reset( fixture, custody, application, @@ -1603,7 +1603,7 @@ async fn approved_media_lifecycle( delivery, claim, reset_mode.unwrap(), - ) + )) .await; return; } From 39c12b384982e1e82b9b26d3a7ebe66dbc341b97 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:52:02 +1100 Subject: [PATCH 36/52] Document exact native retry custody and conservative quote contract --- crates/graphql-orm-ai/MIGRATION.md | 4 ++++ .../graphql-orm-ai/docs/tool-result-media.md | 18 +++++++++++++++--- .../src/orm_supervised_coordinator.rs | 4 ++++ crates/graphql-orm-ai/src/provider_session.rs | 4 ++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 525488cd..518c389b 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -5793,5 +5793,9 @@ 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/docs/tool-result-media.md b/crates/graphql-orm-ai/docs/tool-result-media.md index ddbe5773..83d8ce3e 100644 --- a/crates/graphql-orm-ai/docs/tool-result-media.md +++ b/crates/graphql-orm-ai/docs/tool-result-media.md @@ -208,6 +208,18 @@ 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. Another denial remains terminal. Saved historical content that is -no longer admitted is withheld truthfully; an old screenshot cannot renew an -observation or authorize input. +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/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index 6f12d64d..2a3b0dd4 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -289,6 +289,10 @@ pub trait AiSupervisedAgentTurnPlanner: Send + Sync { /// 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. + /// + /// # 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, diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index 543160cb..32666144 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -1457,6 +1457,10 @@ pub trait AiProviderSessionService: Send + Sync { /// 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, From f3095f945b4a13fa343741732928bdfc7c308f34 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 18:58:26 +1100 Subject: [PATCH 37/52] Correct native reset fixture types and source guard construction --- crates/graphql-orm-ai/src/provider_calls.rs | 2 -- .../src/provider_calls/tests/tool_media_tests.rs | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index d4b5f8bc..1b0bfc2c 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -14215,7 +14215,6 @@ mod tests { maximum_tool_calls, allows_native_control: false, provider_session_claim: None, - static_offering_reset_receipt: None, callbacks: Mutex::new(()), calls: Mutex::new(Vec::new()), results: Mutex::new(Vec::new()), @@ -14372,7 +14371,6 @@ mod tests { maximum_tool_calls: 8, allows_native_control: false, provider_session_claim: None, - static_offering_reset_receipt: None, callbacks: Mutex::new(()), calls: Mutex::new(Vec::new()), results: Mutex::new(Vec::new()), 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 index 8cdf953e..30edbecd 100644 --- 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 @@ -1074,6 +1074,7 @@ async fn delivery_history_race_denies_protected_sources_but_preserves_general_de Arc::new(Mutex::new(call.lease().clone())), &[manifest], 4, + None, ) .unwrap() .unwrap(); @@ -1276,7 +1277,7 @@ async fn media_input_and_sessions(fixture: &Fixture) -> Arc Date: Tue, 13 Oct 2026 19:32:33 +1100 Subject: [PATCH 38/52] Heap-bound private retry and retained reset futures --- .../src/orm_supervised_coordinator.rs | 6 +- crates/graphql-orm-ai/src/provider_calls.rs | 72 +++++++++---------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index 2a3b0dd4..7f3e943e 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -1015,7 +1015,9 @@ impl AiSupervisedAgentCoordinator { adopted_automatic: Option<&AiAdoptedAutomaticMutationBatch>, adopted_subscription: Option<&crate::AiAdoptedReadOnlyToolBatch>, ) -> Result { - self.execute_turn_with_retry( + // 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, @@ -1024,7 +1026,7 @@ impl AiSupervisedAgentCoordinator { adopted_automatic, adopted_subscription, true, - ) + )) .await } diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 1b0bfc2c..0c3cf6af 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -3960,15 +3960,14 @@ impl AiProviderCallExecutor { && plan.task_context.is_some() => { let current = lease_state.lock().await; - let (claim, cursor) = self - .reset_static_offering( - ¤t, - &mut plan, - &session_plan, - &session_service, - None, - ) - .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(_) => { @@ -3991,15 +3990,14 @@ impl AiProviderCallExecutor { // 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 = self - .reset_static_offering( - ¤t, - &mut plan, - &session_plan, - &session_service, - Some(&claim), - ) - .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; @@ -4024,15 +4022,14 @@ impl AiProviderCallExecutor { | AiError::Conflict, ) => { if plan.native_reset_evidence.is_some() && plan.task_context.is_some() { - return self - .execute_withheld_native_task_context( - lease_state, - plan, - &session_plan, - &session_service, - &claim, - ) - .await; + 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") @@ -4148,15 +4145,14 @@ impl AiProviderCallExecutor { if withheld_plan.native_reset_evidence.is_some() && withheld_plan.task_context.is_some() { - return self - .execute_withheld_native_task_context( - lease_state, - withheld_plan, - &session_plan, - &session_service, - ¤t_claim, - ) - .await; + return Box::pin(self.execute_withheld_native_task_context( + lease_state, + withheld_plan, + &session_plan, + &session_service, + ¤t_claim, + )) + .await; } session_service .require_cleanup( @@ -4178,13 +4174,13 @@ impl AiProviderCallExecutor { 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. - self.confirm_unstarted_static_offering_cleanup( + Box::pin(self.confirm_unstarted_static_offering_cleanup( ¤t, &withheld_plan, &session_plan, &session_service, ¤t_claim, - ) + )) .await .map_err(|_| AiError::ProviderFailed)?; } From 4056ed304f5437d1a2b1cf92e2ec8508266686d8 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:01:48 +1100 Subject: [PATCH 39/52] fix(ai): bound unstarted offering planner by lease and cancellation --- .../src/orm_supervised_coordinator.rs | 85 +++++++++++++++++-- .../tests/static_offering_media_tests.rs | 25 +++++- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index 7f3e943e..e9d72d3a 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -289,6 +289,8 @@ pub trait AiSupervisedAgentTurnPlanner: Send + Sync { /// 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 @@ -1382,15 +1384,19 @@ impl AiSupervisedAgentCoordinator { ) .await; } - let replacement = match self - .planner - .replan_unstarted_static_offering( - &lease, - guard.provider_turns(), - context.clone(), - ) - .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 @@ -2034,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_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, 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 index 373f7faf..4cdd74dd 100644 --- 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 @@ -119,7 +119,7 @@ impl AiSupervisedAgentTurnPlanner for QuotaRetryPlanner { context .capability_delivery() .set_static_bootstrap_offering(&BTreeSet::new())?; - if self.mode == 11 { + if matches!(self.mode, 11 | 13) { crate::OrmAiRunCancellationService::new( self.fixture.database.clone(), Arc::new(AllowAccess), @@ -140,6 +140,11 @@ impl AiSupervisedAgentTurnPlanner for QuotaRetryPlanner { ) .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(), @@ -248,7 +253,12 @@ pub(super) async fn owning_coordinator_reset( .count() .await .unwrap(); - let result = Box::pin(coordinator.execute_approved_claim(&approved)).await; + 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()) @@ -321,7 +331,7 @@ pub(super) async fn owning_coordinator_reset( assert!(planner.held_plan.lock().await.is_some()); assert!(requests.is_empty()); } - 10 => { + 10 | 12 => { assert!( matches!(result, Ok(AiSupervisedAgentRunOutcome::Failed { .. })), "{result:?}" @@ -329,7 +339,7 @@ pub(super) async fn owning_coordinator_reset( assert_eq!(planner.retries.load(Ordering::SeqCst), 1); assert!(requests.is_empty()); } - 11 => { + 11 | 13 => { assert!( matches!( result, @@ -373,3 +383,10 @@ async fn approved_native_quota_retry_is_once_and_preserves_effect_task_and_exter 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; + } +} From cb4662ddd38a9b1262bf6cb8eee541ee86f3e2c4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:03:26 +1100 Subject: [PATCH 40/52] fix(ai): preserve replacement freeze during custody recheck --- .../src/orm_supervised_coordinator.rs | 2 +- .../src/provider_calls/static_offering.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs index e9d72d3a..1cefcf21 100644 --- a/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_supervised_coordinator.rs @@ -2081,7 +2081,7 @@ impl AiSupervisedAgentCoordinator { return Err(AiError::Conflict); } context - .validate_unstarted_cleanup( + .validate_current_unstarted_cleanup( lease, self.provider_session_service .as_deref() diff --git a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs index 6841aa9f..7a090d4b 100644 --- a/crates/graphql-orm-ai/src/provider_calls/static_offering.rs +++ b/crates/graphql-orm-ai/src/provider_calls/static_offering.rs @@ -435,6 +435,19 @@ impl AiUnstartedStaticOfferingReplan { 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 From fc7724f37ad558e24e81840c25c0ab5a71eea7c4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:18:19 +1100 Subject: [PATCH 41/52] test(ai): price complete native task continuation before quota retry --- .../tests/static_offering_media_tests.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) 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 index 4cdd74dd..0ddfc49b 100644 --- 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 @@ -39,7 +39,25 @@ impl QuotaRetryPlanner { base.budget.attempt_id = lease.attempt_id(); base.budget.lease_generation = lease.lease_generation(); base.budget.idempotency_key = "owning-quota-original".into(); - base.budget.estimate.input_tokens = 8000; + let input_contribution = continuation.conservative_input_tokens()?; + let base_projection = base + .request + .conservative_provider_egress_tokens(&base.provider_kind)?; + 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)?; + 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; @@ -58,6 +76,12 @@ impl QuotaRetryPlanner { &policy, &self.fixture.generated_target_policy, )?; + assert!( + provider.budget.estimate.input_tokens + >= provider + .request + .conservative_provider_egress_tokens(&provider.provider_kind)? + ); if self.mode == 9 { *self.held_plan.lock().await = Some(provider.clone()); } @@ -168,6 +192,29 @@ pub(super) async fn owning_coordinator_reset( 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 = 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(), + )); let fixture = Arc::new(fixture); let policy = AiBudgetPolicyRecord::query(fixture.database.pool()) .limit(1) @@ -182,7 +229,7 @@ pub(super) async fn owning_coordinator_reset( &policy.id, crate::persistence::UpdateAiBudgetPolicyRecordInput { maximum_image_units: Some(Some(0)), - maximum_input_tokens: Some(Some(100_000)), + maximum_input_tokens: Some(Some(20_000)), maximum_runs: (mode == 10).then_some(Some(1)), ..Default::default() }, From 6d4b628edb350d287f7309920509cdb4c6d526cf Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:19:51 +1100 Subject: [PATCH 42/52] test(ai): map exact fixture projection errors explicitly --- .../tests/static_offering_media_tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 0ddfc49b..5b1b7311 100644 --- 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 @@ -42,15 +42,17 @@ impl QuotaRetryPlanner { let input_contribution = continuation.conservative_input_tokens()?; let base_projection = base .request - .conservative_provider_egress_tokens(&base.provider_kind)?; + .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)?; + let complete_quote = complete_projection + .conservative_provider_egress_tokens(&base.provider_kind) + .map_err(|_| AiError::Conflict)?; assert!( complete_quote >= base_projection @@ -80,7 +82,8 @@ impl QuotaRetryPlanner { provider.budget.estimate.input_tokens >= provider .request - .conservative_provider_egress_tokens(&provider.provider_kind)? + .conservative_provider_egress_tokens(&provider.provider_kind) + .map_err(|_| AiError::Conflict)? ); if self.mode == 9 { *self.held_plan.lock().await = Some(provider.clone()); From 742b04a7f358bd5ab966f8f7d7953573545125a7 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:36:05 +1100 Subject: [PATCH 43/52] fix(ai): include verified native result in whole inference proof --- crates/graphql-orm-ai/src/provider_calls.rs | 21 +++++++++++ .../tests/static_offering_media_tests.rs | 37 +++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 0c3cf6af..2ff19988 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -843,12 +843,33 @@ impl AiProviderCallPlan { )?; 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); } 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 index 5b1b7311..513429d0 100644 --- 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 @@ -66,7 +66,7 @@ impl QuotaRetryPlanner { budget.idempotency_key = "owning-quota-reduced".into(); base.budget = budget; } - let provider = AiProviderCallPlan::new_continuation_with_classified_capability_surface( + let mut provider = AiProviderCallPlan::new_continuation_with_classified_capability_surface( base.provider_kind, base.request, base.budget, @@ -78,6 +78,28 @@ impl QuotaRetryPlanner { &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 @@ -231,7 +253,7 @@ pub(super) async fn owning_coordinator_reset( &fixture.database, &policy.id, crate::persistence::UpdateAiBudgetPolicyRecordInput { - maximum_image_units: Some(Some(0)), + 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() @@ -369,7 +391,7 @@ pub(super) async fn owning_coordinator_reset( assert!(custody.created_threads.load(Ordering::SeqCst) >= 3); assert!(deletion.requests.lock().await.len() >= 2); } - 9 => { + 9 | 14 => { assert!( matches!( result, @@ -378,7 +400,9 @@ pub(super) async fn owning_coordinator_reset( "{result:?}" ); assert_eq!(planner.retries.load(Ordering::SeqCst), 0); - assert!(planner.held_plan.lock().await.is_some()); + if mode == 9 { + assert!(planner.held_plan.lock().await.is_some()); + } assert!(requests.is_empty()); } 10 | 12 => { @@ -440,3 +464,8 @@ async fn approved_native_pending_quota_replan_is_bounded_and_owner_cancellable() 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; +} From afd45a7f1381e41849f02260f06cefb45336ca15 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:48:15 +1100 Subject: [PATCH 44/52] test(ai): assert wrong source withholding preserves exact task custody --- .../tests/static_offering_media_tests.rs | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) 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 index 513429d0..0f0f6460 100644 --- 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 @@ -325,6 +325,13 @@ pub(super) async fn owning_coordinator_reset( .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)), @@ -371,14 +378,18 @@ pub(super) async fn owning_coordinator_reset( ); let requests = custody.requests.lock().await; match mode { - 8 => { + 8 | 14 => { assert!( matches!(result, Ok(AiSupervisedAgentRunOutcome::Completed { .. })), "{result:?}" ); - assert_eq!(planner.retries.load(Ordering::SeqCst), 1); + 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")); @@ -386,12 +397,43 @@ pub(super) async fn owning_coordinator_reset( 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() + ); + 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. - assert!(custody.created_threads.load(Ordering::SeqCst) >= 3); - assert!(deletion.requests.lock().await.len() >= 2); + 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 | 14 => { + 9 => { assert!( matches!( result, @@ -400,9 +442,7 @@ pub(super) async fn owning_coordinator_reset( "{result:?}" ); assert_eq!(planner.retries.load(Ordering::SeqCst), 0); - if mode == 9 { - assert!(planner.held_plan.lock().await.is_some()); - } + assert!(planner.held_plan.lock().await.is_some()); assert!(requests.is_empty()); } 10 | 12 => { From ff404aa4c08bb2f7b243654945024b73723086e1 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 20:48:45 +1100 Subject: [PATCH 45/52] test(ai): retain exact original event and client identity on withholding --- .../tests/static_offering_media_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index 0f0f6460..91b3de35 100644 --- 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 @@ -407,6 +407,30 @@ pub(super) async fn owning_coordinator_reset( 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); From 738a3935e6af13e3d5a2d844c37b1054751b8fd7 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 21:00:09 +1100 Subject: [PATCH 46/52] fix(ai): require current owning session for original task context --- crates/graphql-orm-ai/src/orm_tools/task_context.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-ai/src/orm_tools/task_context.rs b/crates/graphql-orm-ai/src/orm_tools/task_context.rs index 9b8b8918..bc88bb88 100644 --- a/crates/graphql-orm-ai/src/orm_tools/task_context.rs +++ b/crates/graphql-orm-ai/src/orm_tools/task_context.rs @@ -10,7 +10,7 @@ impl OrmAiApplicationToolCallService { /// before constructing the complete final plan. User text remains untrusted context. /// /// # Errors - /// Rejects cancelled/obsolete leases, current access denial, missing or purged original + /// 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, @@ -19,6 +19,12 @@ impl OrmAiApplicationToolCallService { ) -> 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()) From 59be3314d95851a9cb6a81a1832199f831e4e1ff Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 21:02:01 +1100 Subject: [PATCH 47/52] test(ai): price complete direct-reset request within finite task limits --- .../tests/static_offering_media_tests.rs | 42 ++++++++++--------- .../provider_calls/tests/tool_media_tests.rs | 30 ++++++++++++- 2 files changed, 51 insertions(+), 21 deletions(-) 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 index 91b3de35..044cfebe 100644 --- 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 @@ -5,6 +5,28 @@ use crate::{ 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, @@ -221,25 +243,7 @@ pub(super) async fn owning_coordinator_reset( // 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 = 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(), - )); + fixture.budget_service = extended_task_budget_service(&fixture); let fixture = Arc::new(fixture); let policy = AiBudgetPolicyRecord::query(fixture.database.pool()) .limit(1) 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 index 30edbecd..246319af 100644 --- 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 @@ -1407,7 +1407,26 @@ async fn approved_media_lifecycle( withdrawal: usize, reset_mode: Option, ) { - let (fixture, custody, application) = media_fixture_mode(true, true).await; + 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() @@ -2121,7 +2140,14 @@ async fn exercise_owned_static_reset( base.budget.attempt_id = lease.attempt_id(); base.budget.lease_generation = lease.lease_generation(); base.budget.idempotency_key = "owned-static-reset".into(); - base.budget.estimate.input_tokens = 8000; + 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( From 185e3fcd2ef321d79e4d53ca09cb91d674e6aa49 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 21:27:56 +1100 Subject: [PATCH 48/52] test(ai): retain owning native responder for task reset continuation --- .../provider_calls/tests/tool_media_tests.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 index 246319af..949c7c78 100644 --- 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 @@ -1747,6 +1747,7 @@ async fn approved_media_lifecycle( &custody, application, sessions, + native, ¤t, &adopted, mode, @@ -2095,6 +2096,7 @@ async fn exercise_owned_static_reset( custody: &Arc, application: Arc, sessions: Arc, + native: Arc, lease: &AiRunLease, adopted: &crate::AiAdoptedSupervisedToolBatch, mode: usize, @@ -2240,6 +2242,13 @@ async fn exercise_owned_static_reset( 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), @@ -2273,13 +2282,16 @@ async fn exercise_owned_static_reset( .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, - None, + Some(native), ) .await .unwrap(); @@ -2288,10 +2300,19 @@ async fn exercise_owned_static_reset( 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")); assert_eq!( AiSessionEventRecord::query(fixture.database.pool()) From 61555977cee1763fd1fe275e5ba6917e359f19bf Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 21:36:33 +1100 Subject: [PATCH 49/52] test(ai): distinguish cursor rebind from human message events --- .../provider_calls/tests/tool_media_tests.rs | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) 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 index 949c7c78..f832c77c 100644 --- 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 @@ -2270,9 +2270,20 @@ async fn exercise_owned_static_reset( .unwrap(); assert_ne!(later.run_id, lease.run_id().0); let events_before = AiSessionEventRecord::query(fixture.database.pool()) - .count() + .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!( @@ -2314,13 +2325,34 @@ async fn exercise_owned_static_reset( 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!( - AiSessionEventRecord::query(fixture.database.pool()) - .count() - .await - .unwrap(), - events_before, + 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 { From 881cb8a6144d43d377e099fdc4c2e9f18b9a6403 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 21:44:28 +1100 Subject: [PATCH 50/52] test(ai): apply ordinary policy to withheld source-free continuation --- .../provider_calls/tests/tool_media_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index f832c77c..cc4b670e 100644 --- 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 @@ -272,6 +272,11 @@ impl AiEgressPolicy for Custody { 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 @@ -2435,6 +2440,22 @@ async fn exercise_owned_static_reset( 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 From 3fc71e7e6206b921a030f24decadc032d94374db Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 22:18:17 +1100 Subject: [PATCH 51/52] docs(ai): prepare typed media workspace release notes --- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 6 ++++- crates/graphql-orm-ai-tool-profiles/README.md | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 24 ++++++++++--------- crates/graphql-orm-ai/README.md | 7 ++++++ 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index c2e7546f..5eb91cc6 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -10,7 +10,7 @@ supersedes: [] # Changelog -## [Unreleased] +## [0.16.0] - 2026-10-13 ### Added @@ -20,6 +20,10 @@ supersedes: [] - `PreTransportToolSourceUnavailable` retains safe no-dispatch classification for unavailable protected history after an unstarted reservation is released. +## [Unreleased] + +### Added + - Trusted `ToolPreExecutionRejection` carries an exact invocation hash and one closed public reason without resolver text, credentials or request content. `RejectedBeforeExecution` and `ToolRejectedBeforeExecution` preserve this diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 6b18670b..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" ``` diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index e4cec7e5..9a841ce4 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,16 +18,9 @@ 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.106.1] - 2026-10-01 - -### Fixed - -- Raise the finite provider-session static bootstrap and Grok SDK definition - bounds from 64 to 128. Larger reviewed catalogues can start native sessions - without omitting tools; exact fingerprints, duplicate/schema rejection, - provider frame/byte limits and callback bounds remain mandatory. +## [0.107.0] - 2026-10-13 -## [Unreleased] +### Added - Keep original static capability subsets in the same broker state, and require confirmed exact old-thread deletion before a same-run native continuation @@ -36,8 +29,6 @@ checkpoint facts. For the current workspace baseline and active gates, use the and admits at most one proven unstarted reduction without effect replay. -### Added - - 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 @@ -65,6 +56,17 @@ checkpoint facts. For the current workspace baseline and active gates, use the or media effects. Native no-effect control receipts retain their owning admission while releasing unused image capacity. +## [0.106.1] - 2026-10-01 + +### Fixed + +- Raise the finite provider-session static bootstrap and Grok SDK definition + bounds from 64 to 128. Larger reviewed catalogues can start native sessions + without omitting tools; exact fingerprints, duplicate/schema rejection, + provider frame/byte limits and callback bounds remain mandatory. + +## [Unreleased] + ### Added - `AiToolAuthorizationDecision::require_authentication` distinguishes current diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index ceb67c3c..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 From 2255dab3c29f5fe2e8233b53a42edc1bf79733e4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 13 Oct 2026 23:27:34 +1100 Subject: [PATCH 52/52] docs(ai): document pre-1.0 source compatibility boundaries --- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 6 +++++ .../graphql-orm-ai-tool-profiles/MIGRATION.md | 6 +++++ crates/graphql-orm-ai/CHANGELOG.md | 9 +++++++ crates/graphql-orm-ai/MIGRATION.md | 27 +++++++++++++++++-- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index 5eb91cc6..926a18b4 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -12,6 +12,12 @@ supersedes: [] ## [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 diff --git a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md index be4b0c75..4817a865 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -12,6 +12,12 @@ supersedes: [] ## 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 diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 9a841ce4..2cd59dbd 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -20,6 +20,15 @@ checkpoint facts. For the current workspace baseline and active gates, use the ## [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 diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 518c389b..c19cfe1e 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -21,6 +21,26 @@ use [implementation status](docs/implementation-status.md) and the central ## 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 @@ -45,8 +65,11 @@ 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; -metadata and original output remain protected. Existing rows need no source -backfill and gain no additional disclosure allowance. Include the new table in +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.