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.

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

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

### Fixed

- The Codex app-server web-search lifecycle now follows the generated 0.148.0
action contract: non-search completions may carry an empty query, bounded
extension fields no longer reject recognized text results, and opaque future
result types are accepted without being exposed as structured host metadata.

### Security

- The complete opaque result array remains count- and byte-bounded. Recognized
text results still require bounded fields, a valid reference, an HTTPS URL,
matching normalized host, and the active domain policy before exposure.
Strict frame correlation, lifecycle pairing, and per-turn call ceilings are
unchanged.

There is no database, data, table, column, index, constraint, backfill,
protected-payload, GraphQL SDL, backup, or restore migration.

## [0.95.0] - 2026-08-25

Persistent schema module: **0.63.0** (unchanged from 0.94.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.95.0"
version = "0.95.1"
edition = "2024"
authors = ["Toby Martin <toby@dastari.net>"]
description = "Project-agnostic AI agent runtime for graphql-orm applications"
Expand Down
14 changes: 14 additions & 0 deletions crates/graphql-orm-ai/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ 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.95.0 to 0.95.1: forward-compatible Codex web-search completion

Adopt `graphql-orm-ai` 0.95.1 from one reviewed full monorepo revision. Hosts
do not need to change their API implementation. A completed non-search action
may now expose `Some("")` through the existing optional query field. The
structured result vector contains only recognized, fully validated text-result
metadata; opaque future result types and unexposed extension fields are
bounded but do not cross that typed boundary.

Search authority, egress proof, domain policy, call ceilings, strict lifecycle
correlation, and recognized-result URL/domain validation are unchanged. The AI
schema module remains **0.63.0**; there is no database, data, GraphQL SDL,
protected-payload, backup, or restore migration.

## 0.94.0 to 0.95.0: retained dynamic tools with governed built-ins

Adopt `graphql-orm-ai` 0.95.0 from one reviewed full monorepo revision. Hosts
Expand Down
14 changes: 10 additions & 4 deletions 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.95.0", 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.95.1", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -165,9 +165,15 @@ call ceiling. Its other sole process-level exception is `code_mode_host`:
`dynamicToolCall` delivery on that Codex version, so the launch arguments omit
only that flag while the per-thread configuration still sets the feature
false. When native search is enabled, the actor admits only its exact bounded
item lifecycle and exposes structured result metadata for host accounting and
audit; results enter model context before that completion event. Reverify both
direct delivery and the negative native-item matrix before upgrading Codex.
item lifecycle and exposes recognized, validated structured result metadata
for host accounting and audit; results enter model context before that
completion event. Completion queries follow the action contract, so non-search
actions may report an empty query. The provider's opaque result array is
bounded as a whole; recognized text results retain strict HTTPS,
normalized-domain, allow-domain, reference, and field-size validation, while
unrecognized result types and unexposed extension fields do not become host
metadata. Reverify both direct delivery and the negative native-item matrix
before upgrading Codex.

The Codex schema projector preserves bounded nullable scalar `type` arrays in
the crate-authored FixedBroker definitions. It does not pass through arbitrary
Expand Down
9 changes: 6 additions & 3 deletions crates/graphql-orm-ai/docs/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ supersedes: []

# Implementation Status

`graphql-orm-ai` is at crate version `0.95.0` with AI schema module
`graphql-orm-ai` is at crate version `0.95.1` with AI schema module
`0.63.0`. It uses workspace `graphql-orm` `0.26.0`, backend-neutral
`graphql-orm-ai-tool-profiles` `0.10.0`, and external `agql-auth`
`0.18.0` at `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`.
Expand Down Expand Up @@ -47,8 +47,11 @@ verification evidence belongs in the focused guides.
dynamic-tools-only launch profile, a direct-tool model declaration, and a
process-factory profile attestation; every exact call
rechecks current rules and uses the ordinary registered GraphQL tool,
disclosure, egress, budget, and resolver authorization path. Generic
protocol bridging, shell, files, hosted web, MCP, and browser remain closed.
disclosure, egress, budget, and resolver authorization path. Native web
search is separately default-off and requires exact request capability,
egress, domain policy, and call ceilings; its opaque result envelope is
bounded while only validated structured metadata crosses the host boundary.
Generic protocol bridging, shell, files, MCP, and browser remain closed.
Initialization uses one library-owned notification opt-out profile, while
response-authoritative deletion, empty reasoning lifecycle, and retained
cumulative-usage replay are admitted only through typed content-free
Expand Down
230 changes: 164 additions & 66 deletions crates/graphql-orm-ai/src/providers/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3227,9 +3227,11 @@ pub enum AiCodexAppServerInbound {
call_id: String,
/// Provider-authored action, available on completion when reported.
action: Option<AiCodexAppServerWebSearchAction>,
/// Bounded provider-authored query, absent for an empty start frame.
/// Bounded provider-authored query, absent on start and possibly empty
/// on completion for non-search actions.
query: Option<String>,
/// Validated structured search results, empty on start or no matches.
/// Validated structured search results, empty on start, no matches, or
/// when the provider reports only opaque forward-compatible results.
results: Vec<AiCodexAppServerWebSearchResult>,
/// Whether this is the terminal completion lifecycle.
completed: bool,
Expand Down Expand Up @@ -4536,11 +4538,7 @@ impl AiCodexAppServerProtocolActor {
let query = item
.get("query")
.and_then(Value::as_str)
.filter(|query| {
!query.trim().is_empty()
&& query.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES
&& !query.contains('\0')
})
.filter(|query| query.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !query.contains('\0'))
.ok_or(ProviderError::Rejected)?
.to_owned();
let domain_policy = self
Expand Down Expand Up @@ -5113,10 +5111,11 @@ fn parse_web_search_action(
return Err(ProviderError::Rejected);
}
let query = bounded_optional_web_search_string(object.get("query"))?;
if query
.as_deref()
.is_some_and(|query| query != completed_query)
{
if query.as_deref().is_some_and(|query| {
!query.trim().is_empty()
&& !completed_query.trim().is_empty()
&& query != completed_query
}) {
return Err(ProviderError::Rejected);
}
let queries = match object.get("queries") {
Expand All @@ -5131,6 +5130,12 @@ fn parse_web_search_action(
.collect::<Result<Vec<_>, _>>()?,
_ => return Err(ProviderError::Rejected),
};
if completed_query.trim().is_empty()
&& query.as_deref().is_none_or(|query| query.trim().is_empty())
&& queries.is_empty()
{
return Err(ProviderError::Rejected);
}
AiCodexAppServerWebSearchAction::Search { query, queries }
}
"openPage" => {
Expand Down Expand Up @@ -5196,61 +5201,60 @@ fn parse_web_search_results(
Some(Value::Array(values)) if values.len() <= MAXIMUM_WEB_SEARCH_RESULTS_PER_CALL => values,
_ => return Err(ProviderError::Rejected),
};
let mut total_bytes = 0_usize;
values
.iter()
.map(|value| {
let object = value.as_object().ok_or(ProviderError::Rejected)?;
if object.keys().any(|key| {
!matches!(
key.as_str(),
"domain" | "ref_id" | "snippet" | "title" | "type" | "url"
)
}) || object.get("type").and_then(Value::as_str) != Some("text_result")
{
return Err(ProviderError::Rejected);
}
let required = |key: &str| {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| {
value.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !value.contains('\0')
})
.ok_or(ProviderError::Rejected)
};
let domain = required("domain")?;
if domain != domain.to_ascii_lowercase()
|| !crate::provider::valid_web_domain(domain)
|| !web_search_domain_allowed(domain, domain_policy)
{
return Err(ProviderError::Rejected);
}
let url = required("url")?;
validate_web_search_url(url, Some(domain), domain_policy)?;
let title = required("title")?;
let snippet = required("snippet")?;
let reference_id = required("ref_id")?;
if !valid_reference(reference_id) {
return Err(ProviderError::Rejected);
}
for field in [domain, url, title, snippet, reference_id] {
total_bytes = total_bytes
.checked_add(field.len())
.ok_or(ProviderError::Rejected)?;
}
if total_bytes > MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL {
return Err(ProviderError::Rejected);
}
Ok(AiCodexAppServerWebSearchResult {
domain: domain.to_owned(),
url: url.to_owned(),
title: title.to_owned(),
snippet: snippet.to_owned(),
reference_id: reference_id.to_owned(),
})
})
.collect()
// Codex deliberately keeps this array opaque at the app-server boundary
// so result fields and result kinds can evolve independently. Bound the
// complete wire payload before selecting the stable metadata subset that
// this adapter is prepared to expose.
if serde_json::to_vec(values)
.map_err(|_| ProviderError::Rejected)?
.len()
> MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL
{
return Err(ProviderError::Rejected);
}
let mut results = Vec::new();
for value in values {
let Some(object) = value.as_object() else {
continue;
};
// Unknown opaque entries remain available to the provider's own model
// context but never become trusted or typed host metadata.
if object.get("type").and_then(Value::as_str) != Some("text_result") {
continue;
}
let required = |key: &str| {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| {
value.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !value.contains('\0')
})
.ok_or(ProviderError::Rejected)
};
let domain = required("domain")?;
if domain != domain.to_ascii_lowercase()
|| !crate::provider::valid_web_domain(domain)
|| !web_search_domain_allowed(domain, domain_policy)
{
return Err(ProviderError::Rejected);
}
let url = required("url")?;
validate_web_search_url(url, Some(domain), domain_policy)?;
let title = required("title")?;
let snippet = required("snippet")?;
let reference_id = required("ref_id")?;
if !valid_reference(reference_id) {
return Err(ProviderError::Rejected);
}
results.push(AiCodexAppServerWebSearchResult {
domain: domain.to_owned(),
url: url.to_owned(),
title: title.to_owned(),
snippet: snippet.to_owned(),
reference_id: reference_id.to_owned(),
});
}
Ok(results)
}

fn validate_web_search_url(
Expand Down Expand Up @@ -11160,6 +11164,100 @@ pub(crate) mod tests {
));
}

#[test]
fn native_web_search_admits_empty_other_and_opaque_result_extensions() {
let policy = ModelWebSearchDomainPolicy::allowed_domains(vec!["example.com".to_owned()])
.expect("allow-domain policy should validate");
let mut actor = active_web_search_protocol_actor(policy, 1);
actor
.accept(&lifecycle_notification(
"item/started",
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"startedAtMs": 1,
"item": {"action": null, "id": "search-1", "query": "", "results": null, "type": "webSearch"}
}),
))
.expect("web-search start should be admitted");
let completed = actor
.accept(&lifecycle_notification(
"item/completed",
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"completedAtMs": 2,
"item": {
"action": {"type": "other"},
"id": "search-1",
"query": "",
"results": [
{
"domain": "www.example.com",
"ref_id": "turn0search0",
"snippet": "Example result",
"thumbnail_url": "https://www.example.com/thumbnail.png",
"title": "Example Domain",
"type": "text_result",
"url": "https://www.example.com/"
},
{
"payload": {"future": true},
"type": "future_result"
},
true
],
"type": "webSearch"
}
}),
))
.expect("schema-compatible opaque result extensions should be admitted");
match completed {
AiCodexAppServerInbound::WebSearchLifecycle {
action,
query,
results,
completed,
..
} => {
assert_eq!(action, Some(AiCodexAppServerWebSearchAction::Other));
assert_eq!(query.as_deref(), Some(""));
assert!(completed);
assert_eq!(results.len(), 1);
assert_eq!(results[0].domain(), "www.example.com");
}
other => panic!("unexpected inbound: {other:?}"),
}
}

