Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions crates/graphql-orm-ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ 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.92.0] - 2026-08-23

Persistent schema module: **0.63.0** (unchanged from 0.91.1).

### Added

- `AiToolCallResultPreviewView` now includes an optional host-projected
`arguments` value. `AiToolResultPreviewAuthorizer` has a default-deny
`authorize_and_project_arguments` seam so existing hosts disclose nothing
until they opt in.
- Owner-authorized previews now return the existing versioned, content-free
`AiApplicationToolFailureEnvelope` for a durably failed read tool when its
state, public classification, stored authorization code, and protected
envelope agree exactly.

### Security

- The preview service still rehydrates the current principal and checks exact
session ownership plus current session/scope read authority. Successful
results retain descriptor fingerprint, current tool policy, disclosure
schema, classification, depth, record, and byte validation.
- Argument projection is independently default-deny, host-authored, and
capped at 64 KiB. A missing/stale descriptor can return only the public safe
failure envelope, never stored arguments. Secret-classified results remain
unavailable to the browser.

There is no database, data, table, column, index, constraint, backfill,
protected-payload, backup, or restore migration. The GraphQL SDL adds the
nullable `Arguments` field to the existing tool-call preview object.

## [0.91.1] - 2026-08-23

Persistent schema module: **0.63.0** (unchanged from 0.91.0).
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-ai/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "graphql-orm-ai"
version = "0.91.1"
version = "0.92.0"
edition = "2024"
authors = ["Toby Martin <toby@dastari.net>"]
description = "Project-agnostic AI agent runtime for graphql-orm applications"
Expand Down
20 changes: 20 additions & 0 deletions crates/graphql-orm-ai/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ 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.91.1 to 0.92.0: owner-authorized tool arguments and safe failures

Adopt `graphql-orm-ai` 0.92.0 from one reviewed full monorepo revision. The AI
schema module remains **0.63.0**. There is no database, data, table, column,
index, constraint, backfill, protected-payload, backup, or restore migration.

The existing `aiToolCallResultPreview` GraphQL object adds nullable
`arguments`. Successful `preview` values are unchanged. A durably failed
read-only call can now return the crate-authored public failure envelope as
`preview`, including its stable code and retryable flag, instead of returning
no object. Clients should render that envelope as a safe error and must not
infer resolver details that are absent from it.

`AiToolResultPreviewAuthorizer` implementations may override the new
`authorize_and_project_arguments` method. Its default returns `None`, so
existing implementations remain default-deny. Hosts opting in must project
only arguments safe for the rehydrated current browser principal and must
withhold secrets. Existing direct `AiToolCallResultPreviewView` struct literals
must initialize the new `arguments` field.

## 0.91.0 to 0.91.1: Codex FixedBroker projection and retained session admission

Adopt `graphql-orm-ai` 0.91.1 from one reviewed full monorepo revision. The AI
Expand Down
6 changes: 5 additions & 1 deletion crates/graphql-orm-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages:

