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.

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

Persistent schema module: **0.64.0** (unchanged from 0.95.9).

### Fixed

- Retained Codex cleanup now uses the stable provider profile and versioned
cursor kind as its provider-state namespace. Older protected threads can be
deleted after model, executable, sandbox, bootstrap, tool, reasoning,
registration-fingerprint, or wire-profile changes instead of remaining in
cleanup backoff.

### Security

- Mutable registration fields remain mandatory for create, resume, and every
turn. Cleanup requests still originate only after the protected cursor's
authenticated binding to the complete durable descriptor and cursor
fingerprint is verified. The deletion adapter rejects a different provider
family, provider profile, or cursor kind before launching a process, and the
exact deletion/absence actor remains unchanged and fail-closed.

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

## [0.95.9] - 2026-08-28

Persistent schema module: **0.64.0** (unchanged from 0.95.8).
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.9"
version = "0.95.10"
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.95.9 to 0.95.10: stable Codex cleanup namespace

Adopt `graphql-orm-ai` 0.95.10 from one reviewed full monorepo revision. No host
API or configuration change is required when the existing provider profile ID
continues to identify the same Codex state store. Cleanup now admits an older
durable descriptor by the stable local-provider profile and versioned cursor
kind after the protected cursor has been authenticated against that complete
descriptor. Mutable model, executable, sandbox, bootstrap, tool, reasoning,
registration-fingerprint, and app-server protocol values no longer strand
provider resources after an upgrade.

Those mutable values remain exact admission fences for empty-thread creation,
resume, and every turn. Cross-provider, cross-profile, and cross-cursor-kind
cleanup remains rejected before process launch. Hosts must not reuse one
provider profile ID for different state stores; drain retained sessions before
renaming a profile or moving its state namespace.

The AI schema module remains **0.64.0**. There is no database, data, GraphQL
SDL, protected-payload, backup, restore, or data backfill migration.

## 0.95.8 to 0.95.9: provider-native retained-thread absence proof

Adopt `graphql-orm-ai` 0.95.9 from one reviewed full monorepo revision. Existing
Expand Down
15 changes: 13 additions & 2 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.9", 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.10", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -65,7 +65,9 @@ the compiled test-backed recipe and the missing reusable bootstrap API.
discarding the host's durable conversation history. If deletion returns an
unstructured provider error, a second fresh process may instead prove absence
by exhaustively scanning the bounded active and archived state index. Raw
thread metadata is discarded inside the protocol actor.
thread metadata is discarded inside the protocol actor. Cleanup remains
available across mutable registration, model, and wire-profile upgrades when
the host preserves the same provider-state profile and versioned cursor kind.
- Watermark-bounded, contiguous durable session and owner-inbox replay whose
`HasMore` contract remains correct at the configured ORM page maximum.
- Default-deny application tools with server-authored documents and static
Expand Down Expand Up @@ -155,6 +157,15 @@ Invalidating a retained provider thread emits `provider_session_reset` or
reset even though the durable transcript reads as continuous.

Codex cleanup treats a successful empty `thread/delete` result as authoritative.
The provider profile ID and versioned cursor kind form its stable provider-state
namespace. A protected cursor is opened only after its authenticated binding to
the durable descriptor is verified, so mutable model, executable, tool,
registration-fingerprint, and protocol changes cannot strand an old resource.
Those mutable fields still fence every create, resume, and turn. A different
provider family, profile ID, or cursor kind remains rejected before launch.
Hosts must preserve one profile ID only for one provider state store; moving or
renaming that namespace requires draining its retained sessions first.

An ambiguous delete failure is never interpreted by error message. The reviewed
fallback launches a separate initialized process and scans every source kind in
both active and archived `thread/list` state-DB partitions with stable creation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,15 @@ Use `fingerprint`, not `registration.identity()`, in
`ModelRequest` and matching `AiBudgetReservationRequest` carries
`selected_effort`. A changed setting cannot resume the cursor: clean up and
confirm exact absence, issue the ordinary rebind, create a new empty thread,
and bind the new effort fingerprint. Pre-0.80 v3 registration fingerprints
are accepted only by the deletion adapter for draining; they are never valid
for a turn or resume.
and bind the new effort fingerprint. Cleanup deliberately does not reuse this
mutable execution fingerprint as its namespace. After the durable service
authenticates the protected cursor against the complete stored descriptor, the
deletion adapter admits only the same local provider family, provider profile
ID, and versioned cursor kind. Model, executable, sandbox, bootstrap, tool,
reasoning, registration-fingerprint, and wire-profile changes therefore cannot
strand an older resource, while every create, resume, and turn remains fenced
by their exact current values. A host must preserve one provider profile ID for
one provider state store and drain sessions before moving or renaming it.

