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.

28 changes: 27 additions & 1 deletion crates/graphql-orm-ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Changelog"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-08-28
last_reviewed: 2026-09-01
review_by: 2027-02-01
supersedes: []
---
Expand All @@ -18,6 +18,32 @@ 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.11] - 2026-09-01

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

### Fixed

- An absence-proven deleted provider-session generation can now rebind under
the current server-authored provider, registration, policy, model, protocol,
and capability descriptor. A reviewed runtime change no longer strands the
durable application session behind its historical descriptor.
- Losing the replacement compare-and-swap discards the fresh empty provider
session and safely defers the run instead of reporting an uncertain provider
turn. A residual deleted tombstone is likewise treated as a safe deferral.

### Security

- Live cursor resume still requires the complete descriptor to match exactly.
Replacement remains available only after exact provider absence is persisted
and the protected cursor is cleared. Rebind atomically revalidates the owner,
session, scope, run, attempt, lease, row, claim, cleanup generation, absence
evidence, canonical transcript, and current descriptor before any business
input or tool request crosses the provider boundary.

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

## [0.95.10] - 2026-08-28

Persistent schema module: **0.64.0** (unchanged from 0.95.9).
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.10"
version = "0.95.11"
edition = "2024"
authors = ["Toby Martin <toby@dastari.net>"]
description = "Project-agnostic AI agent runtime for graphql-orm applications"
Expand Down
21 changes: 20 additions & 1 deletion crates/graphql-orm-ai/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Migration Guide"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-08-28
last_reviewed: 2026-09-01
review_by: 2027-02-01
supersedes: []
---
Expand All @@ -19,6 +19,25 @@ 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.10 to 0.95.11: absence-proven descriptor replacement

Adopt `graphql-orm-ai` 0.95.11 from one reviewed full monorepo revision. No
host API or configuration change is required. After cleanup has persisted
exact provider absence and cleared the protected cursor, a later run may
replace the deleted generation under its current server-authored provider,
registration, policy, model, protocol, and capability descriptor. The
historical descriptor does not authorize or fence the replacement generation.

Live cursor resume still requires an exact complete descriptor match. Rebind
continues to revalidate every owner, session, scope, run, attempt, lease, row,
claim, cleanup-generation, absence, transcript, and current-descriptor fence.
If concurrent replacement wins first, the losing fresh empty provider session
is discarded and the run is safely deferred before business input or a tool
request is sent.

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.9 to 0.95.10: stable Codex cleanup namespace

Adopt `graphql-orm-ai` 0.95.10 from one reviewed full monorepo revision. No host
Expand Down
10 changes: 7 additions & 3 deletions crates/graphql-orm-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "graphql-orm-ai"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-08-28
last_reviewed: 2026-09-01
review_by: 2027-02-01
supersedes: []
---
Expand All @@ -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.10", 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.11", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -154,7 +154,11 @@ message under current policy where re-execution is provably safe;
`acknowledgeAiRunFailure` dismisses a failure without deleting audit history.
Invalidating a retained provider thread emits `provider_session_reset` or
`provider_session_rebound` so a host can tell the user the model's context was
reset even though the durable transcript reads as continuous.
reset even though the durable transcript reads as continuous. Once exact
provider absence is persisted, a later run may replace the deleted generation
under its current server-authored descriptor; the historical descriptor grants
no replacement authority. A lost rebind fence discards the fresh empty
provider session and defers safely before any business input or tool request.

Codex cleanup treats a successful empty `thread/delete` result as authoritative.
The provider profile ID and versioned cursor kind form its stable provider-state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Provider Sessions, Hosted Search, and Visible Activity"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-08-15
last_reviewed: 2026-09-01
review_by: 2027-02-11
supersedes: []
---
Expand Down Expand Up @@ -538,10 +538,15 @@ eligibility from raw state fields. `RebindAllowed` is a crate-issued,
short-lived authorization available only after cleanup persisted exact
provider absence in a `Deleted` tombstone. `rebind_for_run` rehydrates current
authority and atomically compares the exact owner/session/scope, run/attempt/
lease fence, deleted row and cleanup generations, provider descriptor, and
host-authored canonical transcript fingerprint. It protects a fresh cursor in
the same row, so the unique session binding remains intact and concurrent
replacement has one winner. Cleared cursor material is never reopened.
lease fence, deleted row and cleanup generations, current server-authored
replacement descriptor, and host-authored canonical transcript fingerprint.
Exact provider absence severs the deleted generation from its historical
descriptor, so an approved provider, registration, model, protocol, policy, or
capability change cannot strand the durable application session. The current
descriptor is bound into the short-lived authorization and persisted with a
fresh cursor in the same row, so the unique session binding remains intact and
concurrent replacement has one winner. Cleared cursor material is never
reopened.