```toml
[dependencies]
graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.91.1", default-features = false, features = ["sqlite"] }
graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.92.0", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -79,6 +79,10 @@ the compiled test-backed recipe and the missing reusable bootstrap API.
- One bounded owner-authorized conversation bootstrap combines the newest
messages, durable watermark, active/recent runs, tool calls, provider
activity and retention reset state for race-free replay/live handoff.
- An owner-authorized tool-call preview rehydrates current authority before
returning host-projected arguments and either a disclosure-validated result
or the exact content-free safe failure envelope. Secret results never enter
this browser contract.
- Provider-neutral adapters plus deterministic network-free mocks.
- Optional provider profiles, attachments, skills, UI intents, rules, and
usage/pricing controls, each behind independent proof and policy boundaries.
Expand Down
177 changes: 165 additions & 12 deletions crates/graphql-orm-ai/src/orm_tool_result_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use crate::{
GraphqlInvocationContext, ProtectedContentEnvelope, ToolGraphqlRequest,
};

const MAXIMUM_BROWSER_ARGUMENT_BYTES: usize = 64 * 1024;

/// Generated-ORM result-preview service for application hosts.
pub struct OrmAiToolCallResultPreviewService {
database: Database<DefaultWriteBackend>,
Expand Down Expand Up @@ -55,6 +57,30 @@ impl OrmAiToolCallResultPreviewService {
.await
.map_err(map_protection)
}

async fn project_arguments(
&self,
principal: &agql_auth::ResolvedPrincipal,
scope: &crate::AiScope,
descriptor: &crate::AiToolDescriptor,
arguments: &serde_json::Value,
) -> Result<Option<serde_json::Value>, AiError> {
let Some(projected) = self
.authorizer
.authorize_and_project_arguments(principal, scope, descriptor, arguments)
.await?
else {
return Ok(None);
};
if serde_json::to_vec(&projected)
.map_err(|_| AiError::PersistenceFailed)?
.len()
> MAXIMUM_BROWSER_ARGUMENT_BYTES
{
return Err(AiError::Forbidden);
}
Ok(Some(projected))
}
}

#[async_trait]
Expand Down Expand Up @@ -117,13 +143,19 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService {
.map_err(|error| map_orm(OrmPublicError::from(error)))?
.filter(|run| run.session_id == session.id)
.ok_or(AiError::NotFound)?;
let successful =
call.state == "completed" && call.authorization_code.as_deref() == Some("allowed");
let safe_failure = call
.authorization_code
.as_deref()
.and_then(application_tool_failure_code)
.filter(|_| matches!(call.state.as_str(), "execution_failed" | "egress_denied"));
if call.run_id != run.id
|| call.state != "completed"
|| (!successful && safe_failure.is_none())
|| call.completed_at.is_none()
|| call.payload_purged_at.is_some()
|| call.protected_result.is_none()
|| call.protected_arguments.is_none()
|| call.authorization_code.as_deref() != Some("allowed")
{
return Ok(None);
}
Expand All @@ -132,8 +164,62 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService {
.runtime
.tool_catalog()
.descriptor(&tool_id)
.filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint)
.ok_or(AiError::Forbidden)?;
.filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint);
let policy = self
.runtime
.content_protection_policy_resolver()
.resolve(current.principal(), &scope)
.await?;
if !policy.ready || policy.scope != scope {
return Err(AiError::RuntimeNotReady);
}
if let Some(code) = safe_failure {
let classification = parse_classification(
call.result_classification
.as_deref()
.ok_or(AiError::PersistenceFailed)?,
)?;
if classification != DataClassification::Public {
return Err(AiError::PersistenceFailed);
}
let stored = self
.open(
&policy,
protection_context(call.id, "protected_result", &scope),
call.protected_result
.as_ref()
.ok_or(AiError::PersistenceFailed)?,
)
.await?;
let preview = extract_safe_failure(&stored, code)?;
let arguments = if let Some(descriptor) = descriptor
&& descriptor.browser_result_preview.is_some()
{
let stored_arguments = self
.open(
&policy,
protection_context(call.id, "protected_arguments", &scope),
call.protected_arguments
.as_ref()
.ok_or(AiError::PersistenceFailed)?,
)
.await?;
self.project_arguments(&current, &scope, descriptor, &stored_arguments)
.await?
} else {
None
};
return Ok(Some(AiToolCallResultPreviewView {
session_id: session.id,
run_id: run.id,
tool_call_id: call.id,
tool_id: call.tool_id,
classification: classification_name(classification).to_owned(),
arguments: arguments.map(async_graphql::Json),
preview: async_graphql::Json(preview),
}));
}
let descriptor = descriptor.ok_or(AiError::Forbidden)?;
let preview_policy = match descriptor.browser_result_preview {
Some(policy) => policy,
None => return Ok(None),
Expand All @@ -154,14 +240,6 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService {
if classification > preview_policy.maximum_classification {
return Ok(None);
}
let policy = self
.runtime
.content_protection_policy_resolver()
.resolve(current.principal(), &scope)
.await?;
if !policy.ready || policy.scope != scope {
return Err(AiError::RuntimeNotReady);
}
let arguments = self
.open(
&policy,
Expand Down Expand Up @@ -203,6 +281,14 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService {
if preauthorization.principal().reference() != &requested_reference {
return Err(AiError::ReauthorizationFailed);
}
let arguments = self
.project_arguments(
preauthorization.principal(),
&scope,
descriptor,
&request.variables,
)
.await?;
let stored = self
.open(
&policy,
Expand Down Expand Up @@ -251,6 +337,7 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService {
tool_call_id: call.id,
tool_id: descriptor.id.as_str().to_owned(),
classification: classification_name(classification).to_owned(),
arguments: arguments.map(async_graphql::Json),
preview: async_graphql::Json(preview),
}))
}
Expand Down Expand Up @@ -296,6 +383,41 @@ fn extract_exact_result(value: &serde_json::Value) -> Result<&serde_json::Value,
object.get("data").ok_or(AiError::PersistenceFailed)
}

fn application_tool_failure_code(value: &str) -> Option<crate::AiApplicationToolFailureCode> {
use crate::AiApplicationToolFailureCode as Code;

match value {
"invalid_arguments" => Some(Code::InvalidArguments),
"selection_too_large" => Some(Code::SelectionTooLarge),
"relationship_depth_exceeded" => Some(Code::RelationshipDepthExceeded),
"result_budget_exceeded" => Some(Code::ResultBudgetExceeded),
"capability_stale" => Some(Code::CapabilityStale),
"authorization_denied" => Some(Code::AuthorizationDenied),
"temporarily_unavailable" => Some(Code::TemporarilyUnavailable),
"tool_unavailable" => Some(Code::ToolUnavailable),
"resolver_validation_failed" => Some(Code::ResolverValidationFailed),
"not_found" => Some(Code::NotFound),
_ => None,
}
}

fn extract_safe_failure(
value: &serde_json::Value,
code: crate::AiApplicationToolFailureCode,
) -> Result<serde_json::Value, AiError> {
let object = value.as_object().ok_or(AiError::PersistenceFailed)?;
if object.len() != 4
|| object.get("version").and_then(serde_json::Value::as_u64)
!= Some(u64::from(crate::AI_APPLICATION_TOOL_FAILURE_VERSION))
|| object.get("ok").and_then(serde_json::Value::as_bool) != Some(false)
|| object.get("code").and_then(serde_json::Value::as_str) != Some(code.as_str())
|| object.get("retryable").and_then(serde_json::Value::as_bool) != Some(code.retryable())
{
return Err(AiError::PersistenceFailed);
}
Ok(value.clone())
}

fn json_shape(value: &serde_json::Value, depth: usize) -> Result<(usize, u64), AiError> {
if depth > 64 {
return Err(AiError::Forbidden);
Expand Down Expand Up @@ -349,3 +471,34 @@ const fn classification_name(value: DataClassification) -> &'static str {
DataClassification::Secret => "secret",
}
}

#[cfg(test)]
mod tests {
use super::{application_tool_failure_code, extract_safe_failure};
use crate::{AiApplicationToolFailureCode, AiApplicationToolFailureEnvelope, AiError};

#[test]
fn safe_failure_preview_accepts_only_the_exact_content_free_envelope() {
let code = AiApplicationToolFailureCode::AuthorizationDenied;
let envelope = AiApplicationToolFailureEnvelope::new(code).to_json();
assert_eq!(application_tool_failure_code(code.as_str()), Some(code));
assert_eq!(
extract_safe_failure(&envelope, code).expect("exact safe envelope should validate"),
envelope
);

let mut mismatched =
AiApplicationToolFailureEnvelope::new(AiApplicationToolFailureCode::ToolUnavailable)
.to_json();
assert!(matches!(
extract_safe_failure(&mismatched, code),
Err(AiError::PersistenceFailed)
));
mismatched["code"] = serde_json::Value::String(code.as_str().to_owned());
mismatched["detail"] = serde_json::Value::String("must not cross".to_owned());
assert!(matches!(
extract_safe_failure(&mismatched, code),
Err(AiError::PersistenceFailed)
));
}
}
Loading
Loading