A retained capability-delivery descriptor persists the complete
`AiProviderCapabilitySessionBinding::fingerprint()` instead of that raw value.
Expand Down
150 changes: 109 additions & 41 deletions crates/graphql-orm-ai/src/providers/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,9 +685,9 @@ impl AiCodexAppServerRegistration {
.is_some_and(|binding| binding.reasoning_effort() == effort)
}

fn admits_cleanup_fingerprint(&self, fingerprint: &str) -> bool {
self.admits_provider_session_fingerprint(fingerprint)
|| legacy_registration_identity_v3(self) == fingerprint
fn owns_cleanup_namespace(&self, descriptor: &crate::AiProviderSessionDescriptor) -> bool {
descriptor.provider_kind() == &ProviderKind::LocalHarness
&& descriptor.provider_profile_id() == self.provider_profile_id
}

fn reasoning_effort_capabilities(&self) -> ProviderCapabilities {
Expand Down Expand Up @@ -1282,13 +1282,7 @@ impl crate::AiProviderSessionDeletionService for AiCodexAppServerProviderSession
request: &crate::AiProviderSessionDeletionRequest,
) -> Result<crate::AiProviderSessionAbsenceProof, ProviderError> {
let descriptor = request.claim().descriptor();
if descriptor.provider_kind() != &ProviderKind::LocalHarness
|| descriptor.provider_profile_id() != self.registration.provider_profile_id()
|| descriptor.provider_model() != self.registration.logical_model()
|| !self
.registration
.admits_cleanup_fingerprint(descriptor.registration_fingerprint())
|| descriptor.protocol_version() != self.registration.protocol_version()
if !self.registration.owns_cleanup_namespace(descriptor)
|| request.cursor().kind() != "codex.app_server.thread.v2"
{
return Err(ProviderError::Rejected);
Expand Down Expand Up @@ -6393,28 +6387,6 @@ fn registration_identity(registration: &AiCodexAppServerRegistration) -> String
hex::encode(hasher.finalize())
}

fn legacy_registration_identity_v3(registration: &AiCodexAppServerRegistration) -> String {
let mut hasher = Sha256::new();
hasher.update(b"graphql-orm-ai/codex-app-server-registration/v3\0");
for value in [
registration.provider_profile_id.as_str(),
registration.logical_model.as_str(),
registration.executable_sha256.as_str(),
registration.executable_version.as_str(),
registration.sandbox_profile.as_str(),
registration.protocol_version.as_str(),
] {
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value.as_bytes());
}
let profile = registration.launch_profile.identity_label();
hasher.update((profile.len() as u64).to_be_bytes());
hasher.update(profile.as_bytes());
hasher.update((registration.bootstrap_instructions.fingerprint().len() as u64).to_be_bytes());
hasher.update(registration.bootstrap_instructions.fingerprint().as_bytes());
hex::encode(hasher.finalize())
}

#[cfg(test)]
pub(crate) mod tests {
use std::io::{BufRead, BufReader, Write};
Expand All @@ -6426,7 +6398,8 @@ pub(crate) mod tests {
use std::thread::{self, JoinHandle};

use agql_auth::{
AccessTokenMetadata, AuthPrincipal, AuthUser, PrincipalReference, SessionContext,
AccessTokenMetadata, AuthPrincipal, AuthUser, FixedClock, PrincipalReference,
SessionContext,
};
use futures::stream;
use graphql_orm::graphql::orm::{
Expand All @@ -6445,8 +6418,9 @@ pub(crate) mod tests {
use crate::{
AiGraphqlMutationCapabilityCatalog, AiGraphqlQueryCapabilityCatalog,
AiGraphqlQueryCapabilityLimits, AiGraphqlSubscriptionCapabilityCatalog,
AiGraphqlSubscriptionCapabilityLimits, AiRunId, AiSessionId, AiToolCatalog, AiToolId,
GraphqlExecutionTargetId, ProviderDynamicToolResult, ProviderEvent,
AiGraphqlSubscriptionCapabilityLimits, AiProviderSessionDeletionService, AiRunId, AiScope,
AiSessionId, AiToolCatalog, AiToolId, GraphqlExecutionTargetId, ProviderDynamicToolResult,
ProviderEvent,
};
use uuid::Uuid;

Expand Down Expand Up @@ -7411,10 +7385,6 @@ pub(crate) mod tests {
})
.collect::<BTreeSet<_>>();
assert_eq!(fingerprints.len(), efforts.len());
let legacy = legacy_registration_identity_v3(&reviewed);
assert!(!reviewed.admits_provider_session_fingerprint(&legacy));
assert!(reviewed.admits_cleanup_fingerprint(&legacy));

let subset = ModelReasoningEffortProfile::new(
"model-1",
[ModelReasoningEffort::Low, ModelReasoningEffort::Medium],
Expand Down Expand Up @@ -7463,7 +7433,6 @@ pub(crate) mod tests {
binding.fingerprint(),
ModelReasoningEffort::Unspecified,
));
assert!(admitted.admits_cleanup_fingerprint(binding.fingerprint()));
let changed = admitted.with_bootstrap_instructions(trusted_bootstrap());
assert!(!changed.admits_provider_session_fingerprint(binding.fingerprint()));

Expand Down Expand Up @@ -9483,6 +9452,106 @@ pub(crate) mod tests {
);
}

fn cleanup_request(
descriptor: crate::AiProviderSessionDescriptor,
cursor: crate::AiProviderSessionCursor,
) -> crate::AiProviderSessionDeletionRequest {
let now = time::OffsetDateTime::now_utc();
crate::AiProviderSessionDeletionRequest::new(
crate::AiProviderSessionCleanupClaim {
binding_id: Uuid::new_v4(),
session_id: AiSessionId(Uuid::new_v4()),
scope: AiScope::new("project", "cleanup-test"),
descriptor,
cleanup_worker_id: "cleanup-worker".to_owned(),
cleanup_generation: 1,
cleanup_expires_at: now + time::Duration::minutes(1),
row_version: 1,
},
cursor,
)
}

#[tokio::test]
async fn detached_cleanup_uses_stable_profile_and_cursor_namespace() {
let registration = registration("2.0.0");
let counters = Arc::new(Counters::new());
let provider =
AiCodexAppServerProvider::new(registration.clone(), pool(counters.clone(), 2, 4));
let now = time::OffsetDateTime::now_utc();
let deletion = provider.provider_session_deletion_service(Arc::new(FixedClock::new(now)));
let cursor = crate::AiProviderSessionCursor::new(
"codex.app_server.thread.v2",
"thread-retained-test",
)
.expect("retired cursor should validate");
let retired = crate::AiProviderSessionDescriptor::new(
ProviderKind::LocalHarness,
registration.provider_profile_id(),
"retired-model",
"f".repeat(64),
"retired-app-server-protocol",
"e".repeat(64),
)
.expect("retired descriptor should validate");

deletion
.delete_or_confirm_absent(&cleanup_request(retired, cursor.clone()))
.await
.expect("the stable provider-state namespace should admit cleanup");
assert_eq!(counters.launches.load(Ordering::SeqCst), 1);
assert_eq!(counters.deleted_threads.load(Ordering::SeqCst), 1);

for rejected in [
crate::AiProviderSessionDescriptor::new(
ProviderKind::LocalHarness,
"another-profile",
registration.logical_model(),
"a".repeat(64),
registration.protocol_version(),
"e".repeat(64),
)
.expect("cross-profile descriptor should validate"),
crate::AiProviderSessionDescriptor::new(
ProviderKind::OpenAi,
registration.provider_profile_id(),
registration.logical_model(),
"a".repeat(64),
registration.protocol_version(),
"e".repeat(64),
)
.expect("cross-provider descriptor should validate"),
] {
assert!(matches!(
deletion
.delete_or_confirm_absent(&cleanup_request(rejected, cursor.clone()))
.await,
Err(ProviderError::Rejected)
));
}
let wrong_cursor =
crate::AiProviderSessionCursor::new("another.thread.kind", "thread-retained-test")
.expect("cross-kind cursor should validate");
let current = crate::AiProviderSessionDescriptor::new(
ProviderKind::LocalHarness,
registration.provider_profile_id(),
registration.logical_model(),
registration
.provider_session_fingerprint(ModelReasoningEffort::Unspecified)
.expect("current fingerprint should validate"),
registration.protocol_version(),
"e".repeat(64),
)
.expect("current descriptor should validate");
assert!(matches!(
deletion
.delete_or_confirm_absent(&cleanup_request(current, wrong_cursor))
.await,
Err(ProviderError::Rejected)
));
assert_eq!(counters.launches.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn empty_retained_thread_binds_exact_dynamic_definitions_before_content() {
let counters = Arc::new(Counters::new());
Expand Down Expand Up @@ -9687,7 +9756,6 @@ pub(crate) mod tests {
assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1);
assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 1);
assert_eq!(counters.deleted_threads.load(Ordering::SeqCst), 1);
assert!(registration.admits_cleanup_fingerprint(descriptor.registration_fingerprint()));
}

#[tokio::test]
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.27.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) |
| `graphql-orm-ai` | `0.95.9` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` |
| `graphql-orm-ai` | `0.95.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` |
| `graphql-orm-ai-tool-profiles` | `0.10.2` | `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.27.0` | `crates/graphql-orm-macros` | `sqlite` | none |
Expand Down
Loading