A managed cleanup loop performs:

Expand All @@ -556,7 +561,8 @@ An ordinary later run may replace only that exact absence-proven tombstone.
Expiry, process death, transport failure, cleanup backoff, and restore
quarantine remain unavailable. If creation of the replacement empty thread
succeeds but its rebind CAS loses, `AiProviderCallExecutor` invokes the exact
registered provider discard boundary.
registered provider discard boundary and safely defers the run. No business
input or tool request crosses the provider boundary before the rebind commits.

### Parking across approval and subscription waits

Expand Down
10 changes: 8 additions & 2 deletions crates/graphql-orm-ai/src/orm_provider_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,9 +572,15 @@ mod service {
}
if state == AiProviderSessionState::Deleted
&& binding.last_run_id != Some(lease.run_id().0)
&& descriptor_from_record(binding).map_err(ai_error_to_orm)?
== *planned.descriptor()
{
// Exact provider absence severs the old cursor from
// the next generation. The tombstone's historical
// descriptor must therefore not strand the durable
// application session after a reviewed provider,
// registration, policy, or capability change. The
// current server-authored descriptor is instead
// bound into this short-lived run authorization and
// atomically persisted with the fresh empty cursor.
let provider_absence_observed_at = binding
.provider_absence_observed_at
.and_then(|value| OffsetDateTime::from_unix_timestamp(value).ok())
Expand Down
16 changes: 12 additions & 4 deletions crates/graphql-orm-ai/src/provider_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2940,7 +2940,14 @@ impl AiProviderCallExecutor {
&cursor,
)
.await;
return Err(error);
return Err(match error {
// No business input is sent before the empty
// provider session is durably rebound. Losing that
// persistence fence is therefore a safe deferral,
// not an uncertain provider turn.
AiError::Conflict => AiError::ProviderSessionDeferred,
other => other,
});
}
}
}
Expand All @@ -2961,7 +2968,8 @@ impl AiProviderCallExecutor {
crate::AiProviderSessionRunDisposition::Unavailable(
crate::AiProviderSessionState::CleanupRequired
| crate::AiProviderSessionState::CleanupInProgress
| crate::AiProviderSessionState::CleanupBackoff,
| crate::AiProviderSessionState::CleanupBackoff
| crate::AiProviderSessionState::Deleted,
) => {
return Err(AiError::ProviderSessionDeferred);
}
Expand Down Expand Up @@ -9702,7 +9710,7 @@ mod tests {
}

#[tokio::test]
async fn rebind_fence_loss_discards_the_exact_new_empty_provider_session() {
async fn rebind_fence_loss_discards_the_empty_session_and_defers_safely() {
let cursor = AiProviderSessionCursor::new("mock.thread", "losing-rebind-thread")
.expect("test cursor should validate");
let fixture = fixture_with_provider(
Expand Down Expand Up @@ -9760,7 +9768,7 @@ mod tests {
None,
)
.await,
Err(AiError::Conflict)
Err(AiError::ProviderSessionDeferred)
));
assert_eq!(fixture.mock.discarded_provider_session_count(), 1);
assert_eq!(fixture.mock.request_count(), 0);
Expand Down
11 changes: 7 additions & 4 deletions crates/graphql-orm-ai/src/provider_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,9 @@ pub struct AiProviderSessionBindingView {
/// cursor and grants no provider egress, tool, GraphQL, or application
/// authority. [`AiProviderSessionService::rebind_for_run`] rehydrates the
/// current principal and atomically revalidates every field before replacing
/// the tombstone.
/// the tombstone under the current server-authored descriptor. Exact provider
/// absence severs the deleted generation from its historical descriptor; that
/// descriptor grants no authority over the replacement generation.
#[derive(Clone, PartialEq, Eq)]
pub struct AiProviderSessionRebindAuthorization {
pub(crate) binding_id: Uuid,
Expand Down Expand Up @@ -553,7 +555,7 @@ pub enum AiProviderSessionRunDisposition {
/// A confirmed wait was already reclaimed for this exact run fence.
Reclaimed(Box<AiProviderSessionClaim>),
/// Exact provider absence was proven and this run may replace the
/// tombstone once.
/// tombstone once under its current server-authored descriptor.
RebindAllowed(Box<AiProviderSessionRebindAuthorization>),
/// A binding exists but cannot safely serve the planned run.
Unavailable(AiProviderSessionState),
Expand Down Expand Up @@ -1398,8 +1400,9 @@ pub trait AiProviderSessionService: Send + Sync {
/// # Errors
///
/// Returns an error when current authority, the run fence, the
/// crate-issued authorization, deleted generation, provider descriptor,
/// transcript prefix, or protected replacement cursor is not exact.
/// crate-issued authorization, deleted generation, current replacement
/// descriptor, transcript prefix, or protected replacement cursor is not
/// exact.
async fn rebind_for_run(
&self,
_lease: &AiRunLease,
Expand Down
39 changes: 15 additions & 24 deletions crates/graphql-orm-ai/tests/provider_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,30 +839,20 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() {
.await
.expect("uncertain run should finish recovery-required");
let second_run = next_active_run(&fixture, &owner, first_run.session_id()).await;
let second_plan = AiProviderSessionTurnPlan::new(descriptor.clone(), "d".repeat(64))
.expect("rebind plan should validate");
let changed_descriptor = AiProviderSessionDescriptor::new(
ProviderKind::LocalHarness,
"reviewed-local-profile",
"changed-model",
"a".repeat(64),
"codex-app-server/v2",
"b".repeat(64),
ProviderKind::OpenAiCompatible,
"replacement-profile",
"replacement-model",
"e".repeat(64),
"replacement-protocol/v3",
"f".repeat(64),
)
.expect("changed descriptor should validate");
let changed_plan = AiProviderSessionTurnPlan::new(changed_descriptor, "d".repeat(64))
let changed_plan = AiProviderSessionTurnPlan::new(changed_descriptor.clone(), "d".repeat(64))
.expect("changed plan should validate");
assert!(matches!(
fixture
.provider_sessions
.disposition_for_run(&second_run, &changed_plan)
.await
.expect("changed descriptor disposition should resolve"),
AiProviderSessionRunDisposition::Unavailable(AiProviderSessionState::Deleted)
));
let authorization = match fixture
.provider_sessions
.disposition_for_run(&second_run, &second_plan)
.disposition_for_run(&second_run, &changed_plan)
.await
.expect("deleted binding should be classified")
{
Expand All @@ -878,8 +868,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() {
&other_run,
authorization.clone(),
AiProviderSessionBindRequest::new(
descriptor.clone(),
AiProviderSessionCursor::new("codex.thread", "swapped-owner-thread")
changed_descriptor.clone(),
AiProviderSessionCursor::new("replacement.thread", "swapped-owner-thread")
.expect("swapped cursor should validate"),
"d".repeat(64),
None,
Expand All @@ -896,8 +886,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() {
&second_run,
authorization.clone(),
AiProviderSessionBindRequest::new(
descriptor.clone(),
AiProviderSessionCursor::new("codex.thread", "swapped-transcript-thread")
changed_descriptor.clone(),
AiProviderSessionCursor::new("replacement.thread", "swapped-transcript-thread")
.expect("swapped cursor should validate"),
"e".repeat(64),
None,
Expand All @@ -910,8 +900,8 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() {
let stale_authorization = authorization.clone();
let request = || {
AiProviderSessionBindRequest::new(
descriptor.clone(),
AiProviderSessionCursor::new("codex.thread", "fresh-thread")
changed_descriptor.clone(),
AiProviderSessionCursor::new("replacement.thread", "fresh-thread")
.expect("fresh cursor should validate"),
"d".repeat(64),
None,
Expand All @@ -930,6 +920,7 @@ async fn exact_absence_authorizes_one_fenced_rebind_with_a_fresh_cursor() {
(Ok(claim), Err(AiError::Conflict)) | (Err(AiError::Conflict), Ok(claim)) => claim,
outcomes => panic!("exactly one rebind should win: {outcomes:?}"),
};
assert_eq!(rebound.descriptor(), &changed_descriptor);
let opened = fixture
.provider_sessions
.open_for_run(&second_run, &rebound)
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.30.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) |
| `graphql-orm-ai` | `0.95.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` |
| `graphql-orm-ai` | `0.95.11` | `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.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` |
| `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none |
Expand Down