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.

29 changes: 28 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-09-01
last_reviewed: 2026-09-02
review_by: 2027-02-01
supersedes: []
---
Expand All @@ -18,6 +18,33 @@ 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.96.0] - 2026-09-02

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

### Fixed

- An active retained provider-session binding whose exact server-authored
descriptor, transcript fingerprint, watermark, or lifetime no longer
matches the current turn is now atomically fenced into cleanup. The run
defers before provider dispatch, exact absence is recorded, and a compatible
generation may rebind instead of leaving the application session stranded.
- A `RecoveryRequired` run may now be retried when committed rows prove both
that no assistant output exists and that no provider dispatch was possible.
Missing reservations and exclusively released/expired reservations are the
only no-dispatch proofs.

### Security

- Reserved, committed, uncertain, and unknown reservation states continue to
refuse replay. Same-run replacement after cleanup is authorized only when
exact provider absence and committed no-dispatch evidence both exist; an
incompatible live cursor is never resumed or silently replaced.

`AiRunRetryEvidence` adds the required `provider_dispatch_possible` field.
There is no database, data, table, column, index, constraint, backfill,
protected-payload, GraphQL SDL, backup, or restore migration.

## [0.95.14] - 2026-09-01

Persistent schema module: **0.64.0** (unchanged from 0.95.13).
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.14"
version = "0.96.0"
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-09-01
last_reviewed: 2026-09-02
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.14 to 0.96.0: retained-plan recovery and no-dispatch retry proof

Adopt `graphql-orm-ai` 0.96.0 from one reviewed full monorepo revision. Hosts
that construct `AiRunRetryEvidence` must add `provider_dispatch_possible` from
committed reservation rows. Set it to `false` only when the exact run has no
reservation or every reservation is durably `released`/`expired`; use `true`
for reserved, committed, uncertain, unrecognized, or unavailable evidence.

The ORM provider-session service now atomically moves an incompatible active
binding into cleanup during disposition. Provider execution treats unavailable
bindings as a pre-dispatch deferral, allowing the ordinary cleanup worker to
record exact absence and the retry scheduler to bind the current descriptor.
Do not bypass deletion or copy an old cursor into a new descriptor. A deleted
generation may authorize the same run only when its committed budget rows also
prove that provider dispatch was impossible.

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.13 to 0.95.14: retained-resume warning interleaving