#[test]
fn native_web_search_bounds_opaque_results_and_requires_search_terms() {
let oversized = "x".repeat(MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL);
assert!(matches!(
parse_web_search_results(
Some(&json!([{"payload": oversized, "type": "future_result"}])),
&ModelWebSearchDomainPolicy::PublicWeb,
),
Err(ProviderError::Rejected)
));
assert!(matches!(
parse_web_search_action(
Some(&json!({"query": "", "queries": [], "type": "search"})),
"",
&ModelWebSearchDomainPolicy::PublicWeb,
),
Err(ProviderError::Rejected)
));
assert!(matches!(
parse_web_search_action(
Some(&json!({"queries": ["example.com"], "type": "search"})),
"",
&ModelWebSearchDomainPolicy::PublicWeb,
),
Ok(Some(AiCodexAppServerWebSearchAction::Search { .. }))
));
}

#[test]
fn native_web_search_rejects_results_outside_the_active_allow_domains() {
let policy = ModelWebSearchDomainPolicy::allowed_domains(vec!["example.com".to_owned()])
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/workspace-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ changes.
| Package | Version | Path | Default features | Direct internal dependencies |
| --- | --- | --- | --- | --- |
| `graphql-orm` | `0.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) |
| `graphql-orm-ai` | `0.95.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` |
| `graphql-orm-ai` | `0.95.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` |
| `graphql-orm-ai-tool-profiles` | `0.10.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) |
| `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` |
| `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none |
Expand Down