Adopt `graphql-orm-ai` 0.95.14 from one reviewed full monorepo revision. A host
Expand Down
18 changes: 12 additions & 6 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.14", 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.96.0", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -150,15 +150,21 @@ depend solely on the process-local wakeup channel.
Terminal `run_failed` and `run_recovery_required` events carry a bounded,
content-free failure record with a stable code and a retryable flag computed
from committed rows. `retryAiRun` authors a new run over the same durable user
message under current policy where re-execution is provably safe;
message under current policy where re-execution is provably safe. A
recovery-required run is retryable only when committed rows prove no assistant
output and no possible provider dispatch: no reservation, or exclusively
released/expired reservations. Reserved, committed, uncertain, and unknown
states remain non-retryable;
`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. 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.
provider absence is persisted, a later run—or the same provably pre-dispatch
run—may replace the deleted generation under its current server-authored
descriptor; the historical descriptor grants no replacement authority. An
incompatible active descriptor/transcript is atomically fenced into that
cleanup path. A lost rebind fence discards the fresh empty provider session and
defers safely before any business input or tool request.

A retained Codex host must not treat the `thread/resume` response as sufficient
readiness. Continue feeding the strict protocol actor until
Expand Down
11 changes: 7 additions & 4 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.14` with AI schema module
`graphql-orm-ai` is at crate version `0.96.0` with AI schema module
`0.64.0`. It uses workspace `graphql-orm` `0.30.0`, backend-neutral
`graphql-orm-ai-tool-profiles` `0.10.4`, and external `agql-auth`
`0.19.0` at `1d2e9fe2e1576105212a7b340a11abf8cad0382d`.
Expand Down Expand Up @@ -75,10 +75,13 @@ verification evidence belongs in the focused guides.
- The provider-neutral durable session service protects opaque retained-thread
cursors under exact owner/scope/run/descriptor/transcript fencing and an
exact deletion/absence lifecycle. An absence-proven deleted generation may
be replaced once through a crate-issued short-lived rebind authorization;
be replaced once through a crate-issued short-lived rebind authorization.
An incompatible active descriptor/transcript is atomically fenced into
cleanup before dispatch, and a recovery-required run is re-authored only
when committed output/reservation rows prove no provider dispatch occurred;
exact cleanup may use a fresh initialized provider process without resuming
the retained thread. Cleanup/backoff, expiry, descriptor drift, and restore
quarantine remain unavailable. Cursor state is separate from warm processes,
the retained thread. Cleanup/backoff and restore quarantine remain
unavailable. Cursor state is separate from warm processes,
private from GraphQL, backup-redacted, and readiness-blocking on portable
restore until drained. Provider failures may additionally emit only a closed
content-free operational category without changing conservative run
Expand Down
4 changes: 4 additions & 0 deletions crates/graphql-orm-ai/src/orm_coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3914,6 +3914,7 @@ mod tests {
crate::AiRunRetryEvidence {
terminal: crate::AiRunTerminalEvent::Failed,
produced_assistant_output: false,
provider_dispatch_possible: false,
},
Some("provider_budget_denied"),
),
Expand Down Expand Up @@ -3972,6 +3973,7 @@ mod tests {
crate::AiRunRetryEvidence {
terminal: crate::AiRunTerminalEvent::Failed,
produced_assistant_output: false,
provider_dispatch_possible: false,
},
Some("provider_pre_transport_failed"),
),
Expand Down Expand Up @@ -4029,6 +4031,7 @@ mod tests {
crate::AiRunRetryEvidence {
terminal: crate::AiRunTerminalEvent::Failed,
produced_assistant_output: false,
provider_dispatch_possible: false,
},
Some("provider_native_item_rejected"),
),
Expand Down Expand Up @@ -4604,6 +4607,7 @@ mod tests {
crate::AiRunRetryEvidence {
terminal: crate::AiRunTerminalEvent::Failed,
produced_assistant_output: false,
provider_dispatch_possible: false,
},
Some("provider_session_cleanup_unavailable"),
),
Expand Down
88 changes: 85 additions & 3 deletions crates/graphql-orm-ai/src/orm_provider_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,74 @@ mod service {
.map(AiProviderSessionRunDisposition::Resume)
.map_err(ai_error_to_orm);
}
if state == AiProviderSessionState::Active {
// An active cursor that cannot resume the exact
// server-authored descriptor/transcript must not
// strand the durable application session. Fence it
// into deletion before returning to the caller;
// only exact provider-absence proof may authorize a
// fresh binding generation.
let descriptor_matches = descriptor_from_record(binding)
.map_err(ai_error_to_orm)?
== *planned.descriptor();
let reason_code = if !descriptor_matches {
"provider_session_descriptor_changed"
} else if binding.transcript_fingerprint
!= planned.transcript_fingerprint()
|| binding.through_message_sequence.checked_add(1)
!= Some(input.sequence)
{
"provider_session_transcript_changed"
} else {
"provider_session_expired"
}
.to_owned();
let outcome = tx
.compare_and_swap::<AiProviderSessionBindingRecord>(
&binding.id,
binding.row_version,
AiProviderSessionBindingRecordWhereInput::default(),
cleanup_required_update(reason_code.clone(), lease.run_id().0),
)
.await
.map_err(OrmPublicError::from)?;
if !matches!(outcome, ConditionalUpdateOutcome::Updated(_)) {
return Err(OrmPublicError::new(OrmErrorCode::Conflict));
}
append_audit(
tx,
"ai.provider_session.cleanup_required",
binding.id,
&reason_code,
lease.run_id().0,
now,
)
.await?;
append_provider_session_disclosure(
tx,
crate::orm_runs::PROVIDER_SESSION_RESET_EVENT,
binding.session_id,
binding.id,
&reason_code,
Some(lease.run_id().0),
now,
)
.await?;
return Ok(AiProviderSessionRunDisposition::Unavailable(
AiProviderSessionState::CleanupRequired,
));
}
if state == AiProviderSessionState::Deleted
&& binding.last_run_id != Some(lease.run_id().0)
&& (binding.last_run_id != Some(lease.run_id().0)
|| binding
.cleanup_reason_code
.as_deref()
.is_some_and(is_pre_dispatch_rebind_cleanup_reason)
&& !crate::orm_runs::run_provider_dispatch_possible(
tx,
lease.run_id().0,
)
.await?)
{
// Exact provider absence severs the old cursor from
// the next generation. The tombstone's historical
Expand Down Expand Up @@ -2542,14 +2608,21 @@ mod service {
cleanup_next_attempt_at: Some(Some(
next_attempt_at.unix_timestamp(),
)),
cleanup_reason_code: Some(Some(reason_code)),
..Default::default()
},
)
.await
.map_err(OrmPublicError::from)?;
if matches!(outcome, ConditionalUpdateOutcome::Updated(_)) {
Ok(())
append_audit(
tx,
"ai.provider_session.cleanup_retry_scheduled",
record.id,
&reason_code,
record.last_run_id.unwrap_or(record.session_id),
now,
)
.await
} else {
Err(OrmPublicError::new(OrmErrorCode::Conflict))
}
Expand Down Expand Up @@ -2580,6 +2653,15 @@ mod service {
}
}

fn is_pre_dispatch_rebind_cleanup_reason(reason_code: &str) -> bool {
matches!(
reason_code,
"provider_session_descriptor_changed"
| "provider_session_transcript_changed"
| "provider_session_expired"
)
}

async fn load_and_validate_completed_run(
tx: &mut MutationContext<'_, DefaultWriteBackend>,
lease: &AiRunLease,
Expand Down
4 changes: 3 additions & 1 deletion crates/graphql-orm-ai/src/orm_run_disposition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use time::Duration;
use uuid::Uuid;

use crate::orm_inbox::{PreparedAiInboxEvent, append_inbox_event};
use crate::orm_runs::run_produced_assistant_output;
use crate::orm_runs::{run_produced_assistant_output, run_provider_dispatch_possible};
use crate::orm_sessions::{
content_context, map_orm, map_protection, map_transaction, principal_identity, record_scope,
};
Expand Down Expand Up @@ -339,6 +339,8 @@ impl OrmAiRunDispositionService {
tx, session_id, run_id,
)
.await?,
provider_dispatch_possible: run_provider_dispatch_possible(tx, run_id)
.await?,
};
if classify_run_retry(evidence, run.error_code.as_deref())
!= AiRunRetryAdmission::Allowed
Expand Down
Loading
Loading