diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87130f27..66d80780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI on: pull_request: + push: + branches: + - main workflow_dispatch: jobs: @@ -19,6 +22,21 @@ jobs: run: python3 scripts/check-documentation.py --base "${{ github.event.pull_request.base.sha }}" - name: Check generated workspace inventory run: python3 scripts/generate-workspace-inventory.py --check + - name: Check Git-only release metadata + run: | + python3 scripts/check-release-state.py + python3 scripts/generate-release-manifest.py \ + --release-id workspace-2000.01.01.1 \ + --ref HEAD \ + --output /tmp/workspace-release-a.json \ + --notes-output /tmp/workspace-release-a.md + python3 scripts/generate-release-manifest.py \ + --release-id workspace-2000.01.01.1 \ + --ref HEAD \ + --output /tmp/workspace-release-b.json \ + --notes-output /tmp/workspace-release-b.md + cmp /tmp/workspace-release-a.json /tmp/workspace-release-b.json + cmp /tmp/workspace-release-a.md /tmp/workspace-release-b.md - name: Check PR documentation impact if: github.event_name == 'pull_request' env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..282b75d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,214 @@ +name: Workspace release + +on: + workflow_dispatch: + inputs: + release_id: + description: Immutable workspace release ID (workspace-YYYY.MM.DD.N) + required: true + type: string + target_ref: + description: Full commit SHA expected to equal current main + required: true + type: string + include_router_artifact: + description: Build and attach the reviewed Linux router artifact + required: true + default: false + type: boolean + router_distribution_approval: + description: Required approval/evidence reference when attaching the router binary + required: false + type: string + prerelease: + description: Mark the GitHub release as a prerelease + required: true + default: false + type: boolean + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: workspace-release + cancel-in-progress: false + +jobs: + validate: + name: Validate exact release source + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: release + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + - name: Prove the selected source is current main + env: + TARGET_REF: ${{ inputs.target_ref }} + RELEASE_ID: ${{ inputs.release_id }} + INCLUDE_ROUTER: ${{ inputs.include_router_artifact }} + ROUTER_APPROVAL: ${{ inputs.router_distribution_approval }} + run: | + set -euo pipefail + git fetch origin main --tags --force + test "$(git rev-parse HEAD)" = "${TARGET_REF}" + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + if git show-ref --verify --quiet "refs/tags/${RELEASE_ID}"; then + echo "release tag already exists: ${RELEASE_ID}" >&2 + exit 1 + fi + if [[ "${INCLUDE_ROUTER}" == "true" && -z "${ROUTER_APPROVAL}" ]]; then + echo "router_distribution_approval is required for a router binary" >&2 + exit 1 + fi + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Validate documentation and release metadata + run: | + python3 scripts/check-documentation.py + python3 scripts/generate-workspace-inventory.py --check + python3 scripts/check-release-state.py + scripts/check-workspace-dependencies.sh + cargo fmt --all -- --check + - name: Verify neutral packages + run: | + cargo test -p graphql-orm-operation-catalog -p graphql-orm-ai-tool-profiles --locked + cargo test -p graphql-orm-storage --locked + cargo test -p graphql-orm-backup --features orm-sqlite --locked + cargo clippy -p graphql-orm-operation-catalog -p graphql-orm-ai-tool-profiles -p graphql-orm-storage --all-targets --locked -- -D warnings + cargo clippy -p graphql-orm-backup --all-targets --features orm-sqlite --locked -- -D warnings + RUSTDOCFLAGS="-D warnings -D missing_docs" cargo doc -p graphql-orm-operation-catalog -p graphql-orm-ai-tool-profiles -p graphql-orm-storage --no-deps --locked + RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm-backup --features orm-sqlite --no-deps --locked + - name: Verify ORM backend lanes + run: | + cargo test -p graphql-orm --locked + cargo check -p graphql-orm --no-default-features --features postgres --locked + cargo check -p graphql-orm --no-default-features --features mssql --locked + cargo clippy -p graphql-orm --all-targets --locked -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm -p graphql-orm-macros --no-deps --locked + - name: Verify AI provider and backend lanes + run: | + cargo test -p graphql-orm-ai --features provider-openai,provider-anthropic,provider-xai,provider-ollama,provider-openai-compatible,local-harness --locked + cargo test -p graphql-orm-ai --features graphql-case-pascal --test graphql_naming --locked + cargo check -p graphql-orm-ai --no-default-features --features postgres --locked + cargo check -p graphql-orm-ai --no-default-features --features mssql --locked + cargo check -p graphql-orm-ai -p graphql-orm --no-default-features --features graphql-orm-ai/sqlite,graphql-orm/mssql --locked + cargo test -p graphql-orm-ai --no-default-features --features postgres,provider-openai --test postgres_parity --locked -- --test-threads=1 + cargo clippy -p graphql-orm-ai --all-targets --features provider-openai,provider-anthropic,provider-xai,provider-ollama,provider-openai-compatible,local-harness --locked -- -D warnings + RUSTDOCFLAGS="-D warnings -D missing_docs" cargo doc -p graphql-orm-ai -p graphql-orm-operation-catalog -p graphql-orm-ai-tool-profiles --features graphql-orm-ai/provider-openai,graphql-orm-ai/provider-anthropic,graphql-orm-ai/provider-xai,graphql-orm-ai/provider-ollama,graphql-orm-ai/provider-openai-compatible,graphql-orm-ai/local-harness --no-deps --locked + - name: Verify alternate storage and backup lanes + run: | + cargo check -p graphql-orm-backup --no-default-features --features local,orm-postgres --locked + cargo check -p graphql-orm-storage --no-default-features --features s3,azure --locked + cargo check -p graphql-orm-storage --no-default-features --features smb --locked + - name: Verify router lanes + run: | + cargo test -p graphql-orm-router-protocol --locked + cargo test -p graphql-orm-router --locked + cargo test -p graphql-orm-router --features auth-agql --locked + cargo clippy -p graphql-orm-router-protocol --all-targets --locked -- -D warnings + cargo clippy -p graphql-orm-router --all-targets --features auth-agql --locked -- -D warnings + RUSTDOCFLAGS="-D warnings -D missing_docs" cargo doc -p graphql-orm-router-protocol --no-deps --locked + RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm-router --no-deps --features auth-agql --locked + - name: Verify router minimum supported Rust version + run: | + rustup toolchain install 1.90.0 --profile minimal + cargo +1.90.0 test -p graphql-orm-router-protocol --locked + cargo +1.90.0 test -p graphql-orm-router --locked + - name: Generate deterministic release bundle + env: + RELEASE_ID: ${{ inputs.release_id }} + run: | + mkdir -p release-dist + python3 scripts/generate-release-manifest.py \ + --release-id "${RELEASE_ID}" \ + --ref HEAD \ + --check-clean \ + --verify-tags \ + --output "release-dist/${RELEASE_ID}.json" \ + --notes-output "release-dist/${RELEASE_ID}.md" + - name: Build approved router artifact + if: inputs.include_router_artifact + env: + RELEASE_ID: ${{ inputs.release_id }} + ROUTER_APPROVAL: ${{ inputs.router_distribution_approval }} + run: | + set -euo pipefail + test "$(rustc -vV | sed -n 's/^host: //p')" = "x86_64-unknown-linux-gnu" + SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + export SOURCE_DATE_EPOCH + cargo install cargo-cyclonedx --version 0.5.9 --locked + cargo build -p graphql-orm-router --release --features auth-agql --locked + cargo cyclonedx \ + --manifest-path crates/graphql-orm-router/Cargo.toml \ + --format json \ + --spec-version 1.5 \ + --features auth-agql + install -m 0755 target/release/graphql-orm-router release-dist/graphql-orm-router + cp crates/graphql-orm-router/graphql-orm-router.cdx.json \ + release-dist/graphql-orm-router.cdx.json + cp LICENSE release-dist/LICENSE + printf '%s\n' "${ROUTER_APPROVAL}" > release-dist/router-distribution-approval.txt + tar -C release-dist --sort=name --mtime="@${SOURCE_DATE_EPOCH}" \ + --owner=0 --group=0 --numeric-owner \ + -czf "release-dist/${RELEASE_ID}-graphql-orm-router-x86_64-unknown-linux-gnu.tar.gz" \ + graphql-orm-router graphql-orm-router.cdx.json LICENSE \ + router-distribution-approval.txt + rm release-dist/graphql-orm-router release-dist/graphql-orm-router.cdx.json \ + release-dist/LICENSE release-dist/router-distribution-approval.txt + - name: Create checksums + run: | + cd release-dist + sha256sum -- * > SHA256SUMS + - name: Attest release assets + uses: actions/attest-build-provenance@v3 + with: + subject-path: release-dist/* + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.release_id }} + path: release-dist/ + if-no-files-found: error + retention-days: 30 + - name: Create annotated release tags + env: + RELEASE_ID: ${{ inputs.release_id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + tags_to_push=() + while IFS= read -r package_tag; do + if git show-ref --verify --quiet "refs/tags/${package_tag}"; then + continue + fi + git tag -a "${package_tag}" -m "${package_tag}" + tags_to_push+=("refs/tags/${package_tag}") + done < <(jq -r '.packages[].tag' "release-dist/${RELEASE_ID}.json") + git tag -a "${RELEASE_ID}" -m "${RELEASE_ID}" + tags_to_push+=("refs/tags/${RELEASE_ID}") + git push --atomic origin "${tags_to_push[@]}" + - name: Publish immutable workspace release + env: + RELEASE_ID: ${{ inputs.release_id }} + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + prerelease_flag=() + if [[ "${PRERELEASE}" == "true" ]]; then + prerelease_flag=(--prerelease) + fi + gh release create "${RELEASE_ID}" \ + --verify-tag \ + --title "${RELEASE_ID}" \ + --notes-file "release-dist/${RELEASE_ID}.md" \ + "${prerelease_flag[@]}" \ + release-dist/* diff --git a/.gitignore b/.gitignore index b44ce8ed..090317ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /target /.handoff/ +*.cdx.json +*.pyc +__pycache__/ diff --git a/Cargo.lock b/Cargo.lock index 9ac180ab..985fb689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3016,7 +3016,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.73.0" +version = "0.75.0" dependencies = [ "agql-auth", "async-graphql", diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..729415d6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dastari + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6178a357..d78225cf 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ It is designed for two related use cases: per-parent keysets, and optional counts - single and composite primary-key read support - single and composite relation-key batching, including nested legacy shapes like - `JimCardFiles -> Contacts -> Details` + `LegacyCardFiles -> Contacts -> Details` - managed compound foreign keys with ordered member introspection on SQLite and PostgreSQL - stable named ordinary indexes with typed per-column ascending or descending order - portable spatial fields and predicates with native PostGIS support and SQLite GeoJSON fallback @@ -247,16 +247,16 @@ Composite relation keys use array syntax and batch efficiently across SQLite, Po ```rust #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] -pub details: Vec, +pub details: Vec, ``` -A nested query such as `JimCardFiles -> Contacts -> Details` executes as one parent query plus one +A nested query such as `LegacyCardFiles -> Contacts -> Details` executes as one parent query plus one batched relation query per relation layer, not N+1 or nested N*N queries. ## Documentation @@ -279,6 +279,7 @@ batched relation query per relation layer, not N+1 or nested N*N queries. - [Schema modules and fenced leases](docs/architecture/schema-modules-and-leases.md) - [Completed monorepo consolidation](docs/plans/completed/monorepo-consolidation/README.md) - [Testing and verification](docs/development/testing.md) +- [Workspace versioning and releases](docs/operations/release/process.md) ## Status diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 1fc48753..0a3732bc 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-11 +last_reviewed: 2026-08-12 review_by: 2027-02-01 supersedes: [] --- @@ -20,6 +20,184 @@ checkpoint facts. For the current workspace baseline and active gates, use the ## [Unreleased] +No changes recorded after 0.75.0. + +## [0.75.0] - 2026-08-12 + +### Added + +- `AiToolCatalog::read_only_model_definition` constructs the provider-facing + definition directly from one registered read-only descriptor. Hosts choose + only a provider-safe alias; stable ID, description, canonical argument + schema, strictness, and fingerprint cannot drift into a second declaration. +- `AiCodexAppServerBootstrapInstructions` carries bounded compile-time static + deployment instructions for retained threads. Its protected content + fingerprint participates in registration identity and is rechecked through + creation, first activation, and resume. + +### Changed + +- `AiCodexAppServerRunProcess::create_empty_thread` now receives the typed + static bootstrap beside the model and exact dynamic definitions. Retained + `ModelRequest::instructions` must be empty; request-local text cannot enter + the developer-instruction channel. +- Registration identity version 3 binds the bootstrap fingerprint. Existing + retained cursors from older registration identities become cleanup-only. +- Provider-neutral session cursor, descriptor, activation, and deletion + contracts now remain available in the MSSQL compile/schema profile. The ORM + provider-session runtime remains intentionally limited to SQLite and + PostgreSQL. + +### Fixed + +- Canonical GraphQL tool-profile argument schemas are projected through a + closed Codex-specific JSON Schema subset. Unsupported scalar bound keywords + are represented in provider-visible property descriptions, while the exact + canonical schema remains authoritative for every returned dynamic call. +- The retained first turn now receives the same immutable trusted instructions + that created its empty thread. Tool behavior no longer depends on a + request-local instruction that cannot be transmitted after durable binding. + +### Security + +- Generated-profile, tool-bound plan, coordinator, newly-bound, retained + resume, exact responder, cancellation, process-fence, schema substitution, + descriptor substitution, instruction substitution, and cursor substitution + tests share one canonical generated read-only profile with a bounded integer + input and explicit result projection. The ignored Codex 0.147.0 acceptance + uses that same profile rather than a hand-authored model definition. + +## [0.74.0] - 2026-08-12 + +### Added + +- `AiCodexAppServerLaunchProfile` now supplies a closed, fingerprinted + experimental dynamic-tools-only process and thread profile. It exposes the + exact reviewed Codex CLI arguments, requires an isolated configuration home, + disables native execution, browser, hosted-search, connector, image, + collaboration, plugin, and interactive surfaces, and makes every dynamic + thread and turn environment-free. +- `AiCodexAppServerModelToolMode` records the reviewed executable/model + catalogue mode. The dynamic-tools-only profile accepts only `Direct` models; + Code Mode and Code Mode-only models fail configuration rather than silently + advertising unusable custom tools. +- `AiCodexAppServerRunProcessFactory::supports_launch_profile` makes runtime + readiness explicit. Existing factories default to text-only support, and + `ProviderCapabilities::custom_tools` remains false until the factory attests + the exact dynamic-only profile. + +### Changed + +- Dynamic Codex registrations now use `with_launch_profile` instead of the + former boolean `with_experimental_dynamic_tools` switch. Registration + identity version 2 includes the closed launch profile, so stale retained + cursors are cleanup-only after adoption. + +### Fixed + +- The strict protocol actor now accepts JSON-RPC server-request identifier + zero for an otherwise exact, lifecycle-correlated `item/tool/call`. Codex + 0.147.0 uses this valid identifier for its first dynamic request; duplicate, + malformed, uncorrelated, unknown, or schema-invalid requests still fail + closed. + +### Security + +- The live Codex 0.147.0 acceptance now proves one exact dynamic tool call on + both a newly bound thread and a later retained resume while native shell, + unified execution, Code Mode, filesystem environments, MCP, browser, + hosted web, images, plugins, collaboration, and generic JSON-RPC remain + unavailable. It also proves provider-owned interruption and terminal close + for an active dynamic turn. + +## [0.73.4] - 2026-08-12 + +### Fixed + +- Both strict Codex app-server initialization paths now send one + library-owned `optOutNotificationMethods` profile for unused thread status, + thread settings, cleared goals, MCP startup status, and account rate-limit + notifications. Authoritative thread starts, turns, item lifecycles, + assistant deltas, dynamic-tool requests, in-turn usage, and completion are + never suppressed. A server that emits any opted-out method still fails + closed at the actor. +- Retained-thread deletion now uses only the exact empty successful + `thread/delete` response as provider-absence evidence. The actor no longer + admits a deletion-bound `thread/status/changed` exception. +- Codex 0.147.0 empty reasoning item pairs are admitted only through the new + content-free `AiCodexAppServerInbound::ReasoningLifecycle` value while every + `turn/start` explicitly sets `summary: "none"`. Non-empty content or summary, + malformed pairs, and raw reasoning deltas remain rejected and never cross + the provider boundary. +- A cumulative `thread/tokenUsage/updated` value replayed while loading a + retained thread is validated against the complete nonnegative generated + shape and exposed only as content-free + `AiCodexAppServerInbound::RetainedResumeUsageSnapshot`. It is never charged + to the next run. On Codex 0.147.0, the exact snapshot can complete only a + typed resume after its correlated response is observed when that version + omits `thread/started`; it cannot complete new thread creation. + +### Security + +- The live Codex 0.147.0 gate now covers a persistent empty thread, an initial + direct dynamic-tool turn, process restart, retained resume, a second + dynamic-tool turn, and response-authoritative deletion while shell, files, + MCP, browser, hosted web, remote control, and generic JSON-RPC stay closed. + +## [0.73.3] - 2026-08-12 + +### Fixed + +- The strict Codex app-server actor now admits the documented generic + `warning` notification only during an exact correlated turn. Its positive + timestamp, closed parameter shape, optional thread binding, bounded + control-free message, per-turn count, and cumulative bytes are validated; + the message, timestamp, and thread reference are then discarded behind the + content-free `AiCodexAppServerInbound::RuntimeWarning` variant. +- Warnings before `turn/start`, after terminal completion, for another thread, + or with malformed, extra, oversized, control-bearing, or flooding payloads + remain rejected. No generic notification, remote-control, shell, file, MCP, + browser, hosted-web, or JSON-RPC capability is added. + +## [0.73.2] - 2026-08-12 + +### Fixed + +- Durable provider-session activation now distinguishes a newly created empty + thread from a previously committed retained thread. The first turn consumes + a crate-owned, run-fenced activation and starts directly on the exact + process and cursor that created the empty thread; later runs continue to use + the strict `thread/resume` lifecycle. +- The Codex app-server run pool freezes the empty thread's cursor and reviewed + dynamic-tool definitions, rejects process, run, cursor, registration, + model, or tool swaps, and consumes initial activation once. Bind/open + failure, cancellation, lease loss, and abandoned streams retain exact + deletion and process-tree cleanup behavior. + +### Added + +- `AiCodexAppServerRunProcess` has typed `start_bound_turn` and + `start_bound_dynamic_turn` operations for the post-bind first turn. They do + not expose activation state or a reset flag and must not issue + `thread/resume`. + +## [0.73.1] - 2026-08-12 + +### Fixed + +- The strict Codex app-server actor now begins a new internal lifecycle + observation phase for each typed `thread/start` or `thread/resume` request. + One actor can create an empty retained thread, accept its response and + `thread/started` notification, resume the same protected cursor, accept the + new pair in either order, and start a turn. Incomplete, duplicate, + mismatched, late, or concurrently active lifecycle cycles remain rejected. +- Retained model and experimental dynamic-tool definitions remain frozen + across terminal turns and repeated resume cycles. A later resume or + `turn/start` cannot replace the tool set, while ephemeral thread definitions + are still cleared after their terminal turn. + +## [0.73.0] - 2026-08-11 + This development line advances the pre-1.0 crate version to `0.73.0` and AI schema module `0.55.0`. It begins the applied-restore implementation with bounded database-derived facts, aligns the reviewed dependency universe, diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index d6f71313..9eea1b99 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.73.0" +version = "0.75.0" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 6d31fb18..b97a0f0b 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-11 +last_reviewed: 2026-08-12 review_by: 2027-02-01 supersedes: [] --- @@ -19,6 +19,225 @@ 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.75.0: canonical Codex tools and retained bootstrap (schema remains 0.55.0) + +Construct provider definitions from the registered manifest instead of +copying descriptor fields: + +```rust +let definition = tool_catalog.read_only_model_definition( + ®istered_tool_id, + "inventory_count", +)?; +``` + +The alias is provider-local correlation metadata. The library copies and later +revalidates the exact stable ID, description, argument schema, and descriptor +fingerprint. Hosts must not strip `$schema`, scalar bounds, or projection +metadata. The Codex adapter now performs its own closed, fingerprint-bound +projection of canonical argument JSON Schema into the subset accepted by the +app-server. + +Move retained-thread developer instructions out of +`ModelRequest::instructions` and into the immutable registration: + +```rust +let bootstrap = AiCodexAppServerBootstrapInstructions::from_static(&[ + "Use a registered application tool whenever current facts are needed to answer the request.", +])?; +let registration = AiCodexAppServerRegistration::new( + provider_profile_id, + logical_model, + executable_sha256, + executable_version, + sandbox_profile, + AI_CODEX_APP_SERVER_PROTOCOL_V2, +)? +.with_launch_profile(launch_profile) +.with_bootstrap_instructions(bootstrap); +``` + +Only compile-time static deployment policy belongs in this value. Never place +user input, tenant or route context, secrets, resolver output, or model-authored +text in it. Retained requests now reject non-empty +`ModelRequest::instructions`; ordinary business text remains in bounded input +blocks. Update `AiCodexAppServerRunProcess::create_empty_thread` implementations +to accept the added `&AiCodexAppServerBootstrapInstructions` argument and pass +it unchanged to `AiCodexAppServerProtocolActor::start_persistent_empty_thread`. + +Registration identity version 3 includes the bootstrap fingerprint. Drain and +delete older provider-session bindings through their exact cleanup lifecycle; +do not resume them under a replacement registration. There is no GraphQL SDL, +database entity, table, column, index, constraint, backup/restore, or persistent +storage semantic change. No data migration, backfill, or row rewrite is +required, and AI schema module `0.55.0` remains current. + +The provider-neutral session value types are also available when compiling the +MSSQL feature profile so provider adapters can retain one canonical public +contract across backend lanes. This does not add an MSSQL provider-session +persistence implementation or change its experimental compile/schema-only +status. + +## 0.74.0: closed Codex dynamic-tools-only launch profile (schema remains 0.55.0) + +Replace the former boolean dynamic-tool registration switch with the closed +profile and make the trusted process factory attest that it applies that exact +profile: + +```rust +let launch_profile = AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, +)?; +let registration = AiCodexAppServerRegistration::new( + provider_profile_id, + logical_model, + executable_sha256, + executable_version, + sandbox_profile, + AI_CODEX_APP_SERVER_PROTOCOL_V2, +)? +.with_launch_profile(launch_profile); +``` + +`AiCodexAppServerRunProcessFactory::supports_launch_profile` defaults to true +only for the strict text-only profile. A factory enabling dynamic tools must +return true only after it launches the reviewed executable with +`registration.launch_profile().codex_arguments()` unchanged, clears inherited +environment and credentials, supplies an isolated configuration home with no +project configuration or MCP servers, uses an empty working directory, and +applies its fixed external sandbox. If this proof is absent, +`ProviderCapabilities::custom_tools` is false and dynamic calls return +`Unsupported` before process launch. + +The model tool mode comes from the reviewed model catalogue bound to the exact +executable digest. Codex 0.147.0 models declared `code_mode_only` cannot use +this profile: with Code Mode disabled their direct dynamic definitions are not +model-visible. Keep their text-only provider registration or choose a reviewed +`Direct` model for the separate dynamic-tool profile. Do not relabel the +catalogue mode or enable Code Mode, shell, unified execution, filesystem, MCP, +browser, hosted web, remote control, or another native surface as a workaround. + +Registration identity version 2 includes the launch profile. Existing +provider-session bindings created with the earlier dynamic registration must +be invalidated and deleted through the ordinary exact cleanup lifecycle before +replacement; they must not be resumed under the new identity. + +The protocol actor now accepts unsigned server-request ID `0` for an otherwise +exact dynamic call because Codex 0.147.0 emits that valid JSON-RPC identifier. +Hosts need no special case and must continue passing complete frames unchanged +to `accept`. + +This release changes only public provider API and runtime compatibility. There +is no GraphQL SDL, database entity, table, column, index, constraint, +backup/restore, or persistent storage semantic change. No data migration, +backfill, or row rewrite is required, and AI schema module `0.55.0` remains +current. + +## 0.73.4: closed Codex notification profile and retained resume compatibility (schema remains 0.55.0) + +Existing Codex process implementations continue calling +`AiCodexAppServerProtocolActor::initialize` or +`initialize_with_dynamic_tools`; no host-authored capability object is added. +Both methods now include the library-owned exact notification opt-out profile. +Do not add, remove, or rewrite its methods in the host, and continue passing +every received frame unchanged to `accept`. The stable path does not opt into +the experimental API; the dynamic-tool path still adds only +`experimentalApi: true`. + +Hosts should treat the additive non-exhaustive inbound variants as follows: + +- `ReasoningLifecycle` is content-free progress metadata. Do not invent or + display reasoning text. The actor accepts only paired empty reasoning items + because every turn explicitly requests `summary: "none"`. +- `RetainedResumeUsageSnapshot` is cumulative provider state replayed during + an exact retained-thread resume and before the new active turn. Do not emit + it as usage or charge it to the current run. It may satisfy retained-resume + readiness after the correlated response because Codex 0.147.0 does not emit + `thread/started` on that exact resume path. It never replaces the response + or completes initial thread creation. + +Deletion adapters should finish only after the exact correlated empty +`thread/delete` response. Stop waiting for or locally admitting a +`thread/status/changed` `notLoaded` notification. The fixed initialization +profile suppresses unused thread status, thread settings, cleared goal, MCP +startup, and account rate-limit notifications. If the server sends any of +those despite negotiation, pass the frame to the actor and fail closed. + +This release changes only the provider protocol/API contract. There is no +GraphQL SDL, database entity, table, column, index, constraint, +backup/restore, or durable semantic change. No data migration, backfill, or +row rewrite is required, and AI schema module `0.55.0` remains current. + +## 0.73.3: content-free Codex runtime warnings (schema remains 0.55.0) + +Codex app-server process adapters should handle +`AiCodexAppServerInbound::RuntimeWarning` as a non-fatal, content-free control +event and continue waiting for authoritative turn, item, usage, and completion +events. Continue passing every complete provider frame unchanged to +`AiCodexAppServerProtocolActor::accept`; do not inspect, log, forward, or +substring-match warning messages in the host. + +The actor admits a warning only after a typed `turn/start` has opened the exact +thread-bound turn and before its terminal `turn/completed`. It validates the +positive signed timestamp, exact envelope and parameter keys, optional thread +correlation, a non-empty control-free message of at most 4 KiB, at most eight +warnings, and at most 16 KiB of warning text per turn. All content is discarded +before the public inbound value is returned. Warning budgets reset only when a +new typed turn begins and after terminal completion. + +This is an additive provider protocol-compatibility fix. There is no GraphQL +SDL, database entity, table, column, index, constraint, backup/restore, or +durable semantic change. No data migration or row rewrite is required, and AI +schema module `0.55.0` remains current. + +## 0.73.2: newly bound provider-session activation (schema remains 0.55.0) + +`AiProviderCallExecutor::execute_with_provider_session` now preserves whether +the opened cursor was created empty and durably bound by the current run or +claimed from a previously committed turn. This evidence is crate-owned and is +not a host input, GraphQL value, model value, or public reset mechanism. + +Codex app-server process implementations should add the new typed +`AiCodexAppServerRunProcess::start_bound_turn` and +`start_bound_dynamic_turn` methods. These methods receive the first turn only +after cursor protection, durable binding, current-principal reauthorization, +and exact reopening have succeeded. Start `turn/start` directly on the loaded +thread and do not issue `thread/resume`. Keep existing +`start_retained_turn` and `start_retained_dynamic_turn` implementations for a +cursor claimed by a later run; those paths must still perform the full +`thread/resume` response/notification lifecycle before `turn/start`. + +The new trait methods have fail-closed default implementations, so unrelated +providers remain source-compatible. A Codex host must implement them to use +new persistent sessions. Do not infer activation from request order, local +flags, cursor shape, or actor state, and do not recreate the actor or process +between empty creation and the first bound turn. + +This is a provider/runtime lifecycle correction only. There is no GraphQL SDL, +database entity, table, column, index, constraint, backup/restore, or durable +semantic change. No data migration or row rewrite is required, and AI schema +module `0.55.0` remains current. + +## 0.73.1: repeatable retained Codex lifecycles (schema remains 0.55.0) + +`AiCodexAppServerProtocolActor` now owns a separate bounded observation phase +for every typed thread creation or resume operation. Hosts may use the same +actor for `thread/start` followed by one or more exact `thread/resume` cycles. +For each cycle, continue passing complete frames unchanged and wait for exactly +one correlated response plus one matching `thread/started` notification before +starting a turn. Either ordering remains supported. + +No reset method is added. Starting the next lifecycle fails while the previous +pair, a turn, or deletion remains incomplete. Retained model and dynamic-tool +definitions are immutable across cycles and terminal turns. Existing process +adapters need no source changes; remove any host-side actor replacement or +protocol-frame workaround introduced for this bug. + +This is a runtime protocol-state fix only. There is no GraphQL SDL, database, +entity, table, column, index, constraint, backup/restore, or persistent semantic +change. No data migration or row rewrite is required, and AI schema module +`0.55.0` remains current. + ## Unreleased: strict Codex lifecycle envelopes (crate 0.72.0 to 0.73.0; schema remains 0.55.0) The `provider-codex-app-server` protocol actor now requires the complete Codex diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 1ea73050..b8f25f9e 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -323,7 +323,7 @@ Exactly one persistence backend should be selected: | `provider-ollama` | no | Native Ollama chat: text, exact images, structured output, stateless application tools | | `provider-openai-compatible` | no | Profiled Responses/SSE: text/JSON and opt-in strict tools, structured output, retained continuation | | `local-harness` | no | Installed JSONL v2 text/structured/stateless-tool protocol over a trusted sandbox launcher | -| `provider-codex-app-server` | no | Strict timestamped and correlated Codex app-server lifecycle, protected retained threads, explicit never-approval/read-only thread policy, disabled-only remote-control and deletion-bound not-loaded status admission, and default-off coordinator-owned experimental dynamic tools; no remote control, shell, filesystem, web, or generic JSON-RPC | +| `provider-codex-app-server` | no | Strict timestamped and correlated Codex app-server lifecycle, a closed notification opt-out profile, response-authoritative deletion, protected retained threads, content-free warning/empty-reasoning/retained-usage control events, explicit never-approval/read-only thread policy, disabled-only remote-control admission, and a factory-attested direct-model dynamic-tools-only profile; no remote control, shell, filesystem, web, MCP, or generic JSON-RPC | | `graphql-case-pascal` | no | PascalCase roots, arguments, inputs, outputs, and ORM fields | Do not build with `--all-features`: the database backends are mutually @@ -335,7 +335,7 @@ Add the crate from a reviewed monorepo revision: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.73.0", features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.75.0", features = ["sqlite"] } ``` > **Pre-release dependency note:** this source snapshot resolves @@ -557,11 +557,22 @@ callbacks remain separately gated; see the The separate Codex app-server feature retains one strictly allowlisted process per exact claimed run and can resume an exact protected provider-session -cursor. Experimental native dynamic tools are separately enabled on the -immutable registration and execute only through the ordinary coordinator's -current-rule, registered-GraphQL-tool, disclosure, egress, budget, and resolver -authorization boundary. Native -OpenAI can combine host-enabled hosted search with exact application tools in +cursor. Experimental dynamic tools require a closed dynamic-tools-only launch +profile, a reviewed direct-tool model declaration, and process-factory +attestation that the exact profile is enforced; otherwise the provider reports +`custom_tools = false`. Accepted dynamic calls execute only through the +ordinary coordinator's current-rule, registered-GraphQL-tool, disclosure, +egress, budget, and resolver authorization boundary. Retained Codex +registrations may also bind an +`AiCodexAppServerBootstrapInstructions` value created from compile-time static +host policy. The protected fingerprint becomes part of the registration and +every retained request must leave `ModelRequest::instructions` empty. Tool +definitions should come from `AiToolCatalog::read_only_model_definition`, so +the descriptor ID, description, canonical JSON Schema, and fingerprint cannot +drift from the registered manifest; the adapter performs its closed +provider-specific schema projection internally. + +Native OpenAI can combine host-enabled hosted search with exact application tools in provider-retained mode and can stream provider-generated visible summaries and validated citations into the ordered protected activity feed. Defaults remain closed. See [provider sessions, hosted search, and visible activity](docs/provider-sessions-and-hosted-activity.md). diff --git a/crates/graphql-orm-ai/docs/README.md b/crates/graphql-orm-ai/docs/README.md index cf8e94c7..9296d21a 100644 --- a/crates/graphql-orm-ai/docs/README.md +++ b/crates/graphql-orm-ai/docs/README.md @@ -35,7 +35,7 @@ Start with the root [README](../README.md), then use the focused guides below. - [Native Ollama provider](ollama.md) - [Installed local harness boundary](local-harness.md) - [Provider sessions, hosted search, and visible activity](provider-sessions-and-hosted-activity.md) -- [AI provider sessions, hosted tools, and visible activity plan](../../../docs/plans/active/ai-provider-sessions-and-hosted-tools/README.md) +- [AI provider sessions, hosted tools, and visible activity completion](../../../docs/plans/completed/ai-provider-sessions-and-hosted-tools/README.md) - [Future capability-scoped visual-browser broker](visual-browser-broker.md) - [Read-only application-tool loop](read-only-tool-loop.md) - [Protected coordinator checkpoints](coordinator-checkpoints.md) diff --git a/crates/graphql-orm-ai/docs/backend-capability-matrix.md b/crates/graphql-orm-ai/docs/backend-capability-matrix.md index 243efb31..43302699 100644 --- a/crates/graphql-orm-ai/docs/backend-capability-matrix.md +++ b/crates/graphql-orm-ai/docs/backend-capability-matrix.md @@ -27,7 +27,7 @@ authorization, migration, restore, or deployment acceptance. | Capability | Implemented here | Host/consumer proof still required | Closed or unsupported | | --- | --- | --- | --- | | Session/message runtime and protected storage | Generated-ORM persistence, authorization bridge, content protection, fencing, bounded streaming/checkpoints, retention workers. | Schema composition, encryption/key deployment, migration and restore rehearsal, access-policy parity. | Runtime readiness after full restore remains closed until applied reconciliation succeeds. | -| Native/provider adapters | OpenAI, Anthropic, xAI, Ollama, reviewed OpenAI-compatible profiles, trusted JSONL local harness, and strict Codex app-server process/retained-thread interface with default-off experimental dynamic tools under explicit features. | Real credentials, endpoint/network policy, provider account retention/residency settings, OS/container sandbox and process-tree kill implementation, experimental app-server compatibility tests, live opt-in tests. | Generic endpoint/model/JSON-RPC authority, arbitrary child-process launch, and adapter-owned application-tool execution are unsupported. | +| Native/provider adapters | OpenAI, Anthropic, xAI, Ollama, reviewed OpenAI-compatible profiles, trusted JSONL local harness, and strict Codex app-server process/retained-thread interface with a default-off, factory-attested dynamic-tools-only profile for reviewed direct-tool models, registration-bound static bootstrap instructions, and canonical catalog-to-Codex tool-schema projection. | Real credentials, endpoint/network policy, provider account retention/residency settings, OS/container sandbox and process-tree kill implementation, exact executable/model-catalogue review, compile-time static bootstrap policy, experimental app-server compatibility tests, live opt-in tests. | Generic endpoint/model/JSON-RPC authority, arbitrary child-process launch, request-local retained developer instructions, host-authored schema rewriting, Code Mode as a dynamic-tool workaround, and adapter-owned application-tool execution are unsupported. | | Provider sessions and activity | Private protected provider-session binding on SQLite/PostgreSQL; strict Codex create/resume/interrupt/delete transport; MSSQL schema compile; ordered protected text/visible-summary/hosted-tool/citation activity; exact cleanup and restore audit. | Canonical host transcript/policy fingerprint, managed cleanup lifecycle, reviewed Codex process actor and sandbox, portable-backup drain. | Warm-process state is never persisted; restored cursors never auto-resume; multiplexing and portable cursor restore are unsupported. | | OpenAI background runs | Exact submission, retrieval, webhook receipt, retry/deadline handling, terminal usage/budget/output reconciliation. | Worker scheduling/operations and provider-account configuration. | Ambiguous provider creation or conflicting terminal evidence enters recovery; it is never replayed. | | Application tools | Explicit catalog, disclosure contracts, read-only loop, sequential one-mutation supervised flow, fresh principal/tool policy/resolver authorization. | Application operation registration, policy, logical target transport, delegated credential issuer, disclosure classification. | Recursive control-plane/introspection, autonomous writes, mixed/partial/parallel/stateless supervised execution are closed; generic parallel consequential execution is unsupported. | diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 8748f139..d3988ac0 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -3,14 +3,14 @@ title: "Implementation Status" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-11 +last_reviewed: 2026-08-12 review_by: 2027-02-01 supersedes: [] --- # Implementation Status -`graphql-orm-ai` is at crate version `0.73.0` with AI schema module +`graphql-orm-ai` is at crate version `0.75.0` with AI schema module `0.55.0`. It uses workspace `graphql-orm` `0.21.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.3.0`, and external `agql-auth` `0.14.0` at `413fda3435f060604cd653c11e2cc18a668aace1`. @@ -37,11 +37,20 @@ verification evidence belongs in the focused guides. - The optional Codex app-server adapter retains one strictly allowlisted process per exact claimed run and may resume one exact protected provider thread. It is globally and per-owner bounded, cancellation-aware, and - kill-on-drop. Experimental native dynamic tools require an immutable - default-off registration flag and remain coordinator-owned; every exact call + kill-on-drop. Experimental native dynamic tools require an immutable closed + 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. + 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 + controls that cannot become model output or current-run usage. Retained + developer instructions are compile-time static, registration-fingerprinted, + and distinct from request input. Provider definitions are projected from the + exact registered manifest, with canonical JSON Schema validation retained at + every dynamic-call boundary. - 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. Cursor state is separate from warm diff --git a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md index a9f4beeb..a3818084 100644 --- a/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md +++ b/crates/graphql-orm-ai/docs/provider-sessions-and-hosted-activity.md @@ -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-11 +last_reviewed: 2026-08-12 review_by: 2027-02-11 supersedes: [] --- @@ -64,27 +64,112 @@ and return `AiCodexAppServerLaunchedProcess` with an idempotent synchronous process-tree kill callback. The wrapper invokes that callback on final drop, including an abandoned stream or failed graceful shutdown. +Dynamic tools require a separate closed launch profile. Bind the reviewed +model-catalogue mode and profile into the registration: + +```rust +let launch_profile = AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, +)?; +let bootstrap = AiCodexAppServerBootstrapInstructions::from_static(&[ + "Use a registered application tool whenever current facts are needed to answer the request.", +])?; +let registration = AiCodexAppServerRegistration::new( + "local-dynamic-tools", + "reviewed-direct-tool-model", + executable_sha256, + executable_version, + "isolated-no-native-tools", + AI_CODEX_APP_SERVER_PROTOCOL_V2, +)? +.with_launch_profile(launch_profile) +.with_bootstrap_instructions(bootstrap); +``` + +The factory returns true from `supports_launch_profile` only when it launches +that profile with `registration.launch_profile().codex_arguments()` unchanged, +an environment cleared of unrelated credentials, a private configuration home +containing no project config or MCP servers, an empty working directory, and +the registered operating-system sandbox. The actor additionally sends empty +thread/turn environments and a closed thread config that disables shell, +unified execution, Code Mode, utility tools, connectors, plugins, +collaboration, images, browser/computer use, and hosted search. This is defense +in depth: the process sandbox remains authoritative if a provider version +ignores a feature toggle. + +Only a reviewed `Direct` model-tool declaration can construct this profile. +Codex models declared `CodeMode` or `CodeModeOnly` are rejected rather than +silently losing dynamic tools or requiring a native Code Mode host. Such a +registration may still use the strict text-only profile. When the factory does +not attest the dynamic profile, provider capabilities report +`custom_tools = false` and no dynamic process starts. + The crate-owned protocol actor deliberately has no generic JSON-RPC send -method. It admits only initialization, exact thread start/resume/delete, turn -start/interruption, correlated responses, the closed visible-event allowlist, -and—only for an experimental registration—the exact documented -`item/tool/call` server request. Commands, shell, files, patches, MCP, -collaboration, images, hosted web search, browser control, raw reasoning, and -arbitrary methods remain forbidden. +method. Initialization always negotiates one fixed opt-out profile for thread +status/settings/cleared-goal, MCP-startup, and account-rate-limit notifications +that this adapter neither consumes nor exposes. Stable and experimental +initialization use the same profile; only the dynamic-tool path additionally +sets `experimentalApi: true`. An opted-out method remains rejected if the +server sends it anyway. The actor admits only initialization, exact thread +start/resume/delete, turn start/interruption, correlated responses, the closed +visible-event allowlist, and—only for an experimental registration—the exact +documented `item/tool/call` server request. Commands, shell, files, patches, +MCP, collaboration, images, hosted web search, browser control, raw reasoning, +and arbitrary methods remain forbidden. + +Codex may emit the documented generic `warning` while a turn is open. The +actor accepts only the exact positive-timestamp envelope, an optional thread ID +matching the active thread, and a bounded non-empty control-free message. It +limits each turn to eight warnings and 16 KiB total text, discards every field, +and returns only `AiCodexAppServerInbound::RuntimeWarning`. Hosts treat that +variant as a non-fatal control event; they never log or forward the warning +text. Warnings outside the current turn and every other generic notification +remain rejected. + +Every turn explicitly requests `summary: "none"`. Codex may still report an +empty reasoning item lifecycle. The actor accepts only an exact paired item +whose `content` and `summary` arrays remain empty, discards its identifier and +timestamp, and returns `ReasoningLifecycle`. It rejects non-empty reasoning or +summary content and all reasoning deltas, so this control event is neither a +reasoning summary nor hidden chain-of-thought. The closed default accepts an initial `StatelessReplay` request containing only bounded trusted instructions and text; each call gets a fresh ephemeral thread while the exact run process may be reused. A retained turn instead uses `ModelContinuationMode::ProviderRetained`, an exact `AiProviderSessionTurnPlan`, and a configured `AiProviderSessionService`. -Creation sends only immutable model and reviewed dynamic-tool definitions to -an empty persistent thread. It sends no developer instruction or user input -until the opaque cursor is durably protected and claimed. Resume binds the -cursor to the exact owner/session/scope/profile/model/executable/protocol/ -policy/transcript/run fence. - -Experimental dynamic tools require -`AiCodexAppServerRegistration::with_experimental_dynamic_tools` and +Creation sends only the immutable model, optional compile-time static +`AiCodexAppServerBootstrapInstructions`, and reviewed dynamic-tool definitions +to an empty persistent thread. It sends no user input, request-local +instruction, route context, secret, or resolver result until the opaque cursor +is durably protected and claimed. The bootstrap fingerprint is part of the +registration identity, and retained requests must leave +`ModelRequest::instructions` empty. First activation and every resume prove +the same bootstrap, cursor, owner/session/scope/profile/model/executable/ +protocol/policy/transcript/run fence before business input can start. + +One protocol actor may perform sequential lifecycle cycles on its retained +process. Each typed `thread/start` or `thread/resume` begins a private +observation phase. New thread creation requires exactly one correlated +response and one matching `thread/started` notification in either order. +Retained resume uses that same pair when both frames are delivered. Codex +0.147.0 may instead deliver one cumulative `thread/tokenUsage/updated` +snapshot around the correlated response. The actor validates its complete +nonnegative generated shape and exact thread correlation, discards all token +values, and permits that content-free snapshot to close only the typed resume +phase once its response is also present. The snapshot is not charged to the +new run and cannot complete initial creation. The next resume and `turn/start` +remain closed until the applicable phase is complete. There is no public state +reset, and the retained model and dynamic-tool definitions cannot change +between creation, resume, or later terminal turns. + +Deletion completes from the exact empty successful `thread/delete` response. +It never depends on or admits `thread/status/changed`; the fixed initialization +profile suppresses that notification for the connection. + +Experimental dynamic tools require a registration using +`AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1`, a process +factory that attests that exact profile, and `AiReadOnlyAgentTurnPlan::new_experimental_dynamic_tools`. The provider process receives no application credential or resolver transport. An exact `item/tool/call` is schema/fingerprint matched to the current `ModelRequest`, @@ -94,6 +179,24 @@ disclosure- and egress-approved result is returned to app-server. Unknown, duplicate, stale, over-limit, changed-policy, or incomplete calls poison the turn and make a retained cursor cleanup-only. +Owning subgraphs compile generated or custom profiles into a canonical +manifest and register it in `AiToolCatalog`. Build the provider definition with +`AiToolCatalog::read_only_model_definition`; do not copy the description, +argument schema, stable ID, or descriptor fingerprint into host code. Codex +0.147.0 accepts a smaller JSON Schema subset than the canonical profile +contract. The adapter therefore performs one closed deterministic projection: +it removes only unsupported schema meta/constraint keywords, carries scalar +bounds into the provider-visible property description, and fingerprints the +projection together with the exact canonical descriptor. The unmodified +canonical schema remains authoritative when a dynamic call is admitted and +again at coordinator execution, so projection cannot weaken the accepted +argument range. + +JSON-RPC request identifier zero is valid and is used by Codex 0.147.0 for its +first server-initiated dynamic call. The actor correlates it in the same +private pending-request map as every other unsigned identifier; accepting zero +does not weaken method, lifecycle, schema, tool, owner, run, or cursor checks. + Coordinator cancellation and terminal paths call `interrupt_run` and `close_run` through `AiRuntime`. The process binding includes a non-exported owner fingerprint for admission only; it grants no provider or application @@ -207,16 +310,21 @@ enforce this creation order: 2. use the host-planned immutable `AiProviderSessionDescriptor` and canonical transcript-prefix fingerprint; 3. call `bind_for_run` under the current run lease; -4. only after the protected binding commits, send business content; and -5. after protected assistant output, its matching +4. call `open_for_run`, preserve the crate-owned newly-bound activation, and + consume it once on the exact process/cursor that created the empty thread; +5. start the first `turn/start` directly on that already-loaded thread without + issuing `thread/resume`, then send business content; and +6. after protected assistant output, its matching `assistant_output_persisted` checkpoint, and canonical `Completed` run state commit, call `commit_turn` with the new authoritative watermark/fingerprint. If this retention-only update fails, quarantine the cursor without changing the already-completed user answer. -Resume uses `claim_for_run` and then `open_for_run`. Both require the exact +Later-run resume uses `claim_for_run` and then `open_for_run`. Both require the exact descriptor and transcript evidence, current principal/session/scope access, -and current run fence. A crash, cancellation, protocol error, ambiguous +and current run fence. Its provider adapter performs the strict +`thread/resume` response/notification lifecycle before `turn/start`; it cannot +reuse the one-shot newly-bound activation. A crash, cancellation, protocol error, ambiguous provider state, output-persistence failure, policy/profile/model/executable drift, or rejected cursor calls `require_cleanup`; v1 never guesses provider state or advances a watermark from incomplete evidence. diff --git a/crates/graphql-orm-ai/docs/release-process.md b/crates/graphql-orm-ai/docs/release-process.md index aaa777b4..b7cd78ee 100644 --- a/crates/graphql-orm-ai/docs/release-process.md +++ b/crates/graphql-orm-ai/docs/release-process.md @@ -55,7 +55,10 @@ release/base branch. Persistent schema changes also require a new behavior, backup inclusion, and public error changes. 6. Confirm no test used a live database or real consumer integration. 7. Move `Unreleased` notes to the release version/date, update `Cargo.toml` and - `Cargo.lock`, commit, and create an annotated tag. + `Cargo.lock`, and commit. The protected workspace release workflow creates + the qualified `graphql-orm-ai-v` tag and workspace release tag only + after the complete release matrix passes. Git consumers pin the reviewed full tag commit. Do not depend on a moving -default branch. +default branch. The canonical repository-wide procedure is the +[workspace release process](../../../docs/operations/release/process.md). diff --git a/crates/graphql-orm-ai/src/lib.rs b/crates/graphql-orm-ai/src/lib.rs index c041612a..cf6a81c3 100644 --- a/crates/graphql-orm-ai/src/lib.rs +++ b/crates/graphql-orm-ai/src/lib.rs @@ -112,7 +112,6 @@ mod provider_activity; #[cfg(any(feature = "sqlite", feature = "postgres"))] mod provider_calls; mod provider_run; -#[cfg(any(feature = "sqlite", feature = "postgres"))] mod provider_session; mod providers; mod remote_execution; @@ -227,7 +226,6 @@ pub use provider_activity::*; #[cfg(any(feature = "sqlite", feature = "postgres"))] pub use provider_calls::*; pub use provider_run::*; -#[cfg(any(feature = "sqlite", feature = "postgres"))] pub use provider_session::*; pub use providers::*; pub use remote_execution::*; diff --git a/crates/graphql-orm-ai/src/orm_coordinator.rs b/crates/graphql-orm-ai/src/orm_coordinator.rs index efe6d426..999f0ceb 100644 --- a/crates/graphql-orm-ai/src/orm_coordinator.rs +++ b/crates/graphql-orm-ai/src/orm_coordinator.rs @@ -2023,6 +2023,11 @@ mod tests { delay: Option, } + #[cfg(feature = "provider-codex-app-server")] + struct CanonicalDynamicProviderExecutor { + definition: crate::ModelToolDefinition, + } + impl TestProviderExecutor { fn remaining_responses(&self) -> usize { self.responses @@ -2078,6 +2083,41 @@ mod tests { } } + #[cfg(feature = "provider-codex-app-server")] + #[async_trait] + impl AiAgentProviderTurnExecutor for CanonicalDynamicProviderExecutor { + async fn execute_turn( + &self, + _lease: &AiRunLease, + _plan: AiProviderCallPlan, + ) -> Result { + Err(AiError::Conflict) + } + + async fn execute_dynamic_turn( + &self, + lease: Arc>, + _plan: AiProviderCallPlan, + execution: Arc, + ) -> Result { + let current = lease.lock().await.clone(); + let mut result = AiProviderCallResult::test_result( + ¤t, + None, + "canonical-dynamic-response", + vec![( + "canonical-dynamic-call", + self.definition.tool_id.as_str(), + json!({"Limit": 3}), + )], + ); + let persisted = execution.execute_dynamic_tool(¤t, &result, 0).await?; + *lease.lock().await = persisted.lease().clone(); + result = result.test_with_interactive_tool_results(vec![persisted]); + Ok(result) + } + } + struct RetainedTestProviderExecutor { result: Mutex>, claim: crate::AiProviderSessionClaim, @@ -2246,6 +2286,13 @@ mod tests { continuation_count: AtomicUsize, } + #[cfg(feature = "provider-codex-app-server")] + struct CanonicalDynamicPlanner { + scope: AiScope, + route: AiToolResultEgressRoute, + plan: AiProviderCallPlan, + } + struct TestRetainedChatPlanner { scope: AiScope, provider_session: crate::AiProviderSessionTurnPlan, @@ -2330,6 +2377,31 @@ mod tests { } } + #[cfg(feature = "provider-codex-app-server")] + #[async_trait] + impl AiReadOnlyAgentTurnPlanner for CanonicalDynamicPlanner { + async fn initial_plan( + &self, + _lease: &AiRunLease, + ) -> Result { + AiReadOnlyAgentTurnPlan::new_experimental_dynamic_tools( + self.plan.clone(), + self.route.clone(), + test_rules(self.scope.clone()), + false, + ) + } + + async fn continuation_plan( + &self, + _lease: &AiRunLease, + _provider_turns: u32, + _continuation: AiAgentContinuation, + ) -> Result { + Err(AiError::Conflict) + } + } + #[async_trait] impl AiReadOnlyAgentTurnPlanner for TestRetainedChatPlanner { async fn initial_plan( @@ -2864,6 +2936,94 @@ mod tests { } } + #[cfg(feature = "provider-codex-app-server")] + fn canonical_dynamic_plan( + lease: &AiRunLease, + ) -> (AiProviderCallPlan, crate::ModelToolDefinition) { + let (catalog, definition) = crate::providers::canonical_dynamic_tool_catalog(); + let tool_id = crate::AiToolId::parse(definition.tool_id.clone()) + .expect("canonical generated tool ID should validate"); + let descriptor = catalog + .descriptor(&tool_id) + .expect("canonical generated descriptor should be registered"); + let mut policy = crate::AiToolPolicySet::new(crate::ToolMaturity::ReadOnly); + policy.bind(crate::AiToolPolicyBinding { + tool_id, + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + let scope = test_scope(); + let request = crate::ModelRequest { + model: "model-1".to_owned(), + instructions: Vec::new(), + input: vec![crate::ModelInputBlock::Text { + text: "Use inventory_count with Limit 3 and return the count.".to_owned(), + }], + continuation: None, + continuation_mode: crate::ModelContinuationMode::ProviderRetained, + tools: vec![definition.clone()], + builtin_tools: Vec::new(), + maximum_builtin_tool_calls: None, + reasoning_summary: crate::ModelReasoningSummaryRequest::Disabled, + output_schema: None, + maximum_output_tokens: Some(128), + }; + let budget = crate::AiBudgetReservationRequest { + scope: scope.clone(), + session_id: lease.session_id(), + run_id: lease.run_id(), + attempt_id: lease.attempt_id(), + lease_generation: lease.lease_generation(), + provider_kind: crate::ProviderKind::LocalHarness, + model: request.model.clone(), + pricing_policy_version: "canonical-codex-v1".to_owned(), + estimate: crate::AiBudgetAmounts { + output_tokens: 128, + runs: 1, + tool_units: 1, + ..crate::AiBudgetAmounts::default() + }, + idempotency_key: Uuid::new_v4().to_string(), + expires_at: time::OffsetDateTime::now_utc() + Duration::minutes(5), + }; + let manifest = AiEgressManifest { + provider_profile_id: "canonical-codex-profile".to_owned(), + provider_kind: crate::ProviderKind::LocalHarness.as_str().to_owned(), + model: request.model.clone(), + destination: "sandboxed-local-harness".to_owned(), + destination_trust: AiDestinationTrust::Local, + capability: AiEgressCapability::ModelInference, + scope, + session_id: Some(lease.session_id()), + run_id: Some(lease.run_id()), + sources: vec![AiDataSourceRef { + kind: "user_message".to_owned(), + reference: "canonical-generated-tool-test".to_owned(), + classification: DataClassification::Internal, + trust: AiSourceTrust::UserProvided, + }], + estimated_bytes: request.conservative_egress_bytes(), + estimated_tokens: 64, + attachment_count: 0, + purpose: "answer-with-registered-tool".to_owned(), + retention: "provider-session".to_owned(), + residency: None, + policy_version: "canonical-egress-v1".to_owned(), + consent_reference: None, + }; + let plan = AiProviderCallPlan::new_with_tools( + crate::ProviderKind::LocalHarness, + request, + budget, + vec![manifest], + "canonical-generated-dynamic-turn", + &catalog, + &policy, + ) + .expect("canonical generated dynamic plan should validate"); + (plan, definition) + } + fn adopted_read_only_checkpoint( lease: &AiRunLease, checkpoint_id: Uuid, @@ -3148,23 +3308,25 @@ mod tests { assert_eq!(session_service.cleanups.load(Ordering::SeqCst), 1); } + #[cfg(feature = "provider-codex-app-server")] #[tokio::test] async fn experimental_dynamic_turn_uses_ordinary_tool_boundary_and_no_continuation() { let lease = AiRunLease::test_running(principal_reference()); let run = Arc::new(TestRunControl::new()); - let provider = Arc::new(TestProviderExecutor { - responses: Mutex::new(VecDeque::from([Ok(AiProviderCallResult::test_result( - &lease, - None, - "response-dynamic-final", - vec![("dynamic-call-1", "test.read", json!({}))], - ))])), - delay: None, - }); - let planner = Arc::new(TestDynamicPlanner { + let (plan, definition) = canonical_dynamic_plan(&lease); + let provider = Arc::new(CanonicalDynamicProviderExecutor { definition }); + let planner = Arc::new(CanonicalDynamicPlanner { scope: test_scope(), - route: test_route(), - continuation_count: AtomicUsize::new(0), + route: AiToolResultEgressRoute::new( + "canonical-codex-profile", + "sandboxed-local-harness", + AiDestinationTrust::Local, + "answer-with-registered-tool", + "provider-session", + "canonical-egress-v1", + ) + .expect("canonical dynamic route should validate"), + plan, }); let forbidden_checkpoints = Arc::new(ChatForbiddenBoundaries::default()); let coordinator = AiReadOnlyAgentCoordinator::new( @@ -3206,7 +3368,6 @@ mod tests { .load(Ordering::SeqCst), 0 ); - assert_eq!(planner.continuation_count.load(Ordering::SeqCst), 0); assert_eq!(run.final_states(), vec![AiRunState::Completed]); } diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs index f1563ada..09f48793 100644 --- a/crates/graphql-orm-ai/src/provider.rs +++ b/crates/graphql-orm-ai/src/provider.rs @@ -1024,7 +1024,6 @@ pub struct ProviderRequestContext { session_id: AiSessionId, run_id: AiRunId, run_binding: Option, - #[cfg(any(feature = "sqlite", feature = "postgres"))] provider_session: Option, correlation_id: String, budget: AuthorizedBudgetReservation, @@ -1051,7 +1050,6 @@ impl ProviderRequestContext { session_id, run_id, run_binding: None, - #[cfg(any(feature = "sqlite", feature = "postgres"))] provider_session: None, correlation_id: correlation_id.into(), budget, @@ -1197,7 +1195,6 @@ impl ProviderRequestContext { } /// Freshly authorized retained provider session for this exact request. - #[cfg(any(feature = "sqlite", feature = "postgres"))] pub fn provider_session(&self) -> Option<&crate::AiOpenedProviderSession> { self.provider_session.as_ref() } diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index 080522c1..950bd71c 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -2115,20 +2115,23 @@ impl AiProviderCallExecutor { } let lease = lease_state.lock().await.clone(); let binding = crate::AiProviderRunBinding::from_lease(&lease)?; - let claim = match session_service.inspect_for_run(&lease).await? { + let (claim, newly_bound_cursor) = match session_service.inspect_for_run(&lease).await? { Some(existing) if existing.state() == crate::AiProviderSessionState::Active && existing.descriptor() == session_plan.descriptor() && existing.transcript_fingerprint() == session_plan.transcript_fingerprint() => { - session_service - .claim_for_run( - &lease, - session_plan.descriptor(), - session_plan.transcript_fingerprint(), - ) - .await? + ( + session_service + .claim_for_run( + &lease, + session_plan.descriptor(), + session_plan.transcript_fingerprint(), + ) + .await?, + None, + ) } Some(_) => return Err(AiError::Conflict), None => { @@ -2149,7 +2152,7 @@ impl AiProviderCallExecutor { None, )?; match session_service.bind_for_run(&lease, request).await { - Ok(claim) => claim, + Ok(claim) => (claim, Some(cursor)), Err(error) => { let _ = self .runtime @@ -2168,12 +2171,45 @@ impl AiProviderCallExecutor { let opened = match session_service.open_for_run(&lease, &claim).await { Ok(opened) => opened, Err(error) => { + if let Some(cursor) = &newly_bound_cursor { + let _ = self + .runtime + .discard_empty_provider_session( + session_plan.descriptor().provider_kind(), + &binding, + session_plan.descriptor(), + cursor, + ) + .await; + } let _ = session_service .require_cleanup(&claim, "provider_session_open_failed") .await; return Err(error); } }; + let opened = if let Some(cursor) = &newly_bound_cursor { + match opened.activate_newly_bound_empty(binding, cursor) { + Ok(opened) => opened, + Err(error) => { + let _ = self + .runtime + .discard_empty_provider_session( + session_plan.descriptor().provider_kind(), + &binding, + session_plan.descriptor(), + cursor, + ) + .await; + let _ = session_service + .require_cleanup(&claim, "provider_session_activation_failed") + .await; + return Err(error); + } + } + } else { + opened + }; let turn = self.execute_inner(lease_state.clone(), plan, dynamic_execution, Some(opened)); tokio::pin!(turn); let mut current_claim = claim; @@ -6977,6 +7013,115 @@ mod tests { .expect("caller can terminally finish after handling the result"); } + #[tokio::test] + async fn executor_marks_only_newly_bound_empty_provider_session_for_initial_turn() { + let cursor = AiProviderSessionCursor::new("mock.thread", "new-empty-thread") + .expect("test cursor should validate"); + let mock = MockProvider::new(vec![ + ProviderEvent::ResponseStarted { response_id: None }, + ProviderEvent::TextDelta { + text: "initial retained output".to_owned(), + }, + ProviderEvent::Usage { + input_tokens: 8, + output_tokens: 3, + cached_input_tokens: 0, + }, + ProviderEvent::ResponseCompleted { response_id: None }, + ]) + .with_provider_session_cursor(cursor); + let fixture = fixture_with_provider(mock).await; + let session = AiSessionRecord::find_by_id(&fixture.database, &fixture.lease.session_id().0) + .await + .expect("session lookup should succeed") + .expect("session should exist"); + let update = AiSessionRecord::compare_and_swap( + &fixture.database, + &session.id, + session.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + message_head: Some(1), + ..Default::default() + }, + ) + .await + .expect("session watermark update should succeed"); + assert!(matches!(update, ConditionalUpdateOutcome::Updated(_))); + AiMessageRecord::insert( + &fixture.database, + CreateAiMessageRecordInput { + id: fixture.lease.input_message_id(), + session_id: fixture.lease.session_id().0, + sequence: 1, + message_role: "user".to_owned(), + author_principal_kind: Some("user".to_owned()), + author_subject: Some(fixture.principal.subject().to_owned()), + client_message_id: Some(Uuid::new_v4()), + content_hash: Some("c".repeat(64)), + run_id: Some(fixture.lease.run_id().0), + provider_kind: None, + provider_model: None, + protected_preview: None, + block_count: 1, + completion_state: "complete".to_owned(), + finalized_at: Some(OffsetDateTime::now_utc().unix_timestamp()), + content_purged_at: None, + }, + ) + .await + .expect("input message should insert"); + + let provider_sessions = Arc::new( + OrmAiProviderSessionService::new( + fixture.database.clone(), + Arc::new(AllowAccess), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + Arc::new(Resolver(fixture.principal.clone())), + Arc::new(SystemClock), + AiProviderSessionLimits::default(), + Duration::minutes(5), + ) + .expect("provider-session service should validate"), + ); + let descriptor = AiProviderSessionDescriptor::new( + ProviderKind::OpenAiCompatible, + "mock-profile", + "mock-model", + "a".repeat(64), + "mock-retained/v1", + "b".repeat(64), + ) + .expect("descriptor should validate"); + let session_plan = AiProviderSessionTurnPlan::new(descriptor, "d".repeat(64)) + .expect("session plan should validate"); + let executor = AiProviderCallExecutor::new( + fixture.runtime.clone(), + fixture.budget_service.clone(), + fixture.audit.clone(), + Arc::new(TestUsageAccounting), + Arc::new(SystemClock), + AiProviderCallLimits::new(64, 8_192, 64 * 1_024) + .expect("provider limits should validate"), + ); + let result = executor + .execute_with_provider_session( + Arc::new(Mutex::new(fixture.lease.clone())), + plan(&fixture), + session_plan, + provider_sessions, + None, + ) + .await + .expect("initial retained provider turn should succeed"); + assert!(result.provider_session_claim().is_some()); + assert_eq!( + fixture.mock.provider_session_activations(), + vec![AiProviderSessionActivation::NewlyBoundEmpty] + ); + } + #[tokio::test] async fn completed_builtins_are_counted_but_requested_unused_tools_are_not() { let completed_fixture = fixture(vec![ diff --git a/crates/graphql-orm-ai/src/provider_run.rs b/crates/graphql-orm-ai/src/provider_run.rs index 53f490df..cbee8cfa 100644 --- a/crates/graphql-orm-ai/src/provider_run.rs +++ b/crates/graphql-orm-ai/src/provider_run.rs @@ -2,7 +2,6 @@ use uuid::Uuid; -#[cfg(any(feature = "sqlite", feature = "postgres"))] use sha2::{Digest, Sha256}; #[cfg(any(feature = "sqlite", feature = "postgres"))] @@ -51,28 +50,44 @@ impl AiProviderRunBinding { #[cfg(any(feature = "sqlite", feature = "postgres"))] pub(crate) fn from_lease(lease: &AiRunLease) -> Result { - let reference = lease.principal_reference(); - let mut digest = Sha256::new(); - digest.update(b"graphql-orm-ai/provider-run-owner/v1\0"); - match &reference.kind { - agql_auth::PrincipalReferenceKind::UserSession => digest.update(b"user_session\0"), - agql_auth::PrincipalReferenceKind::ApiToken { principal_kind } => { - digest.update(b"api_token\0"); - digest.update((principal_kind.len() as u64).to_be_bytes()); - digest.update(principal_kind.as_bytes()); - } - } - digest.update((reference.subject.len() as u64).to_be_bytes()); - digest.update(reference.subject.as_bytes()); Self::new( lease.session_id(), lease.run_id(), lease.attempt_id(), lease.lease_generation(), - digest.finalize().into(), + provider_run_owner_fingerprint(lease.principal_reference()), ) } + #[cfg(all( + test, + feature = "provider-codex-app-server", + any(feature = "sqlite", feature = "postgres") + ))] + pub(crate) fn new_for_principal_reference( + session_id: AiSessionId, + run_id: AiRunId, + attempt_id: Uuid, + lease_generation: i64, + reference: &agql_auth::PrincipalReference, + ) -> Result { + Self::new( + session_id, + run_id, + attempt_id, + lease_generation, + provider_run_owner_fingerprint(reference), + ) + } + + #[cfg_attr(feature = "mssql", allow(dead_code))] + pub(crate) fn matches_principal_reference( + self, + reference: &agql_auth::PrincipalReference, + ) -> bool { + self.owner_fingerprint == provider_run_owner_fingerprint(reference) + } + /// Owning durable AI session. pub const fn session_id(self) -> AiSessionId { self.session_id @@ -99,6 +114,23 @@ impl AiProviderRunBinding { } } +#[cfg_attr(feature = "mssql", allow(dead_code))] +fn provider_run_owner_fingerprint(reference: &agql_auth::PrincipalReference) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"graphql-orm-ai/provider-run-owner/v1\0"); + match &reference.kind { + agql_auth::PrincipalReferenceKind::UserSession => digest.update(b"user_session\0"), + agql_auth::PrincipalReferenceKind::ApiToken { principal_kind } => { + digest.update(b"api_token\0"); + digest.update((principal_kind.len() as u64).to_be_bytes()); + digest.update(principal_kind.as_bytes()); + } + } + digest.update((reference.subject.len() as u64).to_be_bytes()); + digest.update(reference.subject.as_bytes()); + digest.finalize().into() +} + /// Why a run-scoped provider resource is being closed. /// /// This value is lifecycle metadata only. It must not be used to infer a diff --git a/crates/graphql-orm-ai/src/provider_session.rs b/crates/graphql-orm-ai/src/provider_session.rs index 36c9f6be..9097913d 100644 --- a/crates/graphql-orm-ai/src/provider_session.rs +++ b/crates/graphql-orm-ai/src/provider_session.rs @@ -6,6 +6,8 @@ //! adapter or host, while this module describes the durable, protected binding //! needed to resume provider state without weakening run fencing. +#![cfg_attr(feature = "mssql", allow(dead_code))] + use std::fmt; use agql_auth::PrincipalReference; @@ -632,11 +634,34 @@ impl AiProviderSessionClaim { pub struct AiOpenedProviderSession { claim: AiProviderSessionClaim, cursor: AiProviderSessionCursor, + activation: AiProviderSessionActivation, } impl AiOpenedProviderSession { pub(crate) fn new(claim: AiProviderSessionClaim, cursor: AiProviderSessionCursor) -> Self { - Self { claim, cursor } + Self { + claim, + cursor, + activation: AiProviderSessionActivation::ExistingRetained, + } + } + + pub(crate) fn activate_newly_bound_empty( + mut self, + binding: crate::AiProviderRunBinding, + created_cursor: &AiProviderSessionCursor, + ) -> Result { + if self.claim.session_id != binding.session_id() + || self.claim.run_id != binding.run_id() + || self.claim.attempt_id != binding.attempt_id() + || self.claim.run_lease_generation != binding.lease_generation() + || !binding.matches_principal_reference(&self.claim.principal_reference) + || self.cursor != *created_cursor + { + return Err(AiError::Conflict); + } + self.activation = AiProviderSessionActivation::NewlyBoundEmpty; + Ok(self) } /// Exact fenced claim receiving provider transport. @@ -648,6 +673,14 @@ impl AiOpenedProviderSession { pub const fn cursor(&self) -> &AiProviderSessionCursor { &self.cursor } + + #[cfg_attr( + not(any(test, feature = "provider-codex-app-server")), + allow(dead_code) + )] + pub(crate) const fn activation(&self) -> AiProviderSessionActivation { + self.activation + } } impl fmt::Debug for AiOpenedProviderSession { @@ -656,10 +689,17 @@ impl fmt::Debug for AiOpenedProviderSession { .debug_struct("AiOpenedProviderSession") .field("claim", &self.claim) .field("cursor", &self.cursor) + .field("activation", &self.activation) .finish() } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AiProviderSessionActivation { + NewlyBoundEmpty, + ExistingRetained, +} + /// Exact durable assistant-output proof used to advance a provider session. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AiProviderSessionCommit { diff --git a/crates/graphql-orm-ai/src/providers.rs b/crates/graphql-orm-ai/src/providers.rs index cf05368c..e5860890 100644 --- a/crates/graphql-orm-ai/src/providers.rs +++ b/crates/graphql-orm-ai/src/providers.rs @@ -27,6 +27,9 @@ mod ollama; #[cfg(feature = "provider-codex-app-server")] mod codex_app_server; +#[cfg(all(test, feature = "provider-codex-app-server"))] +pub(crate) use codex_app_server::tests::canonical_dynamic_tool_catalog; + #[cfg(all(test, feature = "provider-openai"))] pub(crate) use mock::MockBackgroundRetrievalFailure; pub use mock::MockProvider; @@ -61,8 +64,9 @@ pub use ollama::{OllamaProvider, OllamaProviderConfig}; #[cfg(feature = "provider-codex-app-server")] pub use codex_app_server::{ - AI_CODEX_APP_SERVER_PROTOCOL_V2, AiCodexAppServerInbound, AiCodexAppServerLaunchedProcess, - AiCodexAppServerProtocolActor, AiCodexAppServerProvider, AiCodexAppServerRegistration, - AiCodexAppServerRunLimits, AiCodexAppServerRunPool, AiCodexAppServerRunProcess, - AiCodexAppServerRunProcessFactory, AiCodexAppServerTurnInput, + AI_CODEX_APP_SERVER_PROTOCOL_V2, AiCodexAppServerBootstrapInstructions, + AiCodexAppServerInbound, AiCodexAppServerLaunchProfile, AiCodexAppServerLaunchedProcess, + AiCodexAppServerModelToolMode, AiCodexAppServerProtocolActor, AiCodexAppServerProvider, + AiCodexAppServerRegistration, AiCodexAppServerRunLimits, AiCodexAppServerRunPool, + AiCodexAppServerRunProcess, AiCodexAppServerRunProcessFactory, AiCodexAppServerTurnInput, }; diff --git a/crates/graphql-orm-ai/src/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 9d09721c..dad22566 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -8,6 +8,8 @@ //! ordinary coordinator. Every other server-initiated request remains //! forbidden. +#![cfg_attr(feature = "mssql", allow(dead_code))] + use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; @@ -34,15 +36,278 @@ const MAXIMUM_TURNS_PER_RUN: u32 = 1_024; const MAXIMUM_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAXIMUM_TEXT_BYTES: usize = 16 * 1024 * 1024; const MAXIMUM_TEXT_BLOCKS: usize = 256; +const MAXIMUM_BOOTSTRAP_INSTRUCTION_BLOCKS: usize = 16; +const MAXIMUM_BOOTSTRAP_INSTRUCTION_BYTES: usize = 64 * 1024; const MAXIMUM_IDENTIFIER_BYTES: usize = 200; const MAXIMUM_VERSION_BYTES: usize = 200; +const MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES: usize = 4 * 1024; +const MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN: usize = 16 * 1024; +const MAXIMUM_RUNTIME_WARNINGS_PER_TURN: usize = 8; const MAXIMUM_TIMEOUT: Duration = Duration::from_secs(60 * 60); +const OPTED_OUT_NOTIFICATION_METHODS: [&str; 5] = [ + "thread/status/changed", + "thread/settings/updated", + "thread/goal/cleared", + "mcpServer/startupStatus/updated", + "account/rateLimits/updated", +]; const REMOTE_CONTROL_STATUS_CHANGED: &str = "remoteControl/status/changed"; +const RUNTIME_WARNING: &str = "warning"; +const THREAD_TOKEN_USAGE_UPDATED: &str = "thread/tokenUsage/updated"; + +const DYNAMIC_TOOLS_ONLY_DISABLED_FEATURES: &[&str] = &[ + "apps", + "auth_elicitation", + "browser_use", + "browser_use_external", + "browser_use_full_cdp_access", + "code_mode", + "code_mode_host", + "code_mode_only", + "computer_use", + "current_time_reminder", + "default_mode_request_user_input", + "deferred_executor", + "enable_mcp_apps", + "goals", + "hooks", + "image_generation", + "in_app_browser", + "multi_agent", + "plugins", + "recommended_plugins", + "remote_plugin", + "request_permissions_tool", + "shell_snapshot", + "shell_tool", + "skill_mcp_dependency_install", + "skill_search", + "standalone_web_search", + "token_budget", + "tool_call_mcp_elicitation", + "tool_suggest", + "unified_exec", + "view_image", + "workspace_dependencies", +]; /// Exact reviewed Codex app-server protocol contract supported by this /// adapter. pub const AI_CODEX_APP_SERVER_PROTOCOL_V2: &str = "app-server-v2"; +/// Static deployment-owned instructions installed when a retained Codex +/// thread is created. +/// +/// This proof is deliberately separate from [`ModelRequest::instructions`]. +/// A retained thread accepts no request-local instructions: browser input, +/// route context, secrets, resolver output, and model-authored text therefore +/// cannot be smuggled into the privileged developer-instruction channel. The +/// exact bounded text is fingerprinted into the immutable provider +/// registration and is rechecked at empty-thread creation, first activation, +/// and every resume. +#[derive(Clone, PartialEq, Eq)] +pub struct AiCodexAppServerBootstrapInstructions { + blocks: Vec, + fingerprint: String, +} + +impl std::fmt::Debug for AiCodexAppServerBootstrapInstructions { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AiCodexAppServerBootstrapInstructions") + .field("blocks", &"") + .field("block_count", &self.blocks.len()) + .field("fingerprint", &self.fingerprint) + .finish() + } +} + +impl AiCodexAppServerBootstrapInstructions { + /// Creates a disabled bootstrap with no developer instructions. + pub fn disabled() -> Self { + Self::from_blocks(Vec::new()).expect("an empty static bootstrap is valid") + } + + /// Creates one bounded bootstrap from compile-time static host text. + /// + /// The static lifetime makes the intended trust boundary explicit. Hosts + /// must keep only reusable deployment policy here; per-user or per-request + /// data belongs in ordinary model input and application-tool results. + /// + /// # Errors + /// + /// Returns [`ProviderError::InvalidConfiguration`] for an empty block, + /// control characters other than tab/newline, excessive block count, or + /// an aggregate size above 64 KiB. + pub fn from_static(blocks: &'static [&'static str]) -> Result { + Self::from_blocks(blocks.iter().map(|value| (*value).to_owned()).collect()) + } + + fn from_blocks(blocks: Vec) -> Result { + let total_bytes = blocks.iter().try_fold(0_usize, |total, block| { + total + .checked_add(block.len()) + .ok_or(ProviderError::InvalidConfiguration( + "Codex bootstrap instructions are too large".to_owned(), + )) + })?; + if blocks.len() > MAXIMUM_BOOTSTRAP_INSTRUCTION_BLOCKS + || total_bytes > MAXIMUM_BOOTSTRAP_INSTRUCTION_BYTES + || blocks.iter().any(|block| { + block.trim().is_empty() + || block + .chars() + .any(|value| value.is_control() && !matches!(value, '\n' | '\t')) + }) + { + return Err(ProviderError::InvalidConfiguration( + "invalid Codex bootstrap instructions".to_owned(), + )); + } + let mut hasher = Sha256::new(); + hasher.update(b"graphql-orm-ai/codex-app-server-bootstrap/v1\0"); + for block in &blocks { + hasher.update((block.len() as u64).to_be_bytes()); + hasher.update(block.as_bytes()); + } + Ok(Self { + blocks, + fingerprint: hex::encode(hasher.finalize()), + }) + } + + /// Stable content fingerprint included in the registration identity. + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// Whether the retained thread has no static developer instructions. + pub fn is_disabled(&self) -> bool { + self.blocks.is_empty() + } + + fn joined(&self) -> Option { + (!self.blocks.is_empty()).then(|| self.blocks.join("\n\n")) + } +} + +impl Default for AiCodexAppServerBootstrapInstructions { + fn default() -> Self { + Self::disabled() + } +} + +/// Tool-delivery mode declared by the reviewed Codex model catalogue. +/// +/// The declaration is deployment evidence bound to the exact executable +/// digest and model registration. It is not selected by the model or a +/// request. Codex models declared as Code Mode-only cannot safely advertise +/// direct dynamic tools when the Code Mode host is unavailable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AiCodexAppServerModelToolMode { + /// Ordinary Responses function tools are model-visible directly. + Direct, + /// The model prefers Code Mode but may fall back to direct tools. + CodeMode, + /// The model exposes tools only through Code Mode. + CodeModeOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AiCodexAppServerLaunchProfileKind { + StrictTextOnlyV1, + ExperimentalDynamicToolsOnlyV1, +} + +/// Closed Codex app-server launch and thread-isolation contract. +/// +/// The dynamic-tools-only profile fixes the reviewed CLI feature disables, +/// supplies an empty environment list for every dynamic thread/turn, disables +/// ordinary utility and hosted-search tools in thread configuration, and +/// requires an isolated configuration home. The process factory remains +/// responsible for applying the returned argument vector and operating-system +/// sandbox exactly; it cannot substitute a broader profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AiCodexAppServerLaunchProfile { + kind: AiCodexAppServerLaunchProfileKind, +} + +impl AiCodexAppServerLaunchProfile { + /// Strict protocol profile for tool-free text turns. + pub const fn strict_text_only_v1() -> Self { + Self { + kind: AiCodexAppServerLaunchProfileKind::StrictTextOnlyV1, + } + } + + /// Creates the experimental dynamic-tools-only profile for a direct-tool + /// model registration. + /// + /// Code Mode and Code Mode-only catalogue declarations are rejected + /// because disabling the native Code Mode surface makes their direct + /// dynamic-tool availability unreliable or impossible. + /// + /// # Errors + /// + /// Returns [`ProviderError::InvalidConfiguration`] unless the reviewed + /// model catalogue declares [`AiCodexAppServerModelToolMode::Direct`]. + pub fn experimental_dynamic_tools_only_v1( + model_tool_mode: AiCodexAppServerModelToolMode, + ) -> Result { + if model_tool_mode != AiCodexAppServerModelToolMode::Direct { + return Err(ProviderError::InvalidConfiguration( + "Codex dynamic-tools-only profile requires a direct-tool model".to_owned(), + )); + } + Ok(Self { + kind: AiCodexAppServerLaunchProfileKind::ExperimentalDynamicToolsOnlyV1, + }) + } + + /// Exact CLI arguments following the reviewed Codex executable path. + /// + /// The dynamic profile deliberately disables every native execution, + /// browser, hosted-search, connector, collaboration, image, plugin, and + /// interactive tool feature it relies on being absent. The factory must + /// also clear the environment, use a private configuration home containing + /// no project configuration or MCP servers, use an empty working + /// directory, and apply its reviewed external sandbox. + #[must_use] + pub fn codex_arguments(self) -> Vec<&'static str> { + let mut arguments = vec!["app-server", "--stdio", "--strict-config"]; + if self.supports_experimental_dynamic_tools() { + for feature in DYNAMIC_TOOLS_ONLY_DISABLED_FEATURES { + arguments.extend(["--disable", *feature]); + } + } + arguments + } + + /// Whether this closed profile supports the experimental dynamic-tool + /// protocol without native execution tools. + pub const fn supports_experimental_dynamic_tools(self) -> bool { + matches!( + self.kind, + AiCodexAppServerLaunchProfileKind::ExperimentalDynamicToolsOnlyV1 + ) + } + + /// Whether the process must use a private configuration home containing + /// only the minimum provider authentication material. + pub const fn requires_isolated_configuration_home(self) -> bool { + true + } + + fn identity_label(self) -> &'static str { + match self.kind { + AiCodexAppServerLaunchProfileKind::StrictTextOnlyV1 => "strict-text-only-v1", + AiCodexAppServerLaunchProfileKind::ExperimentalDynamicToolsOnlyV1 => { + "experimental-dynamic-tools-only-v1" + } + } + } +} + /// Immutable, content-free identity of one reviewed Codex app-server /// installation and provider profile. /// @@ -58,7 +323,8 @@ pub struct AiCodexAppServerRegistration { executable_version: String, sandbox_profile: String, protocol_version: String, - experimental_dynamic_tools: bool, + launch_profile: AiCodexAppServerLaunchProfile, + bootstrap_instructions: AiCodexAppServerBootstrapInstructions, identity: String, } @@ -94,47 +360,66 @@ impl AiCodexAppServerRegistration { "invalid Codex app-server registration".to_owned(), )); } - let identity = registration_identity( - &provider_profile_id, - &logical_model, - &executable_sha256, - &executable_version, - &sandbox_profile, - &protocol_version, - false, - ); - Ok(Self { + let launch_profile = AiCodexAppServerLaunchProfile::strict_text_only_v1(); + let bootstrap_instructions = AiCodexAppServerBootstrapInstructions::disabled(); + let mut registration = Self { provider_profile_id, logical_model, executable_sha256, executable_version, sandbox_profile, protocol_version, - experimental_dynamic_tools: false, - identity, - }) + launch_profile, + bootstrap_instructions, + identity: String::new(), + }; + registration.refresh_identity(); + Ok(registration) } - /// Enables the reviewed experimental native dynamic-tool protocol. + /// Applies one closed reviewed app-server launch profile. /// /// This changes the immutable registration identity. It only permits the /// adapter to forward an exact provider request to a coordinator-owned - /// responder; it grants no application-tool or resolver authority. + /// responder; it grants no application-tool or resolver authority. A + /// process factory must separately attest that it implements this exact + /// profile before the provider advertises custom tools. #[must_use] - pub fn with_experimental_dynamic_tools(mut self) -> Self { - self.experimental_dynamic_tools = true; - self.identity = registration_identity( - &self.provider_profile_id, - &self.logical_model, - &self.executable_sha256, - &self.executable_version, - &self.sandbox_profile, - &self.protocol_version, - true, - ); + pub fn with_launch_profile(mut self, launch_profile: AiCodexAppServerLaunchProfile) -> Self { + self.launch_profile = launch_profile; + self.refresh_identity(); + self + } + + /// Installs immutable static developer instructions for retained threads. + /// + /// The instructions become part of the registration identity and cannot + /// vary by request. Changing them invalidates existing provider-session + /// bindings so a stale thread cannot inherit a new trust policy. + #[must_use] + pub fn with_bootstrap_instructions( + mut self, + bootstrap_instructions: AiCodexAppServerBootstrapInstructions, + ) -> Self { + self.bootstrap_instructions = bootstrap_instructions; + self.refresh_identity(); self } + fn refresh_identity(&mut self) { + self.identity = registration_identity(self); + } + + /// Exact immutable launch profile included in the registration identity. + pub const fn launch_profile(&self) -> AiCodexAppServerLaunchProfile { + self.launch_profile + } + + /// Exact static bootstrap proof bound to this registration. + pub fn bootstrap_instructions(&self) -> &AiCodexAppServerBootstrapInstructions { + &self.bootstrap_instructions + } + /// Deployment-owned provider profile identifier. pub fn provider_profile_id(&self) -> &str { &self.provider_profile_id @@ -168,7 +453,7 @@ impl AiCodexAppServerRegistration { /// Whether experimental app-server dynamic tools are enabled for this /// immutable registration. pub const fn experimental_dynamic_tools(&self) -> bool { - self.experimental_dynamic_tools + self.launch_profile.supports_experimental_dynamic_tools() } /// Stable content-free registration identity used to prevent configuration @@ -187,6 +472,7 @@ impl AiCodexAppServerRegistration { pub struct AiCodexAppServerTurnInput { model: String, instructions: Vec, + retained_bootstrap_fingerprint: Option, input: Vec, tools: Vec, maximum_output_tokens: u64, @@ -199,6 +485,10 @@ impl std::fmt::Debug for AiCodexAppServerTurnInput { .field("model", &self.model) .field("instructions", &"") .field("instruction_count", &self.instructions.len()) + .field( + "retained_bootstrap_fingerprint", + &self.retained_bootstrap_fingerprint, + ) .field("input", &"") .field("input_count", &self.input.len()) .field("tool_count", &self.tools.len()) @@ -224,6 +514,7 @@ impl AiCodexAppServerTurnInput { let turn = Self { model: model.into(), instructions, + retained_bootstrap_fingerprint: None, input, tools: Vec::new(), maximum_output_tokens, @@ -255,6 +546,14 @@ impl AiCodexAppServerTurnInput { { return Err(ProviderError::InvalidRequest); } + if let Some(fingerprint) = &self.retained_bootstrap_fingerprint { + let bootstrap = + AiCodexAppServerBootstrapInstructions::from_blocks(self.instructions.clone()) + .map_err(|_| ProviderError::InvalidRequest)?; + if !crate::valid_sha256(fingerprint) || bootstrap.fingerprint() != fingerprint { + return Err(ProviderError::InvalidRequest); + } + } Ok(()) } @@ -284,9 +583,28 @@ impl AiCodexAppServerTurnInput { } fn try_from_model_request(request: ModelRequest) -> Result { + Self::try_from_tool_free_request(request, ModelContinuationMode::StatelessReplay) + } + + fn try_from_retained_model_request( + request: ModelRequest, + bootstrap: &AiCodexAppServerBootstrapInstructions, + ) -> Result { + let request = retained_request_with_bootstrap(request, bootstrap)?; + let mut input = + Self::try_from_tool_free_request(request, ModelContinuationMode::ProviderRetained)?; + input.retained_bootstrap_fingerprint = Some(bootstrap.fingerprint().to_owned()); + input.validate()?; + Ok(input) + } + + fn try_from_tool_free_request( + request: ModelRequest, + expected_mode: ModelContinuationMode, + ) -> Result { request.validate()?; if request.continuation.is_some() - || request.continuation_mode != ModelContinuationMode::StatelessReplay + || request.continuation_mode != expected_mode || !request.tools.is_empty() || !request.builtin_tools.is_empty() || request.maximum_builtin_tool_calls.is_some() @@ -352,6 +670,36 @@ impl AiCodexAppServerTurnInput { turn.validate()?; Ok(turn) } + + fn try_from_retained_dynamic_request( + request: ModelRequest, + bootstrap: &AiCodexAppServerBootstrapInstructions, + ) -> Result { + let request = retained_request_with_bootstrap(request, bootstrap)?; + let mut input = Self::try_from_dynamic_request(request)?; + input.retained_bootstrap_fingerprint = Some(bootstrap.fingerprint().to_owned()); + input.validate()?; + Ok(input) + } + + fn instruction_fingerprint(&self) -> Result { + Ok( + AiCodexAppServerBootstrapInstructions::from_blocks(self.instructions.clone())? + .fingerprint() + .to_owned(), + ) + } +} + +fn retained_request_with_bootstrap( + mut request: ModelRequest, + bootstrap: &AiCodexAppServerBootstrapInstructions, +) -> Result { + if !request.instructions.is_empty() { + return Err(ProviderError::Rejected); + } + request.instructions = bootstrap.blocks.clone(); + Ok(request) } /// Resource limits for the run-scoped app-server pool. @@ -571,9 +919,13 @@ impl AiProvider for AiCodexAppServerProvider { } fn capabilities(&self) -> ProviderCapabilities { + let dynamic_tools_available = self.registration.experimental_dynamic_tools() + && self + .pool + .supports_launch_profile(self.registration.launch_profile()); ProviderCapabilities { streaming: true, - custom_tools: self.registration.experimental_dynamic_tools(), + custom_tools: dynamic_tools_available, provider_retained_continuation: true, local: true, ..ProviderCapabilities::default() @@ -593,11 +945,27 @@ impl AiProvider for AiCodexAppServerProvider { )?; let binding = context.run_binding().ok_or(ProviderError::Rejected)?; let retained = context.provider_session().cloned(); - let input = AiCodexAppServerTurnInput::try_from_model_request(request)?; + let input = if retained.is_some() { + AiCodexAppServerTurnInput::try_from_retained_model_request( + request, + self.registration.bootstrap_instructions(), + )? + } else { + AiCodexAppServerTurnInput::try_from_model_request(request)? + }; if let Some(session) = retained { - self.pool - .start_retained_turn(binding, self.registration.clone(), session, input) - .await + match session.activation() { + crate::AiProviderSessionActivation::NewlyBoundEmpty => { + self.pool + .start_bound_turn(binding, self.registration.clone(), session, input) + .await + } + crate::AiProviderSessionActivation::ExistingRetained => { + self.pool + .start_retained_turn(binding, self.registration.clone(), session, input) + .await + } + } } else { self.pool .start_fresh_turn(binding, self.registration.clone(), input) @@ -611,7 +979,11 @@ impl AiProvider for AiCodexAppServerProvider { context: ProviderRequestContext, responder: Arc, ) -> Result { - if !self.registration.experimental_dynamic_tools() { + if !self.registration.experimental_dynamic_tools() + || !self + .pool + .supports_launch_profile(self.registration.launch_profile()) + { return Err(ProviderError::Unsupported); } context.validate_request(&ProviderKind::LocalHarness, &request)?; @@ -622,17 +994,39 @@ impl AiProvider for AiCodexAppServerProvider { )?; let binding = context.run_binding().ok_or(ProviderError::Rejected)?; let retained = context.provider_session().cloned(); - let input = AiCodexAppServerTurnInput::try_from_dynamic_request(request)?; + let input = if retained.is_some() { + AiCodexAppServerTurnInput::try_from_retained_dynamic_request( + request, + self.registration.bootstrap_instructions(), + )? + } else { + AiCodexAppServerTurnInput::try_from_dynamic_request(request)? + }; if let Some(session) = retained { - self.pool - .start_retained_dynamic_turn( - binding, - self.registration.clone(), - session, - input, - responder, - ) - .await + match session.activation() { + crate::AiProviderSessionActivation::NewlyBoundEmpty => { + self.pool + .start_bound_dynamic_turn( + binding, + self.registration.clone(), + session, + input, + responder, + ) + .await + } + crate::AiProviderSessionActivation::ExistingRetained => { + self.pool + .start_retained_dynamic_turn( + binding, + self.registration.clone(), + session, + input, + responder, + ) + .await + } + } } else { self.pool .start_dynamic_turn(binding, self.registration.clone(), input, responder) @@ -671,12 +1065,22 @@ impl AiProvider for AiCodexAppServerProvider { return Err(ProviderError::Rejected); } let input = if request.tools.is_empty() { - AiCodexAppServerTurnInput::try_from_model_request(request.clone())? + AiCodexAppServerTurnInput::try_from_retained_model_request( + request.clone(), + self.registration.bootstrap_instructions(), + )? } else { - if !self.registration.experimental_dynamic_tools() { + if !self.registration.experimental_dynamic_tools() + || !self + .pool + .supports_launch_profile(self.registration.launch_profile()) + { return Err(ProviderError::Unsupported); } - AiCodexAppServerTurnInput::try_from_dynamic_request(request.clone())? + AiCodexAppServerTurnInput::try_from_retained_dynamic_request( + request.clone(), + self.registration.bootstrap_instructions(), + )? }; self.pool .create_empty_thread(*binding, self.registration.clone(), input.tools().to_vec()) @@ -712,14 +1116,16 @@ impl AiProvider for AiCodexAppServerProvider { pub trait AiCodexAppServerRunProcess: Send + Sync { /// Creates one durable empty provider thread and returns its opaque cursor. /// - /// No developer instruction, user input, or other business content may - /// enter the thread before the caller durably binds it. Reviewed dynamic - /// tool definitions may be installed because app-server cannot add them + /// Only the immutable registration-bound bootstrap and reviewed dynamic + /// tool definitions may enter the thread before the caller durably binds + /// it. No user input, request-local instruction, route context, secret, or + /// resolver output is permitted. App-server cannot add dynamic tools /// during `thread/resume`; implementations must transmit exactly the - /// supplied definitions or reject them. + /// supplied bootstrap and definitions or reject them. async fn create_empty_thread( &self, _model: &str, + _bootstrap: &AiCodexAppServerBootstrapInstructions, _dynamic_tools: Vec, ) -> Result { Err(ProviderError::Unsupported) @@ -756,6 +1162,45 @@ pub trait AiCodexAppServerRunProcess: Send + Sync { Err(ProviderError::Unsupported) } + /// Starts the first text-only turn directly on the exact empty thread + /// created and durably bound for this run. + /// + /// Implementations must not issue `thread/resume`. The supplied opened + /// session is crate-fenced to the same run and cursor, and the process + /// pool admits this operation only once on the exact process that created + /// the empty thread. + /// + /// # Errors + /// + /// Returns a non-sensitive error when the exact loaded thread, cursor, + /// frozen configuration, or turn cannot be honored. + async fn start_bound_turn( + &self, + _session: crate::AiOpenedProviderSession, + _input: AiCodexAppServerTurnInput, + ) -> Result { + Err(ProviderError::Unsupported) + } + + /// Starts the first experimental dynamic-tool turn directly on the exact + /// empty thread created and durably bound for this run. + /// + /// Implementations must not issue `thread/resume` and must preserve the + /// exact frozen tool definitions installed during empty-thread creation. + /// + /// # Errors + /// + /// Returns a non-sensitive error when the exact loaded thread, cursor, + /// frozen tool definitions, responder, or turn cannot be honored. + async fn start_bound_dynamic_turn( + &self, + _session: crate::AiOpenedProviderSession, + _input: AiCodexAppServerTurnInput, + _responder: Arc, + ) -> Result { + Err(ProviderError::Unsupported) + } + /// Resumes one exact opened provider-session cursor and starts a text-only /// turn on it. async fn start_retained_turn( @@ -857,9 +1302,12 @@ impl AiCodexAppServerLaunchedProcess { async fn create_empty_thread( &self, model: &str, + bootstrap: &AiCodexAppServerBootstrapInstructions, dynamic_tools: Vec, ) -> Result { - self.process.create_empty_thread(model, dynamic_tools).await + self.process + .create_empty_thread(model, bootstrap, dynamic_tools) + .await } async fn start_fresh_turn( @@ -877,6 +1325,25 @@ impl AiCodexAppServerLaunchedProcess { self.process.start_dynamic_turn(input, responder).await } + async fn start_bound_turn( + &self, + session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + ) -> Result { + self.process.start_bound_turn(session, input).await + } + + async fn start_bound_dynamic_turn( + &self, + session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + responder: Arc, + ) -> Result { + self.process + .start_bound_dynamic_turn(session, input, responder) + .await + } + async fn start_retained_turn( &self, session: crate::AiOpenedProviderSession, @@ -921,6 +1388,17 @@ impl Drop for AiCodexAppServerLaunchedProcess { /// Trusted deployment seam that launches one reviewed app-server process. #[async_trait] pub trait AiCodexAppServerRunProcessFactory: Send + Sync { + /// Whether this factory implements one exact closed launch profile. + /// + /// The default keeps existing text-only factories compatible and refuses + /// the experimental dynamic-tools-only profile. A factory may return true + /// for that profile only when it uses [`AiCodexAppServerLaunchProfile::codex_arguments`] + /// unchanged and enforces the documented isolated-home, environment, + /// working-directory, integrity, and process-sandbox requirements. + fn supports_launch_profile(&self, profile: AiCodexAppServerLaunchProfile) -> bool { + profile == AiCodexAppServerLaunchProfile::strict_text_only_v1() + } + /// Launches and initializes the exact reviewed registration. /// /// The factory must directly execute the verified image without a shell, @@ -974,6 +1452,33 @@ struct RunEntry { turn_count: AtomicU32, turn_active: AtomicBool, poisoned: AtomicBool, + empty_thread: Mutex, +} + +enum EmptyThreadActivation { + Vacant, + Creating, + Available { + cursor_fingerprint: String, + bootstrap_fingerprint: String, + dynamic_tools: Vec, + }, + Consumed, +} + +fn opened_session_matches( + binding: AiProviderRunBinding, + registration: &AiCodexAppServerRegistration, + session: &crate::AiOpenedProviderSession, +) -> bool { + session.claim().session_id() == binding.session_id() + && session.claim().run_id() == binding.run_id() + && session.claim().attempt_id() == binding.attempt_id() + && session.claim().run_lease_generation() == binding.lease_generation() + && session.claim().descriptor().provider_profile_id() == registration.provider_profile_id() + && session.claim().descriptor().provider_model() == registration.logical_model() + && session.claim().descriptor().registration_fingerprint() == registration.identity() + && session.claim().descriptor().protocol_version() == registration.protocol_version() } struct ActiveTurnGuard { @@ -1008,19 +1513,30 @@ impl AiCodexAppServerRunPool { } } + /// Whether the trusted process factory implements one exact closed launch + /// profile. + pub fn supports_launch_profile(&self, profile: AiCodexAppServerLaunchProfile) -> bool { + self.inner.factory.supports_launch_profile(profile) + } + async fn create_empty_thread( &self, binding: AiProviderRunBinding, registration: Arc, dynamic_tools: Vec, ) -> Result { - if !dynamic_tools.is_empty() && !registration.experimental_dynamic_tools() { + if !self.supports_launch_profile(registration.launch_profile()) + || (!dynamic_tools.is_empty() && !registration.experimental_dynamic_tools()) + { return Err(ProviderError::Unsupported); } for tool in &dynamic_tools { tool.validate()?; } let entry = self.entry(binding, registration.clone()).await?; + if entry.turn_count.load(Ordering::Acquire) != 0 { + return Err(ProviderError::Rejected); + } if entry .turn_active .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -1028,16 +1544,36 @@ impl AiCodexAppServerRunPool { { return Err(ProviderError::Rejected); } + { + let mut activation = entry.empty_thread.lock().await; + if !matches!(*activation, EmptyThreadActivation::Vacant) { + entry.turn_active.store(false, Ordering::Release); + return Err(ProviderError::Rejected); + } + *activation = EmptyThreadActivation::Creating; + } let outcome = tokio::time::timeout( self.inner.limits.startup_timeout, - entry - .process - .create_empty_thread(registration.logical_model(), dynamic_tools), + entry.process.create_empty_thread( + registration.logical_model(), + registration.bootstrap_instructions(), + dynamic_tools.clone(), + ), ) .await; entry.turn_active.store(false, Ordering::Release); match outcome { - Ok(Ok(cursor)) if cursor.kind() == "codex.app_server.thread.v2" => Ok(cursor), + Ok(Ok(cursor)) if cursor.kind() == "codex.app_server.thread.v2" => { + *entry.empty_thread.lock().await = EmptyThreadActivation::Available { + cursor_fingerprint: cursor.fingerprint(), + bootstrap_fingerprint: registration + .bootstrap_instructions() + .fingerprint() + .to_owned(), + dynamic_tools, + }; + Ok(cursor) + } Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) .await; @@ -1066,6 +1602,22 @@ impl AiCodexAppServerRunPool { { return Err(ProviderError::Rejected); } + let cursor_matches = { + let activation = entry.empty_thread.lock().await; + matches!( + &*activation, + EmptyThreadActivation::Available { + cursor_fingerprint, + .. + } if cursor_fingerprint == &cursor.fingerprint() + ) + }; + if !cursor_matches { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(ProviderError::Rejected); + } let result = tokio::time::timeout( self.inner.limits.shutdown_timeout, entry.process.delete_thread(cursor), @@ -1073,7 +1625,10 @@ impl AiCodexAppServerRunPool { .await; entry.turn_active.store(false, Ordering::Release); match result { - Ok(Ok(())) => Ok(()), + Ok(Ok(())) => { + *entry.empty_thread.lock().await = EmptyThreadActivation::Consumed; + Ok(()) + } Ok(Err(error)) => { self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) .await; @@ -1092,6 +1647,9 @@ impl AiCodexAppServerRunPool { registration: Arc, cursor: &crate::AiProviderSessionCursor, ) -> Result<(), ProviderError> { + if !self.supports_launch_profile(registration.launch_profile()) { + return Err(ProviderError::Unsupported); + } let permit = self .inner .admission @@ -1220,7 +1778,9 @@ impl AiCodexAppServerRunPool { })) } - pub(crate) async fn start_retained_turn( + /// Starts the first tool-free turn directly on the exact newly bound empty + /// thread without issuing `thread/resume`. + pub(crate) async fn start_bound_turn( &self, binding: AiProviderRunBinding, registration: Arc, @@ -1228,37 +1788,16 @@ impl AiCodexAppServerRunPool { input: AiCodexAppServerTurnInput, ) -> Result { input.validate()?; - if input.model() != registration.logical_model() - || session.claim().session_id() != binding.session_id() - || session.claim().run_id() != binding.run_id() - || session.claim().attempt_id() != binding.attempt_id() - || session.claim().run_lease_generation() != binding.lease_generation() - || session.claim().descriptor().provider_profile_id() - != registration.provider_profile_id() - || session.claim().descriptor().provider_model() != registration.logical_model() - || session.claim().descriptor().registration_fingerprint() != registration.identity() - || session.claim().descriptor().protocol_version() != registration.protocol_version() - { - return Err(ProviderError::Rejected); - } - let entry = self.entry(binding, registration).await?; - if entry - .turn_active - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { + if !input.tools().is_empty() { return Err(ProviderError::Rejected); } - let previous = entry.turn_count.fetch_add(1, Ordering::AcqRel); - if previous >= self.inner.limits.maximum_turns_per_run { - entry.turn_count.fetch_sub(1, Ordering::AcqRel); - entry.turn_active.store(false, Ordering::Release); - return Err(ProviderError::RateLimited); - } + let entry = self + .begin_bound_turn(binding, ®istration, &session, &input) + .await?; let turn_deadline = tokio::time::Instant::now() + self.inner.limits.turn_timeout; let stream = match tokio::time::timeout_at( turn_deadline, - entry.process.start_retained_turn(session, input), + entry.process.start_bound_turn(session, input), ) .await { @@ -1276,24 +1815,242 @@ impl AiCodexAppServerRunPool { return Err(ProviderError::Cancelled); } }; - let guard = ActiveTurnGuard { - entry, - completed: false, - }; - let pool = self.clone(); - Ok(Box::pin(async_stream::try_stream! { - let mut guard = guard; - let mut stream = stream; - let turn_timeout = tokio::time::sleep_until(turn_deadline); - tokio::pin!(turn_timeout); - loop { - let next = tokio::select! { - _ = &mut turn_timeout => { - pool.invalidate( - binding, - &guard.entry, - AiProviderRunCloseReason::ProtocolViolation, - ).await; + self.guard_turn_stream(binding, entry, turn_deadline, stream) + } + + /// Starts the first experimental dynamic-tool turn directly on the exact + /// newly bound empty thread without issuing `thread/resume`. + pub(crate) async fn start_bound_dynamic_turn( + &self, + binding: AiProviderRunBinding, + registration: Arc, + session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + responder: Arc, + ) -> Result { + input.validate()?; + if input.tools().is_empty() || !registration.experimental_dynamic_tools() { + return Err(ProviderError::Unsupported); + } + let entry = self + .begin_bound_turn(binding, ®istration, &session, &input) + .await?; + let turn_deadline = tokio::time::Instant::now() + self.inner.limits.turn_timeout; + let stream = match tokio::time::timeout_at( + turn_deadline, + entry + .process + .start_bound_dynamic_turn(session, input, responder), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(error); + } + Err(_) => { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(ProviderError::Cancelled); + } + }; + self.guard_turn_stream(binding, entry, turn_deadline, stream) + } + + async fn begin_bound_turn( + &self, + binding: AiProviderRunBinding, + registration: &AiCodexAppServerRegistration, + session: &crate::AiOpenedProviderSession, + input: &AiCodexAppServerTurnInput, + ) -> Result, ProviderError> { + let input_instruction_fingerprint = input.instruction_fingerprint()?; + if session.activation() != crate::AiProviderSessionActivation::NewlyBoundEmpty + || !opened_session_matches(binding, registration, session) + || input.model() != registration.logical_model() + { + return Err(ProviderError::Rejected); + } + let entry = self + .inner + .entries + .lock() + .await + .get(&binding) + .cloned() + .filter(|entry| { + entry.registration_identity == registration.identity() + && !entry.poisoned.load(Ordering::Acquire) + }) + .ok_or(ProviderError::Rejected)?; + if entry + .turn_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(ProviderError::Rejected); + } + let activation_matches = { + let mut activation = entry.empty_thread.lock().await; + match &*activation { + EmptyThreadActivation::Available { + cursor_fingerprint, + bootstrap_fingerprint, + dynamic_tools, + } if cursor_fingerprint == &session.cursor().fingerprint() + && bootstrap_fingerprint == &input_instruction_fingerprint + && dynamic_tools == input.tools() => + { + *activation = EmptyThreadActivation::Consumed; + true + } + EmptyThreadActivation::Vacant + | EmptyThreadActivation::Creating + | EmptyThreadActivation::Available { .. } + | EmptyThreadActivation::Consumed => false, + } + }; + if !activation_matches { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(ProviderError::Rejected); + } + let previous = entry.turn_count.fetch_add(1, Ordering::AcqRel); + if previous >= self.inner.limits.maximum_turns_per_run { + entry.turn_count.fetch_sub(1, Ordering::AcqRel); + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(ProviderError::RateLimited); + } + Ok(entry) + } + + fn guard_turn_stream( + &self, + binding: AiProviderRunBinding, + entry: Arc, + turn_deadline: tokio::time::Instant, + stream: ProviderEventStream, + ) -> Result { + let guard = ActiveTurnGuard { + entry, + completed: false, + }; + let pool = self.clone(); + Ok(Box::pin(async_stream::try_stream! { + let mut guard = guard; + let mut stream = stream; + let turn_timeout = tokio::time::sleep_until(turn_deadline); + tokio::pin!(turn_timeout); + loop { + let next = tokio::select! { + _ = &mut turn_timeout => { + pool.invalidate( + binding, + &guard.entry, + AiProviderRunCloseReason::ProtocolViolation, + ).await; + guard.completed = true; + Err(ProviderError::Cancelled) + } + event = stream.next() => match event { + Some(Ok(event)) => Ok(Some(event)), + Some(Err(error)) => { + pool.invalidate( + binding, + &guard.entry, + AiProviderRunCloseReason::ProtocolViolation, + ).await; + guard.completed = true; + Err(error) + } + None => Ok(None), + } + }; + match next? { + Some(event) => yield event, + None => { + guard.completed = true; + break; + } + } + } + })) + } + + pub(crate) async fn start_retained_turn( + &self, + binding: AiProviderRunBinding, + registration: Arc, + session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + ) -> Result { + input.validate()?; + if input.model() != registration.logical_model() + || session.activation() != crate::AiProviderSessionActivation::ExistingRetained + || !opened_session_matches(binding, ®istration, &session) + { + return Err(ProviderError::Rejected); + } + let entry = self.entry(binding, registration).await?; + if entry + .turn_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(ProviderError::Rejected); + } + let previous = entry.turn_count.fetch_add(1, Ordering::AcqRel); + if previous >= self.inner.limits.maximum_turns_per_run { + entry.turn_count.fetch_sub(1, Ordering::AcqRel); + entry.turn_active.store(false, Ordering::Release); + return Err(ProviderError::RateLimited); + } + let turn_deadline = tokio::time::Instant::now() + self.inner.limits.turn_timeout; + let stream = match tokio::time::timeout_at( + turn_deadline, + entry.process.start_retained_turn(session, input), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(error); + } + Err(_) => { + entry.turn_active.store(false, Ordering::Release); + self.invalidate(binding, &entry, AiProviderRunCloseReason::ProtocolViolation) + .await; + return Err(ProviderError::Cancelled); + } + }; + let guard = ActiveTurnGuard { + entry, + completed: false, + }; + let pool = self.clone(); + Ok(Box::pin(async_stream::try_stream! { + let mut guard = guard; + let mut stream = stream; + let turn_timeout = tokio::time::sleep_until(turn_deadline); + tokio::pin!(turn_timeout); + loop { + let next = tokio::select! { + _ = &mut turn_timeout => { + pool.invalidate( + binding, + &guard.entry, + AiProviderRunCloseReason::ProtocolViolation, + ).await; guard.completed = true; Err(ProviderError::Cancelled) } @@ -1435,15 +2192,8 @@ impl AiCodexAppServerRunPool { if input.model() != registration.logical_model() || input.tools().is_empty() || !registration.experimental_dynamic_tools() - || session.claim().session_id() != binding.session_id() - || session.claim().run_id() != binding.run_id() - || session.claim().attempt_id() != binding.attempt_id() - || session.claim().run_lease_generation() != binding.lease_generation() - || session.claim().descriptor().provider_profile_id() - != registration.provider_profile_id() - || session.claim().descriptor().provider_model() != registration.logical_model() - || session.claim().descriptor().registration_fingerprint() != registration.identity() - || session.claim().descriptor().protocol_version() != registration.protocol_version() + || session.activation() != crate::AiProviderSessionActivation::ExistingRetained + || !opened_session_matches(binding, ®istration, &session) { return Err(ProviderError::Rejected); } @@ -1535,6 +2285,9 @@ impl AiCodexAppServerRunPool { binding: AiProviderRunBinding, registration: Arc, ) -> Result, ProviderError> { + if !self.supports_launch_profile(registration.launch_profile()) { + return Err(ProviderError::Unsupported); + } let mut identities = self.inner.registration_identities.lock().await; let identity_is_new = if let Some(identity) = identities.get(&binding) { if identity != registration.identity() { @@ -1583,6 +2336,7 @@ impl AiCodexAppServerRunPool { turn_count: AtomicU32::new(0), turn_active: AtomicBool::new(false), poisoned: AtomicBool::new(false), + empty_thread: Mutex::new(EmptyThreadActivation::Vacant), }); if identity_is_new { identities.insert(binding, registration.identity().to_owned()); @@ -1726,6 +2480,34 @@ pub enum AiCodexAppServerInbound { /// protocol compatibility evidence only and grants no remote-control /// method or capability. RemoteControlDisabled, + /// Content-free notice that app-server emitted one bounded non-fatal + /// warning during the current correlated turn. + /// + /// The timestamp, optional thread reference, and warning text are + /// validated and discarded inside the actor. No warning content or + /// identifier crosses this boundary into events, logs, or model context. + RuntimeWarning, + /// Content-free lifecycle for one exact provider reasoning item. + /// + /// The actor accepts only an empty `content` and `summary` shape while + /// reasoning summaries are disabled on the turn. The item identifier, + /// payload, and timestamp are correlated and discarded inside the actor; + /// hidden reasoning never crosses this boundary. + ReasoningLifecycle { + /// Whether this is the item start or terminal completion. + completed: bool, + }, + /// Content-free retained-thread usage snapshot observed during an exact + /// resume lifecycle. + /// + /// App-server may replay one cumulative snapshot while loading a thread. + /// Its complete nonnegative generated shape and exact thread binding are + /// validated, then all turn identifiers and token values are discarded so + /// they cannot be charged again to the next run. On Codex versions that do + /// not emit `thread/started` after `thread/resume`, this signal may complete + /// only a typed resume after its exact correlated response is observed; it + /// can never complete a new thread-start lifecycle. + RetainedResumeUsageSnapshot, /// Exact experimental dynamic-tool server request matched to one offered /// definition. No other server request is admitted. DynamicToolCall { @@ -1768,6 +2550,14 @@ impl std::fmt::Debug for AiCodexAppServerInbound { Self::RemoteControlDisabled => { formatter.write_str("AiCodexAppServerInbound::RemoteControlDisabled") } + Self::RuntimeWarning => formatter.write_str("AiCodexAppServerInbound::RuntimeWarning"), + Self::ReasoningLifecycle { completed } => formatter + .debug_struct("AiCodexAppServerInbound::ReasoningLifecycle") + .field("completed", completed) + .finish(), + Self::RetainedResumeUsageSnapshot => { + formatter.write_str("AiCodexAppServerInbound::RetainedResumeUsageSnapshot") + } Self::DynamicToolCall { request_id, thread_id, @@ -1796,16 +2586,38 @@ impl std::fmt::Debug for AiCodexAppServerInbound { } } +/// Internal response/notification observation phase for one thread lifecycle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ThreadLifecyclePhase { + Ready, + AwaitingResponseAndStarted, + AwaitingResponse, + AwaitingStarted, + Complete, + Deleted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ThreadLifecycleOperation { + Start, + Resume, +} + /// Closed app-server JSON-RPC encoder/guard. /// /// There is intentionally no generic request builder. The provider-specific /// process actor may emit only the explicitly typed initialization, thread, /// turn, interruption, deletion, and dynamic-tool response methods represented /// here. Admitted server notifications require the complete positive signed -/// `emittedAtMs` envelope and exact lifecycle correlation. The only admitted -/// thread-status transition is `notLoaded` for the exact thread already being -/// deleted. All other server-initiated requests and non-allowlisted -/// notifications fail closed. +/// `emittedAtMs` envelope and exact lifecycle correlation. Initialization +/// negotiates one fixed opt-out profile for unused thread, MCP, and account +/// notifications; those methods remain rejected if the server emits them. +/// Deletion uses only its exact correlated response. A documented generic +/// `warning` is admitted only as a content-free, turn-correlated, +/// flood-bounded control event. Empty reasoning-item lifecycles are admitted +/// only as content-free signals while turn-level reasoning summaries remain +/// explicitly disabled. All other server-initiated requests and +/// non-allowlisted notifications fail closed. #[derive(Debug)] pub struct AiCodexAppServerProtocolActor { next_id: u64, @@ -1814,21 +2626,24 @@ pub struct AiCodexAppServerProtocolActor { pending_turn_thread_id: Option, active_turn_id: Option, retained_model: Option, + retained_bootstrap_fingerprint: Option, dynamic_tools: BTreeMap, + dynamic_tool_projection_fingerprints: BTreeMap, pending_dynamic_requests: BTreeMap, started_dynamic_calls: BTreeMap, responded_dynamic_calls: BTreeMap, started_items: BTreeMap, completed_items: BTreeSet, initialization_complete: bool, - thread_response_observed: bool, - thread_started_observed: bool, - thread_deleted: bool, + thread_lifecycle_phase: ThreadLifecyclePhase, + thread_lifecycle_operation: Option, deleting_thread_id: Option, - thread_not_loaded_observed: bool, + retained_usage_snapshot_observed: bool, turn_response_observed: bool, turn_started_observed: bool, remote_control_disabled_observed: bool, + runtime_warning_count: usize, + runtime_warning_bytes: usize, maximum_frame_bytes: usize, } @@ -1853,26 +2668,35 @@ impl AiCodexAppServerProtocolActor { pending_turn_thread_id: None, active_turn_id: None, retained_model: None, + retained_bootstrap_fingerprint: None, dynamic_tools: BTreeMap::new(), + dynamic_tool_projection_fingerprints: BTreeMap::new(), pending_dynamic_requests: BTreeMap::new(), started_dynamic_calls: BTreeMap::new(), responded_dynamic_calls: BTreeMap::new(), started_items: BTreeMap::new(), completed_items: BTreeSet::new(), initialization_complete: false, - thread_response_observed: false, - thread_started_observed: false, - thread_deleted: false, + thread_lifecycle_phase: ThreadLifecyclePhase::Ready, + thread_lifecycle_operation: None, deleting_thread_id: None, - thread_not_loaded_observed: false, + retained_usage_snapshot_observed: false, turn_response_observed: false, turn_started_observed: false, remote_control_disabled_observed: false, + runtime_warning_count: 0, + runtime_warning_bytes: 0, maximum_frame_bytes, }) } - /// Encodes the one allowed protocol-initialization request. + /// Encodes the one allowed stable protocol-initialization request. + /// + /// The actor always suppresses the exact thread-status, thread-settings, + /// MCP-startup, and account-rate-limit notifications that this closed + /// adapter neither consumes nor admits. The host cannot alter that profile + /// or suppress authoritative thread, turn, item, usage, dynamic-tool, or + /// completion traffic. /// /// # Errors /// @@ -1901,13 +2725,14 @@ impl AiCodexAppServerProtocolActor { "name": client_name, "title": client_title, "version": client_version, - } + }, + "capabilities": initialization_capabilities(false), }), ) } - /// Encodes initialization with only the experimental API capability - /// required by app-server dynamic tools. + /// Encodes initialization with the experimental API capability required + /// by app-server dynamic tools and the same closed notification profile. /// /// # Errors /// @@ -1937,7 +2762,7 @@ impl AiCodexAppServerProtocolActor { "title": client_title, "version": client_version, }, - "capabilities": {"experimentalApi": true}, + "capabilities": initialization_capabilities(true), }), ) } @@ -1952,6 +2777,44 @@ impl AiCodexAppServerProtocolActor { self.encode(json!({"method": "initialized", "params": {}})) } + fn validate_thread_lifecycle_boundary(&self) -> Result<(), ProviderError> { + if !self.initialization_complete + || !self.pending.is_empty() + || !matches!( + self.thread_lifecycle_phase, + ThreadLifecyclePhase::Ready | ThreadLifecyclePhase::Complete + ) + || self.thread_lifecycle_operation.is_some() + || self.pending_turn_thread_id.is_some() + || self.active_turn_id.is_some() + || self.deleting_thread_id.is_some() + || self.turn_response_observed + || self.turn_started_observed + || !self.pending_dynamic_requests.is_empty() + || !self.started_dynamic_calls.is_empty() + || !self.responded_dynamic_calls.is_empty() + || !self.started_items.is_empty() + || !self.completed_items.is_empty() + { + return Err(ProviderError::Rejected); + } + Ok(()) + } + + fn begin_new_thread_lifecycle(&mut self) { + self.active_thread_id = None; + self.thread_lifecycle_phase = ThreadLifecyclePhase::AwaitingResponseAndStarted; + self.thread_lifecycle_operation = Some(ThreadLifecycleOperation::Start); + self.retained_usage_snapshot_observed = false; + } + + fn begin_resume_lifecycle(&mut self, thread_id: &str) { + self.active_thread_id = Some(thread_id.to_owned()); + self.thread_lifecycle_phase = ThreadLifecyclePhase::AwaitingResponseAndStarted; + self.thread_lifecycle_operation = Some(ThreadLifecycleOperation::Resume); + self.retained_usage_snapshot_observed = false; + } + /// Encodes an ephemeral thread start with trusted instructions kept in the /// protocol's developer-instruction field. /// @@ -1964,7 +2827,9 @@ impl AiCodexAppServerProtocolActor { input: &AiCodexAppServerTurnInput, ) -> Result, ProviderError> { input.validate()?; - if !self.dynamic_tools.is_empty() + self.validate_thread_lifecycle_boundary()?; + if self.retained_model.is_some() + || !self.dynamic_tools.is_empty() || !self.pending_dynamic_requests.is_empty() || !self.started_dynamic_calls.is_empty() || !self.responded_dynamic_calls.is_empty() @@ -1976,7 +2841,7 @@ impl AiCodexAppServerProtocolActor { } else { Value::String(input.instructions().join("\n\n")) }; - self.request( + let frame = self.request( ClientMethod::ThreadStart, "thread/start", json!({ @@ -1986,22 +2851,30 @@ impl AiCodexAppServerProtocolActor { "approvalPolicy": "never", "sandbox": "read-only", }), - ) + )?; + self.begin_new_thread_lifecycle(); + Ok(frame) } /// Creates a durable empty thread before any business content is sent. /// /// The caller must durably protect and bind the returned thread cursor - /// before calling [`Self::start_turn`]. Reviewed dynamic-tool definitions - /// may be installed because app-server cannot add them at resume time, but - /// no developer or user instructions are included in this request. + /// before calling [`Self::start_turn`]. Only the supplied immutable static + /// bootstrap may enter `developerInstructions`; no request-local or user + /// content is included. Reviewed dynamic-tool definitions may be installed + /// because app-server cannot add them at resume time. pub fn start_persistent_empty_thread( &mut self, model: &str, + bootstrap: &AiCodexAppServerBootstrapInstructions, dynamic_tools: &[ModelToolDefinition], ) -> Result, ProviderError> { + self.validate_thread_lifecycle_boundary()?; if !valid_identifier(model) + || self.thread_lifecycle_phase != ThreadLifecyclePhase::Ready + || self.active_thread_id.is_some() || self.retained_model.is_some() + || self.retained_bootstrap_fingerprint.is_some() || !self.dynamic_tools.is_empty() || !self.pending_dynamic_requests.is_empty() || !self.started_dynamic_calls.is_empty() @@ -2009,42 +2882,29 @@ impl AiCodexAppServerProtocolActor { { return Err(ProviderError::Rejected); } - let mut definitions = BTreeMap::new(); - let dynamic_tools = dynamic_tools - .iter() - .map(|tool| { - tool.validate()?; - if definitions - .insert(tool.provider_name.clone(), tool.clone()) - .is_some() - { - return Err(ProviderError::Rejected); - } - Ok(json!({ - "type": "function", - "name": tool.provider_name, - "description": tool.description, - "inputSchema": tool.parameters, - "deferLoading": false, - })) - }) - .collect::, _>>()?; + let projected_tools = project_codex_dynamic_tools(dynamic_tools)?; let mut params = json!({ "model": model, - "developerInstructions": null, + "developerInstructions": bootstrap.joined().map_or(Value::Null, Value::String), "ephemeral": false, "approvalPolicy": "never", "sandbox": "read-only", }); - if !dynamic_tools.is_empty() { - params - .as_object_mut() - .ok_or(ProviderError::Rejected)? - .insert("dynamicTools".to_owned(), Value::Array(dynamic_tools)); + if !projected_tools.protocol_values.is_empty() { + let params = params.as_object_mut().ok_or(ProviderError::Rejected)?; + params.insert( + "dynamicTools".to_owned(), + Value::Array(projected_tools.protocol_values), + ); + params.insert("config".to_owned(), dynamic_tools_only_thread_config()); + params.insert("environments".to_owned(), Value::Array(Vec::new())); } let frame = self.request(ClientMethod::ThreadStart, "thread/start", params)?; + self.begin_new_thread_lifecycle(); self.retained_model = Some(model.to_owned()); - self.dynamic_tools = definitions; + self.retained_bootstrap_fingerprint = Some(bootstrap.fingerprint().to_owned()); + self.dynamic_tools = projected_tools.definitions; + self.dynamic_tool_projection_fingerprints = projected_tools.fingerprints; Ok(frame) } @@ -2062,49 +2922,64 @@ impl AiCodexAppServerProtocolActor { input: &AiCodexAppServerTurnInput, ) -> Result, ProviderError> { input.validate()?; + self.validate_thread_lifecycle_boundary()?; + let input_instruction_fingerprint = input.instruction_fingerprint()?; if cursor.kind() != "codex.app_server.thread.v2" || !valid_reference(cursor.expose_to_provider_adapter()) + || match self.thread_lifecycle_phase { + ThreadLifecyclePhase::Ready => self.active_thread_id.is_some(), + ThreadLifecyclePhase::Complete => { + self.retained_model.is_none() + || self.active_thread_id.as_deref() + != Some(cursor.expose_to_provider_adapter()) + } + _ => true, + } || self .retained_model .as_deref() .is_some_and(|model| model != input.model()) + || self + .retained_bootstrap_fingerprint + .as_deref() + .is_some_and(|fingerprint| fingerprint != input_instruction_fingerprint.as_str()) || !self.pending_dynamic_requests.is_empty() || !self.started_dynamic_calls.is_empty() || !self.responded_dynamic_calls.is_empty() { return Err(ProviderError::Rejected); } - let mut definitions = BTreeMap::new(); - for tool in input.tools() { - if definitions - .insert(tool.provider_name.clone(), tool.clone()) - .is_some() - { - return Err(ProviderError::Rejected); - } - } + let projected_tools = project_codex_dynamic_tools(input.tools())?; let developer_instructions = if input.instructions().is_empty() { Value::Null } else { Value::String(input.instructions().join("\n\n")) }; - if !self.dynamic_tools.is_empty() && self.dynamic_tools != definitions { + if self.retained_model.is_some() + && (self.dynamic_tools != projected_tools.definitions + || self.dynamic_tool_projection_fingerprints != projected_tools.fingerprints) + { return Err(ProviderError::Rejected); } - let frame = self.request( - ClientMethod::ThreadResume, - "thread/resume", - json!({ - "threadId": cursor.expose_to_provider_adapter(), - "model": input.model(), - "developerInstructions": developer_instructions, - "approvalPolicy": "never", - "sandbox": "read-only", - }), - )?; - self.active_thread_id = Some(cursor.expose_to_provider_adapter().to_owned()); + let mut params = json!({ + "threadId": cursor.expose_to_provider_adapter(), + "model": input.model(), + "developerInstructions": developer_instructions, + "approvalPolicy": "never", + "sandbox": "read-only", + }); + if !input.tools().is_empty() { + params + .as_object_mut() + .ok_or(ProviderError::Rejected)? + .insert("config".to_owned(), dynamic_tools_only_thread_config()); + } + let frame = self.request(ClientMethod::ThreadResume, "thread/resume", params)?; + self.begin_resume_lifecycle(cursor.expose_to_provider_adapter()); self.retained_model = Some(input.model().to_owned()); - self.dynamic_tools = definitions; + self.retained_bootstrap_fingerprint = Some(input_instruction_fingerprint); + self.dynamic_tools = projected_tools.definitions; + self.dynamic_tool_projection_fingerprints = projected_tools.fingerprints; Ok(frame) } @@ -2115,6 +2990,7 @@ impl AiCodexAppServerProtocolActor { ) -> Result, ProviderError> { if cursor.kind() != "codex.app_server.thread.v2" || !valid_reference(cursor.expose_to_provider_adapter()) + || self.thread_lifecycle_phase != ThreadLifecyclePhase::Complete || self.pending_turn_thread_id.is_some() || self.active_turn_id.is_some() || self.deleting_thread_id.is_some() @@ -2130,8 +3006,8 @@ impl AiCodexAppServerProtocolActor { "thread/delete", json!({"threadId": cursor.expose_to_provider_adapter()}), )?; + self.thread_lifecycle_operation = None; self.deleting_thread_id = Some(cursor.expose_to_provider_adapter().to_owned()); - self.thread_not_loaded_observed = false; Ok(frame) } @@ -2147,7 +3023,9 @@ impl AiCodexAppServerProtocolActor { input: &AiCodexAppServerTurnInput, ) -> Result, ProviderError> { input.validate()?; - if input.tools().is_empty() + self.validate_thread_lifecycle_boundary()?; + if self.retained_model.is_some() + || input.tools().is_empty() || !self.dynamic_tools.is_empty() || !self.pending_dynamic_requests.is_empty() || !self.started_dynamic_calls.is_empty() @@ -2155,26 +3033,7 @@ impl AiCodexAppServerProtocolActor { { return Err(ProviderError::Rejected); } - let mut definitions = BTreeMap::new(); - let dynamic_tools = input - .tools() - .iter() - .map(|tool| { - if definitions - .insert(tool.provider_name.clone(), tool.clone()) - .is_some() - { - return Err(ProviderError::Rejected); - } - Ok(json!({ - "type": "function", - "name": tool.provider_name, - "description": tool.description, - "inputSchema": tool.parameters, - "deferLoading": false, - })) - }) - .collect::, _>>()?; + let projected_tools = project_codex_dynamic_tools(input.tools())?; let developer_instructions = if input.instructions().is_empty() { Value::Null } else { @@ -2187,12 +3046,16 @@ impl AiCodexAppServerProtocolActor { "model": input.model(), "developerInstructions": developer_instructions, "ephemeral": true, - "dynamicTools": dynamic_tools, + "dynamicTools": projected_tools.protocol_values, "approvalPolicy": "never", "sandbox": "read-only", + "config": dynamic_tools_only_thread_config(), + "environments": [], }), )?; - self.dynamic_tools = definitions; + self.begin_new_thread_lifecycle(); + self.dynamic_tools = projected_tools.definitions; + self.dynamic_tool_projection_fingerprints = projected_tools.fingerprints; Ok(frame) } @@ -2243,7 +3106,7 @@ impl AiCodexAppServerProtocolActor { Ok(frame) } - /// Encodes text-only user input for one exact fresh thread. + /// Encodes text-only user input for one exact lifecycle-complete thread. /// /// Trusted instructions are deliberately not copied into the user input /// list. No tool, path, URL, image, skill, audio, environment, approval, @@ -2259,11 +3122,26 @@ impl AiCodexAppServerProtocolActor { input: &AiCodexAppServerTurnInput, ) -> Result, ProviderError> { input.validate()?; + let input_instruction_fingerprint = input.instruction_fingerprint()?; + let consumes_retained_resume_fallback = self.thread_lifecycle_phase + == ThreadLifecyclePhase::Complete + && self.thread_lifecycle_operation == Some(ThreadLifecycleOperation::Resume) + && self.retained_usage_snapshot_observed; + let projected_tools = project_codex_dynamic_tools(input.tools())?; if !valid_reference(thread_id) || self.active_thread_id.as_deref() != Some(thread_id) - || !self.thread_response_observed - || !self.thread_started_observed - || self.thread_deleted + || self.thread_lifecycle_phase != ThreadLifecyclePhase::Complete + || (self.thread_lifecycle_operation.is_some() && !consumes_retained_resume_fallback) + || self + .retained_model + .as_deref() + .is_some_and(|model| model != input.model()) + || self + .retained_bootstrap_fingerprint + .as_deref() + .is_some_and(|fingerprint| fingerprint != input_instruction_fingerprint) + || self.dynamic_tools != projected_tools.definitions + || self.dynamic_tool_projection_fingerprints != projected_tools.fingerprints || self.pending_turn_thread_id.is_some() || self.active_turn_id.is_some() || self.turn_response_observed @@ -2273,15 +3151,22 @@ impl AiCodexAppServerProtocolActor { { return Err(ProviderError::InvalidRequest); } - let frame = self.request( - ClientMethod::TurnStart, - "turn/start", - json!({ - "threadId": thread_id, - "input": input.input().iter().map(|text| json!({"type": "text", "text": text})).collect::>(), - }), - )?; + let mut params = json!({ + "threadId": thread_id, + "input": input.input().iter().map(|text| json!({"type": "text", "text": text})).collect::>(), + "summary": "none", + }); + if !input.tools().is_empty() { + params + .as_object_mut() + .ok_or(ProviderError::Rejected)? + .insert("environments".to_owned(), Value::Array(Vec::new())); + } + let frame = self.request(ClientMethod::TurnStart, "turn/start", params)?; + self.thread_lifecycle_operation = None; self.pending_turn_thread_id = Some(thread_id.to_owned()); + self.runtime_warning_count = 0; + self.runtime_warning_bytes = 0; Ok(frame) } @@ -2338,8 +3223,8 @@ impl AiCodexAppServerProtocolActor { /// Admits one exact bounded inbound response or allowlisted notification. /// /// Every server-initiated request, uncorrelated response, provider error, - /// forbidden item kind, raw reasoning item, and unknown notification is - /// rejected. + /// forbidden item kind, non-empty reasoning content, and unknown + /// notification is rejected. /// /// # Errors /// @@ -2367,8 +3252,7 @@ impl AiCodexAppServerProtocolActor { .get("params") .and_then(Value::as_object) .ok_or(ProviderError::Rejected)?; - if request_id == 0 - || self.pending_dynamic_requests.contains_key(&request_id) + if self.pending_dynamic_requests.contains_key(&request_id) || params.keys().any(|key| { !matches!( key.as_str(), @@ -2458,10 +3342,16 @@ impl AiCodexAppServerProtocolActor { if notification.method == REMOTE_CONTROL_STATUS_CHANGED { return self.accept_disabled_remote_control_status(notification); } + if notification.method == RUNTIME_WARNING { + return self.accept_runtime_warning(notification); + } let method = Some(notification.method.as_str()) .filter(|method| allowed_notification(method)) .ok_or(ProviderError::Rejected)?; let params = notification.params; + if method == THREAD_TOKEN_USAGE_UPDATED && self.active_turn_id.is_none() { + return self.accept_retained_usage_snapshot(¶ms); + } if matches!(method, "item/started" | "item/completed") && params .get("item") @@ -2472,6 +3362,16 @@ impl AiCodexAppServerProtocolActor { { return self.accept_dynamic_tool_lifecycle(method, ¶ms); } + if matches!(method, "item/started" | "item/completed") + && params + .get("item") + .and_then(Value::as_object) + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + == Some("reasoning") + { + return self.accept_empty_reasoning_lifecycle(method, ¶ms); + } validate_allowed_notification(method, ¶ms)?; self.accept_notification_binding(method, ¶ms)?; if method == "turn/completed" { @@ -2481,11 +3381,16 @@ impl AiCodexAppServerProtocolActor { { return Err(ProviderError::Rejected); } - self.dynamic_tools.clear(); + if self.retained_model.is_none() { + self.dynamic_tools.clear(); + self.dynamic_tool_projection_fingerprints.clear(); + } self.pending_turn_thread_id = None; self.active_turn_id = None; self.turn_response_observed = false; self.turn_started_observed = false; + self.runtime_warning_count = 0; + self.runtime_warning_bytes = 0; self.started_items.clear(); self.completed_items.clear(); } @@ -2599,6 +3504,102 @@ impl AiCodexAppServerProtocolActor { }) } + fn accept_empty_reasoning_lifecycle( + &mut self, + method: &str, + params: &Value, + ) -> Result { + self.validate_active_turn( + direct_reference(params, "threadId")?, + direct_reference(params, "turnId")?, + )?; + let params = params.as_object().ok_or(ProviderError::Rejected)?; + let timestamp_key = if method == "item/started" { + "startedAtMs" + } else { + "completedAtMs" + }; + if params.keys().any(|key| { + !matches!(key.as_str(), "item" | "threadId" | "turnId") && key != timestamp_key + }) || params + .get(timestamp_key) + .and_then(Value::as_i64) + .is_none_or(|timestamp| timestamp <= 0) + { + return Err(ProviderError::Rejected); + } + let item = params + .get("item") + .and_then(Value::as_object) + .ok_or(ProviderError::Rejected)?; + if item + .keys() + .any(|key| !matches!(key.as_str(), "content" | "id" | "summary" | "type")) + || item.get("type").and_then(Value::as_str) != Some("reasoning") + || item + .get("content") + .is_some_and(|content| content.as_array().is_none_or(|content| !content.is_empty())) + || item + .get("summary") + .is_some_and(|summary| summary.as_array().is_none_or(|summary| !summary.is_empty())) + { + return Err(ProviderError::Rejected); + } + let item_id = item + .get("id") + .and_then(Value::as_str) + .filter(|item_id| valid_reference(item_id)) + .ok_or(ProviderError::Rejected)?; + let completed = method == "item/completed"; + if completed { + if self.started_items.remove(item_id).as_deref() != Some("reasoning") + || !self.completed_items.insert(item_id.to_owned()) + || self.completed_items.len() > MAXIMUM_TEXT_BLOCKS + { + return Err(ProviderError::Rejected); + } + } else if self.completed_items.contains(item_id) + || self + .started_items + .insert(item_id.to_owned(), "reasoning".to_owned()) + .is_some() + || self.started_items.len() > MAXIMUM_TEXT_BLOCKS + { + return Err(ProviderError::Rejected); + } + Ok(AiCodexAppServerInbound::ReasoningLifecycle { completed }) + } + + fn accept_retained_usage_snapshot( + &mut self, + params: &Value, + ) -> Result { + let usage = validate_thread_token_usage(params)?; + if !self.initialization_complete + || self.retained_usage_snapshot_observed + || self.thread_lifecycle_operation != Some(ThreadLifecycleOperation::Resume) + || self.active_thread_id.as_deref() != Some(usage.thread_id.as_str()) + || self.pending_turn_thread_id.is_some() + || self.active_turn_id.is_some() + || !matches!( + self.thread_lifecycle_phase, + ThreadLifecyclePhase::AwaitingResponseAndStarted + | ThreadLifecyclePhase::AwaitingResponse + | ThreadLifecyclePhase::AwaitingStarted + | ThreadLifecyclePhase::Complete + ) + { + return Err(ProviderError::Rejected); + } + self.retained_usage_snapshot_observed = true; + if self.thread_lifecycle_phase == ThreadLifecyclePhase::AwaitingStarted + && self.thread_lifecycle_operation == Some(ThreadLifecycleOperation::Resume) + { + self.thread_lifecycle_phase = ThreadLifecyclePhase::Complete; + } + Ok(AiCodexAppServerInbound::RetainedResumeUsageSnapshot) + } + fn accept_correlated_response( &mut self, method: ClientMethod, @@ -2607,17 +3608,34 @@ impl AiCodexAppServerProtocolActor { match method { ClientMethod::ThreadStart | ClientMethod::ThreadResume => { let thread_id = nested_reference(result, "thread", "id")?; - if self.thread_response_observed - || self.thread_deleted - || self - .active_thread_id - .as_deref() - .is_some_and(|expected| expected != thread_id) + if !matches!( + self.thread_lifecycle_phase, + ThreadLifecyclePhase::AwaitingResponseAndStarted + | ThreadLifecyclePhase::AwaitingResponse + ) || self + .active_thread_id + .as_deref() + .is_some_and(|expected| expected != thread_id) { return Err(ProviderError::Rejected); } self.active_thread_id = Some(thread_id.to_owned()); - self.thread_response_observed = true; + self.thread_lifecycle_phase = match self.thread_lifecycle_phase { + ThreadLifecyclePhase::AwaitingResponseAndStarted => { + if self.thread_lifecycle_operation == Some(ThreadLifecycleOperation::Resume) + && self.retained_usage_snapshot_observed + { + ThreadLifecyclePhase::Complete + } else { + ThreadLifecyclePhase::AwaitingStarted + } + } + ThreadLifecyclePhase::AwaitingResponse => { + self.thread_lifecycle_operation = None; + ThreadLifecyclePhase::Complete + } + _ => return Err(ProviderError::Rejected), + }; } ClientMethod::TurnStart => { let turn_id = nested_reference(result, "turn", "id")?; @@ -2637,10 +3655,25 @@ impl AiCodexAppServerProtocolActor { self.initialization_complete = true; } ClientMethod::ThreadDelete => { - self.thread_deleted = true; + let deleting_thread_id = self + .deleting_thread_id + .as_deref() + .ok_or(ProviderError::Rejected)?; + if !result.as_object().is_some_and(serde_json::Map::is_empty) + || self.active_thread_id.as_deref() != Some(deleting_thread_id) + || self.pending_turn_thread_id.is_some() + || self.active_turn_id.is_some() + { + return Err(ProviderError::Rejected); + } self.active_thread_id = None; - self.thread_response_observed = false; - self.thread_started_observed = false; + self.retained_model = None; + self.retained_bootstrap_fingerprint = None; + self.dynamic_tools.clear(); + self.dynamic_tool_projection_fingerprints.clear(); + self.deleting_thread_id = None; + self.thread_lifecycle_operation = None; + self.thread_lifecycle_phase = ThreadLifecyclePhase::Deleted; } ClientMethod::TurnInterrupt => {} } @@ -2658,7 +3691,12 @@ impl AiCodexAppServerProtocolActor { || !valid_identifier(¶ms.installation_id) || !self.initialization_complete || self.remote_control_disabled_observed - || self.thread_response_observed + || matches!( + self.thread_lifecycle_phase, + ThreadLifecyclePhase::AwaitingStarted + | ThreadLifecyclePhase::Complete + | ThreadLifecyclePhase::Deleted + ) || self.pending_turn_thread_id.is_some() || self.active_turn_id.is_some() { @@ -2668,6 +3706,49 @@ impl AiCodexAppServerProtocolActor { Ok(AiCodexAppServerInbound::RemoteControlDisabled) } + fn accept_runtime_warning( + &mut self, + notification: CodexAppServerNotificationEnvelope, + ) -> Result { + let params: RuntimeWarningParams = + serde_json::from_value(notification.params).map_err(|_| ProviderError::Rejected)?; + let active_thread_id = self + .active_thread_id + .as_deref() + .ok_or(ProviderError::Rejected)?; + let message_bytes = params.message.len(); + let next_bytes = self + .runtime_warning_bytes + .checked_add(message_bytes) + .ok_or(ProviderError::Rejected)?; + if notification.method != RUNTIME_WARNING + || !self.initialization_complete + || self.thread_lifecycle_phase != ThreadLifecyclePhase::Complete + || self.pending_turn_thread_id.as_deref() != Some(active_thread_id) + || self.deleting_thread_id.is_some() + || (!self + .pending + .values() + .any(|method| *method == ClientMethod::TurnStart) + && !self.turn_response_observed + && !self.turn_started_observed) + || params + .thread_id + .as_deref() + .is_some_and(|thread_id| thread_id != active_thread_id) + || params.message.trim().is_empty() + || message_bytes > MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES + || params.message.chars().any(char::is_control) + || self.runtime_warning_count >= MAXIMUM_RUNTIME_WARNINGS_PER_TURN + || next_bytes > MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN + { + return Err(ProviderError::Rejected); + } + self.runtime_warning_count += 1; + self.runtime_warning_bytes = next_bytes; + Ok(AiCodexAppServerInbound::RuntimeWarning) + } + fn accept_notification_binding( &mut self, method: &str, @@ -2676,16 +3757,26 @@ impl AiCodexAppServerProtocolActor { match method { "thread/started" => { let thread_id = nested_reference(params, "thread", "id")?; + let optional_late_resume_started = self.thread_lifecycle_phase + == ThreadLifecyclePhase::Complete + && self.thread_lifecycle_operation == Some(ThreadLifecycleOperation::Resume) + && self.retained_usage_snapshot_observed; if !self.initialization_complete - || self.thread_started_observed - || self.thread_deleted + || (!optional_late_resume_started + && !matches!( + self.thread_lifecycle_phase, + ThreadLifecyclePhase::AwaitingResponseAndStarted + | ThreadLifecyclePhase::AwaitingStarted + )) || self.pending_turn_thread_id.is_some() || self.active_turn_id.is_some() || self .pending .values() .any(|method| *method == ClientMethod::ThreadDelete) - || (!self.thread_response_observed + || (!optional_late_resume_started + && self.thread_lifecycle_phase + == ThreadLifecyclePhase::AwaitingResponseAndStarted && !self.pending.values().any(|method| { matches!( method, @@ -2700,26 +3791,20 @@ impl AiCodexAppServerProtocolActor { return Err(ProviderError::Rejected); } self.active_thread_id = Some(thread_id.to_owned()); - self.thread_started_observed = true; - } - "thread/status/changed" => { - let status: ThreadNotLoadedStatusChangedParams = - serde_json::from_value(params.clone()).map_err(|_| ProviderError::Rejected)?; - if !self.initialization_complete - || !valid_reference(&status.thread_id) - || self.deleting_thread_id.as_deref() != Some(status.thread_id.as_str()) - || self.thread_not_loaded_observed - || (!self.thread_deleted - && !self - .pending - .values() - .any(|method| *method == ClientMethod::ThreadDelete)) - || self.pending_turn_thread_id.is_some() - || self.active_turn_id.is_some() - { - return Err(ProviderError::Rejected); + if optional_late_resume_started { + self.thread_lifecycle_operation = None; + return Ok(()); } - self.thread_not_loaded_observed = true; + self.thread_lifecycle_phase = match self.thread_lifecycle_phase { + ThreadLifecyclePhase::AwaitingResponseAndStarted => { + ThreadLifecyclePhase::AwaitingResponse + } + ThreadLifecyclePhase::AwaitingStarted => { + self.thread_lifecycle_operation = None; + ThreadLifecyclePhase::Complete + } + _ => return Err(ProviderError::Rejected), + }; } "turn/started" => { let thread_id = direct_reference(params, "threadId")?; @@ -2798,11 +3883,9 @@ impl AiCodexAppServerProtocolActor { return Err(ProviderError::Rejected); } } - "thread/tokenUsage/updated" => { - self.validate_active_turn( - direct_reference(params, "threadId")?, - direct_reference(params, "turnId")?, - )?; + THREAD_TOKEN_USAGE_UPDATED => { + let usage = validate_thread_token_usage(params)?; + self.validate_active_turn(&usage.thread_id, &usage.turn_id)?; } _ => return Err(ProviderError::Rejected), } @@ -2812,8 +3895,7 @@ impl AiCodexAppServerProtocolActor { fn validate_active_turn(&self, thread_id: &str, turn_id: &str) -> Result<(), ProviderError> { if self.active_thread_id.as_deref() != Some(thread_id) || self.active_turn_id.as_deref() != Some(turn_id) - || !self.thread_response_observed - || !self.thread_started_observed + || self.thread_lifecycle_phase != ThreadLifecyclePhase::Complete || !self.turn_started_observed { return Err(ProviderError::Rejected); @@ -2852,30 +3934,83 @@ struct DisabledRemoteControlStatusParams { } #[derive(Deserialize)] -enum DisabledRemoteControlStatus { - #[serde(rename = "disabled")] - Disabled, +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RuntimeWarningParams { + thread_id: Option, + message: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct ThreadNotLoadedStatusChangedParams { +struct ThreadTokenUsageUpdatedParams { thread_id: String, - #[serde(rename = "status")] - _status: ThreadNotLoadedStatus, + turn_id: String, + token_usage: ThreadTokenUsage, } #[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct ThreadNotLoadedStatus { - #[serde(rename = "type")] - _kind: ThreadNotLoadedStatusKind, +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ThreadTokenUsage { + last: TokenUsageBreakdown, + total: TokenUsageBreakdown, + model_context_window: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TokenUsageBreakdown { + input_tokens: i64, + cached_input_tokens: i64, + #[serde(default)] + cache_write_input_tokens: i64, + output_tokens: i64, + reasoning_output_tokens: i64, + total_tokens: i64, +} + +impl TokenUsageBreakdown { + fn validate(&self) -> Result<(), ProviderError> { + if [ + self.input_tokens, + self.cached_input_tokens, + self.cache_write_input_tokens, + self.output_tokens, + self.reasoning_output_tokens, + self.total_tokens, + ] + .into_iter() + .any(|value| value < 0) + { + return Err(ProviderError::Rejected); + } + Ok(()) + } +} + +fn validate_thread_token_usage( + params: &Value, +) -> Result { + let usage: ThreadTokenUsageUpdatedParams = + serde_json::from_value(params.clone()).map_err(|_| ProviderError::Rejected)?; + usage.token_usage.last.validate()?; + usage.token_usage.total.validate()?; + if !valid_reference(&usage.thread_id) + || !valid_reference(&usage.turn_id) + || usage + .token_usage + .model_context_window + .is_some_and(|value| value < 0) + || usage.token_usage.total.total_tokens < usage.token_usage.last.total_tokens + { + return Err(ProviderError::Rejected); + } + Ok(usage) } #[derive(Deserialize)] -enum ThreadNotLoadedStatusKind { - #[serde(rename = "notLoaded")] - NotLoaded, +enum DisabledRemoteControlStatus { + #[serde(rename = "disabled")] + Disabled, } fn direct_reference<'a>(value: &'a Value, key: &str) -> Result<&'a str, ProviderError> { @@ -2915,7 +4050,6 @@ fn allowed_notification(method: &str) -> bool { matches!( method, "thread/started" - | "thread/status/changed" | "turn/started" | "item/started" | "item/completed" @@ -2925,6 +4059,30 @@ fn allowed_notification(method: &str) -> bool { ) } +fn initialization_capabilities(experimental_api: bool) -> Value { + let mut capabilities = serde_json::Map::from_iter([( + "optOutNotificationMethods".to_owned(), + json!(OPTED_OUT_NOTIFICATION_METHODS), + )]); + if experimental_api { + capabilities.insert("experimentalApi".to_owned(), Value::Bool(true)); + } + Value::Object(capabilities) +} + +fn dynamic_tools_only_thread_config() -> Value { + let mut config = serde_json::Map::new(); + for feature in DYNAMIC_TOOLS_ONLY_DISABLED_FEATURES { + config.insert(format!("features.{feature}"), Value::Bool(false)); + } + config.insert("tools.update_plan.enabled".to_owned(), Value::Bool(false)); + config.insert( + "web_search".to_owned(), + Value::String("disabled".to_owned()), + ); + Value::Object(config) +} + fn validate_allowed_notification(method: &str, params: &Value) -> Result<(), ProviderError> { let object = params.as_object().ok_or(ProviderError::Rejected)?; if matches!(method, "item/started" | "item/completed") { @@ -2949,92 +4107,378 @@ fn validate_allowed_notification(method: &str, params: &Value) -> Result<(), Pro Ok(()) } -fn valid_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAXIMUM_IDENTIFIER_BYTES - && value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/') - }) -} - -fn valid_version(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAXIMUM_VERSION_BYTES - && value - .bytes() - .all(|byte| byte.is_ascii_graphic() && byte != b'"' && byte != b'\\') +struct ProjectedCodexDynamicTools { + protocol_values: Vec, + definitions: BTreeMap, + fingerprints: BTreeMap, } -fn valid_reference(value: &str) -> bool { - !value.trim().is_empty() - && value.len() <= 1_024 - && !value.bytes().any(|byte| byte.is_ascii_control()) +fn project_codex_dynamic_tools( + tools: &[ModelToolDefinition], +) -> Result { + let mut protocol_values = Vec::with_capacity(tools.len()); + let mut definitions = BTreeMap::new(); + let mut fingerprints = BTreeMap::new(); + for tool in tools { + tool.validate()?; + if !tool.strict + || definitions + .insert(tool.provider_name.clone(), tool.clone()) + .is_some() + { + return Err(ProviderError::Rejected); + } + let projected_schema = project_codex_argument_schema(&tool.parameters)?; + let fingerprint = codex_schema_projection_fingerprint(tool, &projected_schema)?; + fingerprints.insert(tool.provider_name.clone(), fingerprint); + protocol_values.push(json!({ + "type": "function", + "name": tool.provider_name, + "description": tool.description, + "inputSchema": projected_schema, + "deferLoading": false, + })); + } + Ok(ProjectedCodexDynamicTools { + protocol_values, + definitions, + fingerprints, + }) } -fn registration_identity( - provider_profile_id: &str, - logical_model: &str, - executable_sha256: &str, - executable_version: &str, - sandbox_profile: &str, - protocol_version: &str, - experimental_dynamic_tools: bool, -) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"graphql-orm-ai/codex-app-server-registration/v1\0"); - for value in [ - provider_profile_id, - logical_model, - executable_sha256, - executable_version, - sandbox_profile, - protocol_version, - ] { - hasher.update((value.len() as u64).to_be_bytes()); - hasher.update(value.as_bytes()); +fn project_codex_argument_schema(schema: &Value) -> Result { + let object = schema.as_object().ok_or(ProviderError::Rejected)?; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "$schema" | "type" | "properties" | "required" | "additionalProperties" + ) + }) || object + .get("$schema") + .is_some_and(|value| value.as_str() != Some("https://json-schema.org/draft/2020-12/schema")) + || object.get("type").and_then(Value::as_str) != Some("object") + || object.get("additionalProperties").and_then(Value::as_bool) != Some(false) + { + return Err(ProviderError::Rejected); } - hasher.update([u8::from(experimental_dynamic_tools)]); - hex::encode(hasher.finalize()) + let properties = object + .get("properties") + .and_then(Value::as_object) + .ok_or(ProviderError::Rejected)?; + if properties.len() > 128 { + return Err(ProviderError::Rejected); + } + let mut projected_properties = serde_json::Map::new(); + for (name, property) in properties { + if !valid_identifier(name) { + return Err(ProviderError::Rejected); + } + projected_properties.insert(name.clone(), project_codex_scalar_schema(property)?); + } + let required = object + .get("required") + .and_then(Value::as_array) + .ok_or(ProviderError::Rejected)?; + let mut required_names = BTreeSet::new(); + for value in required { + let name = value.as_str().ok_or(ProviderError::Rejected)?; + if !properties.contains_key(name) || !required_names.insert(name.to_owned()) { + return Err(ProviderError::Rejected); + } + } + Ok(json!({ + "type": "object", + "properties": projected_properties, + "required": required, + "additionalProperties": false, + })) } -#[cfg(test)] -mod tests { - use std::fs; - use std::io::{BufRead, BufReader, Write}; - use std::path::PathBuf; - use std::process::{Child, ChildStdin, Command, Stdio}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::mpsc::{self, Receiver}; - use std::thread::{self, JoinHandle}; - - use agql_auth::{ - AccessTokenMetadata, AuthPrincipal, AuthUser, PrincipalReference, SessionContext, - }; - use futures::stream; - - use super::*; - use crate::{AiRunId, AiSessionId, ProviderDynamicToolResult, ProviderEvent}; - use uuid::Uuid; - - struct LiveCodexProcess { - child: Child, - stdin: ChildStdin, - frames: Receiver>, - reader: Option>, - root: PathBuf, +fn project_codex_scalar_schema(schema: &Value) -> Result { + let object = schema.as_object().ok_or(ProviderError::Rejected)?; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "type" | "description" | "enum" | "minLength" | "maxLength" | "minimum" | "maximum" + ) + }) { + return Err(ProviderError::Rejected); } - - impl LiveCodexProcess { - fn launch(executable: &str) -> Self { - let root = - std::env::temp_dir().join(format!("graphql-orm-ai-codex-0147-{}", Uuid::new_v4())); - fs::create_dir(&root).expect("isolated Codex home should be created"); - let mut child = Command::new(executable) - .args(["app-server", "--stdio"]) - .env_clear() - .env("CODEX_HOME", &root) - .env("HOME", &root) - .env("PATH", "/usr/bin:/bin") + let schema_type = object + .get("type") + .and_then(Value::as_str) + .ok_or(ProviderError::Rejected)?; + if !matches!(schema_type, "string" | "integer" | "number" | "boolean") { + return Err(ProviderError::Rejected); + } + let description = object + .get("description") + .map(|value| value.as_str().ok_or(ProviderError::Rejected)) + .transpose()? + .unwrap_or_default(); + if description.len() > 2_000 || description.chars().any(char::is_control) { + return Err(ProviderError::Rejected); + } + let mut constraint_notes = Vec::new(); + match schema_type { + "string" => { + let minimum = optional_u64(object, "minLength")?; + let maximum = optional_u64(object, "maxLength")?; + if minimum + .zip(maximum) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + return Err(ProviderError::Rejected); + } + if let Some(minimum) = minimum { + constraint_notes.push(format!("minimum length {minimum}")); + } + if let Some(maximum) = maximum { + constraint_notes.push(format!("maximum length {maximum}")); + } + } + "integer" | "number" => { + let minimum = optional_number(object, "minimum")?; + let maximum = optional_number(object, "maximum")?; + if minimum + .zip(maximum) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + return Err(ProviderError::Rejected); + } + if let Some(minimum) = minimum { + constraint_notes.push(format!("minimum {minimum}")); + } + if let Some(maximum) = maximum { + constraint_notes.push(format!("maximum {maximum}")); + } + } + "boolean" => { + if object.contains_key("minLength") + || object.contains_key("maxLength") + || object.contains_key("minimum") + || object.contains_key("maximum") + { + return Err(ProviderError::Rejected); + } + } + _ => unreachable!("schema type was closed above"), + } + let mut projected = serde_json::Map::new(); + projected.insert("type".to_owned(), Value::String(schema_type.to_owned())); + if let Some(values) = object.get("enum") { + let values = values.as_array().ok_or(ProviderError::Rejected)?; + if schema_type != "string" + || values.is_empty() + || values.len() > 100 + || values.iter().any(|value| { + value + .as_str() + .is_none_or(|value| value.is_empty() || value.len() > 200) + }) + { + return Err(ProviderError::Rejected); + } + projected.insert("enum".to_owned(), Value::Array(values.clone())); + } + if !description.is_empty() || !constraint_notes.is_empty() { + let mut projected_description = description.to_owned(); + if !constraint_notes.is_empty() { + if !projected_description.is_empty() { + projected_description.push(' '); + } + projected_description.push_str("Accepted value constraints: "); + projected_description.push_str(&constraint_notes.join(", ")); + projected_description.push('.'); + } + if projected_description.len() > 4_096 { + return Err(ProviderError::Rejected); + } + projected.insert( + "description".to_owned(), + Value::String(projected_description), + ); + } + Ok(Value::Object(projected)) +} + +fn optional_u64( + object: &serde_json::Map, + key: &str, +) -> Result, ProviderError> { + object + .get(key) + .map(|value| value.as_u64().ok_or(ProviderError::Rejected)) + .transpose() +} + +fn optional_number( + object: &serde_json::Map, + key: &str, +) -> Result, ProviderError> { + object + .get(key) + .map(|value| { + value + .as_f64() + .filter(|value| value.is_finite()) + .ok_or(ProviderError::Rejected) + }) + .transpose() +} + +fn codex_schema_projection_fingerprint( + tool: &ModelToolDefinition, + projected_schema: &Value, +) -> Result { + let canonical = canonical_json_value(json!({ + "format": "graphql-orm-ai/codex-dynamic-tool-schema-projection/v1", + "descriptorFingerprint": tool.fingerprint, + "canonicalSchema": tool.parameters, + "projectedSchema": projected_schema, + })); + let encoded = serde_json::to_vec(&canonical).map_err(|_| ProviderError::Rejected)?; + Ok(hex::encode(Sha256::digest(encoded))) +} + +fn canonical_json_value(value: Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .into_iter() + .map(|(key, value)| (key, canonical_json_value(value))) + .collect::>() + .into_iter() + .collect(), + ), + Value::Array(values) => { + Value::Array(values.into_iter().map(canonical_json_value).collect()) + } + value => value, + } +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAXIMUM_IDENTIFIER_BYTES + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/') + }) +} + +fn valid_version(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAXIMUM_VERSION_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b'"' && byte != b'\\') +} + +fn valid_reference(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= 1_024 + && !value.bytes().any(|byte| byte.is_ascii_control()) +} + +fn registration_identity(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}; + use std::path::PathBuf; + use std::process::{Child, ChildStdin, Command, Stdio}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc::{self, Receiver}; + use std::thread::{self, JoinHandle}; + + use agql_auth::{ + AccessTokenMetadata, AuthPrincipal, AuthUser, PrincipalReference, SessionContext, + }; + use futures::stream; + use graphql_orm::prelude::*; + + use super::*; + use crate::{AiRunId, AiSessionId, ProviderDynamicToolResult, ProviderEvent}; + use uuid::Uuid; + + mod canonical_tool_surface { + use graphql_orm::prelude::*; + + #[derive( + GraphQLEntity, GraphQLOperations, serde::Serialize, serde::Deserialize, Clone, Debug, + )] + #[graphql_entity( + table = "codex_profile_inventory", + plural = "CodexProfileInventory", + description = "Reviewed inventory records available to application workflows" + )] + pub struct CodexProfileInventoryRecord { + #[primary_key] + #[filterable(type = "string")] + #[sortable] + #[graphql_orm(description = "Stable public inventory identity")] + pub id: String, + #[graphql_orm(description = "Human-facing inventory label")] + pub label: String, + #[graphql_orm(description = "Internal field excluded from the AI projection")] + pub internal_marker: String, + } + + schema_roots! { + entities: [CodexProfileInventoryRecord], + } + } + + struct AdmitCanonicalGeneratedTool; + + impl crate::AiGeneratedGraphqlOperationPolicy for AdmitCanonicalGeneratedTool { + fn is_application_operation(&self, operation: &GraphqlResolverOperationDescriptor) -> bool { + operation.entity_name() == "CodexProfileInventoryRecord" + } + } + + struct LiveCodexProcess { + child: Child, + stdin: ChildStdin, + frames: Receiver>, + reader: Option>, + } + + impl LiveCodexProcess { + fn launch(executable: &str, root: PathBuf) -> Self { + assert!(root.is_absolute()); + assert!(root.is_dir()); + let profile = AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, + ) + .expect("direct-tool live profile should validate"); + let mut command = Command::new(executable); + command.args(profile.codex_arguments()); + let mut child = command + .env_clear() + .env("CODEX_HOME", &root) + .env("HOME", &root) + .env("PATH", "/usr/bin:/bin") .current_dir(&root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -3059,7 +4503,6 @@ mod tests { stdin, frames, reader: Some(reader), - root, } } @@ -3084,7 +4527,6 @@ mod tests { if let Some(reader) = self.reader.take() { let _ = reader.join(); } - let _ = fs::remove_dir_all(&self.root); } } @@ -3099,6 +4541,8 @@ mod tests { stream_error: AtomicBool, created_threads: AtomicUsize, created_dynamic_tools: AtomicUsize, + bound_turns: AtomicUsize, + retained_turns: AtomicUsize, deleted_threads: AtomicUsize, } @@ -3115,6 +4559,8 @@ mod tests { stream_error: AtomicBool::new(false), created_threads: AtomicUsize::new(0), created_dynamic_tools: AtomicUsize::new(0), + bound_turns: AtomicUsize::new(0), + retained_turns: AtomicUsize::new(0), deleted_threads: AtomicUsize::new(0), } } @@ -3126,6 +4572,10 @@ mod tests { #[async_trait] impl AiCodexAppServerRunProcessFactory for FakeFactory { + fn supports_launch_profile(&self, _profile: AiCodexAppServerLaunchProfile) -> bool { + true + } + async fn launch( &self, _registration: Arc, @@ -3144,6 +4594,18 @@ mod tests { } } + struct TextOnlyFactory; + + #[async_trait] + impl AiCodexAppServerRunProcessFactory for TextOnlyFactory { + async fn launch( + &self, + _registration: Arc, + ) -> Result { + Err(ProviderError::Unavailable) + } + } + struct FakeProcess { counters: Arc, } @@ -3159,6 +4621,7 @@ mod tests { async fn create_empty_thread( &self, _model: &str, + _bootstrap: &AiCodexAppServerBootstrapInstructions, dynamic_tools: Vec, ) -> Result { self.counters.created_threads.fetch_add(1, Ordering::SeqCst); @@ -3203,12 +4666,15 @@ mod tests { responder: Arc, ) -> Result { self.counters.turns.fetch_add(1, Ordering::SeqCst); + if self.counters.pending.load(Ordering::SeqCst) { + return Ok(Box::pin(stream::pending())); + } let definition = input.tools().first().ok_or(ProviderError::Rejected)?; let call = ProviderDynamicToolCall::from_definition( "turn-dynamic-1", "call-dynamic-1", definition, - json!({"query": "bounded"}), + json!({"Limit": 3}), )?; let result = responder.respond(call).await?; if result.call_id() != "call-dynamic-1" @@ -3227,11 +4693,11 @@ mod tests { }), Ok(ProviderEvent::ToolArgumentsDelta { call_id: "call-dynamic-1".to_owned(), - delta: "{\"query\":\"bounded\"}".to_owned(), + delta: "{\"Limit\":3}".to_owned(), }), Ok(ProviderEvent::ToolCallCompleted { call_id: "call-dynamic-1".to_owned(), - arguments: json!({"query": "bounded"}), + arguments: json!({"Limit": 3}), }), Ok(ProviderEvent::TextDelta { text: "There are three.".to_owned(), @@ -3252,15 +4718,36 @@ mod tests { _session: crate::AiOpenedProviderSession, input: AiCodexAppServerTurnInput, ) -> Result { + self.counters.retained_turns.fetch_add(1, Ordering::SeqCst); + self.start_fresh_turn(input).await + } + + async fn start_bound_turn( + &self, + _session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + ) -> Result { + self.counters.bound_turns.fetch_add(1, Ordering::SeqCst); self.start_fresh_turn(input).await } + async fn start_bound_dynamic_turn( + &self, + _session: crate::AiOpenedProviderSession, + input: AiCodexAppServerTurnInput, + responder: Arc, + ) -> Result { + self.counters.bound_turns.fetch_add(1, Ordering::SeqCst); + self.start_dynamic_turn(input, responder).await + } + async fn start_retained_dynamic_turn( &self, _session: crate::AiOpenedProviderSession, input: AiCodexAppServerTurnInput, responder: Arc, ) -> Result { + self.counters.retained_turns.fetch_add(1, Ordering::SeqCst); self.start_dynamic_turn(input, responder).await } @@ -3289,12 +4776,12 @@ mod tests { } fn binding_for_owner(owner: u8) -> AiProviderRunBinding { - AiProviderRunBinding::new( + AiProviderRunBinding::new_for_principal_reference( AiSessionId::new(), AiRunId::new(), Uuid::new_v4(), 1, - [owner; 32], + &principal_reference_for_owner(owner), ) .expect("test binding should validate") } @@ -3304,8 +4791,12 @@ mod tests { } fn principal_reference() -> PrincipalReference { + principal_reference_for_owner(1) + } + + fn principal_reference_for_owner(owner: u8) -> PrincipalReference { AuthPrincipal::User(AuthUser { - user_id: "codex-provider-test".to_owned(), + user_id: format!("codex-provider-test-{owner}"), session_id: Uuid::new_v4(), roles: Vec::new(), scopes: Vec::new(), @@ -3315,6 +4806,37 @@ mod tests { .reference() } + fn opened_session( + binding: AiProviderRunBinding, + registration: &AiCodexAppServerRegistration, + cursor: crate::AiProviderSessionCursor, + ) -> crate::AiOpenedProviderSession { + let descriptor = crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.logical_model(), + registration.identity(), + registration.protocol_version(), + "d".repeat(64), + ) + .expect("provider-session descriptor should validate"); + let claim = crate::AiProviderSessionClaim { + binding_id: Uuid::new_v4(), + session_id: binding.session_id(), + run_id: binding.run_id(), + attempt_id: binding.attempt_id(), + run_lease_generation: binding.lease_generation(), + binding_claim_generation: 1, + binding_row_version: 1, + claim_expires_at: time::OffsetDateTime::now_utc() + time::Duration::minutes(1), + through_message_sequence: 0, + transcript_fingerprint: "c".repeat(64), + principal_reference: principal_reference(), + descriptor, + }; + crate::AiOpenedProviderSession::new(claim, cursor) + } + fn registration(version: &str) -> Arc { Arc::new( AiCodexAppServerRegistration::new( @@ -3325,11 +4847,16 @@ mod tests { "sandbox-empty", AI_CODEX_APP_SERVER_PROTOCOL_V2, ) - .expect("test registration should validate"), + .expect("test registration should validate") + .with_bootstrap_instructions(trusted_bootstrap()), ) } fn dynamic_registration(version: &str) -> Arc { + let profile = AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, + ) + .expect("direct-tool launch profile should validate"); Arc::new( AiCodexAppServerRegistration::new( "profile-1", @@ -3340,24 +4867,142 @@ mod tests { AI_CODEX_APP_SERVER_PROTOCOL_V2, ) .expect("test registration should validate") - .with_experimental_dynamic_tools(), + .with_launch_profile(profile), + ) + } + + fn bootstrap_instructions() -> AiCodexAppServerBootstrapInstructions { + AiCodexAppServerBootstrapInstructions::from_static(&[ + "Use the exact registered application tool when it is required to answer the request.", + ]) + .expect("test bootstrap should validate") + } + + fn trusted_bootstrap() -> AiCodexAppServerBootstrapInstructions { + AiCodexAppServerBootstrapInstructions::from_static(&["trusted"]) + .expect("trusted test bootstrap should validate") + } + + pub(crate) fn canonical_dynamic_tool_catalog() -> (crate::AiToolCatalog, ModelToolDefinition) { + let operation_catalog = canonical_tool_surface::graphql_orm_operation_catalog(); + let operation = operation_catalog + .exposed_operations() + .find(|operation| { + operation.category() == GeneratedGraphqlOperationCategory::List + && operation.entity_name() == "CodexProfileInventoryRecord" + }) + .expect("generated inventory list operation should exist"); + let arguments = operation + .arguments() + .iter() + .map(|argument| format!("{}: {}", argument.graphql_name(), argument.graphql_type())) + .collect::>() + .join(", "); + let (page_argument, page_first, page_info, total_count) = + if cfg!(feature = "graphql-case-pascal") { + ("Page", "First", "PageInfo", "TotalCount") + } else { + ("page", "first", "pageInfo", "totalCount") + }; + let sdl = format!( + r#" + schema {{ query: Query }} + type Query {{ {}({arguments}): CodexProfileInventoryRecordConnection! }} + input CodexProfileInventoryRecordWhereInput {{ id: StringFilter }} + input CodexProfileInventoryRecordOrderByInput {{ id: SortDirection }} + input PageInput {{ {page_first}: Int }} + input StringFilter {{ eq: String }} + enum SortDirection {{ ASC DESC }} + type CodexProfileInventoryRecordConnection {{ + nodes: [CodexProfileInventoryRecord!]! + {page_info}: PageInfo! + }} + type PageInfo {{ {total_count}: Int! }} + type CodexProfileInventoryRecord {{ id: String!, label: String!, internalMarker: String! }} + "#, + operation.field_name(), + ); + let disclosure_rule = + crate::AiDisclosureRule::exportable(crate::DataClassification::Internal); + let disclosure = crate::AiDisclosureSchema::new( + "codex-generated-count-v1", + crate::AiDisclosureShape::object( + disclosure_rule, + [( + operation.field_name().to_owned(), + crate::AiDisclosureShape::object( + disclosure_rule, + [( + page_info.to_owned(), + crate::AiDisclosureShape::object( + disclosure_rule, + [( + total_count.to_owned(), + crate::AiDisclosureShape::scalar(disclosure_rule), + )], + ), + )], + ), + )], + ), + ) + .expect("generated inventory disclosure should validate"); + let profile = crate::AiGraphqlToolProfile::read_only( + "bounded-count", + operation.field_name(), + "Count a bounded set of visible inventory records", + vec![crate::AiGraphqlSelection::object( + page_info, + [crate::AiGraphqlSelection::scalar(total_count)], + )], + disclosure, + 4_096, + 1, + ) + .with_inputs([crate::AiGraphqlProfileInput::integer( + "Limit", + "Maximum records to consider", + true, + 1, + 25, + )]) + .with_arguments([crate::AiGraphqlArgumentPlan::new( + page_argument, + crate::AiGraphqlArgumentValue::object([( + page_first, + crate::AiGraphqlArgumentValue::input("Limit"), + )]), + )]); + let mut builder = crate::AiGraphqlToolManifestBuilder::new( + "canonical-inventory-service", + crate::GraphqlExecutionTargetId::parse("canonical-inventory-graph") + .expect("generated inventory target should validate"), + &sdl, ) + .expect("generated inventory manifest builder should validate"); + builder + .add_generated_profile(profile, operation_catalog, &AdmitCanonicalGeneratedTool) + .expect("generated inventory profile should compile"); + let manifest = builder + .build() + .expect("generated inventory manifest should build"); + let tool_id = manifest.entries[0].descriptor.id.clone(); + let mut catalog = crate::AiToolCatalog::new(); + manifest + .register_into( + &mut catalog, + operation_catalog, + &AdmitCanonicalGeneratedTool, + ) + .expect("generated inventory manifest should register"); + let definition = catalog + .read_only_model_definition(&tool_id, "inventory_count") + .expect("catalog should project the generated definition"); + (catalog, definition) } fn dynamic_tool() -> ModelToolDefinition { - ModelToolDefinition { - tool_id: "inventory.count".to_owned(), - provider_name: "inventory_count".to_owned(), - fingerprint: "b".repeat(64), - description: "Count a bounded reviewed inventory.".to_owned(), - parameters: json!({ - "type": "object", - "properties": {"query": {"type": "string", "maxLength": 100}}, - "required": ["query"], - "additionalProperties": false - }), - strict: true, - } + canonical_dynamic_tool_catalog().1 } struct FakeDynamicResponder; @@ -3368,12 +5013,33 @@ mod tests { &self, call: ProviderDynamicToolCall, ) -> Result { + let expected = dynamic_tool(); if call.response_id() != "turn-dynamic-1" || call.call_id() != "call-dynamic-1" - || call.tool_id() != "inventory.count" - || call.provider_name() != "inventory_count" - || call.tool_fingerprint() != "b".repeat(64) - || call.arguments() != &json!({"query": "bounded"}) + || call.tool_id() != expected.tool_id + || call.provider_name() != expected.provider_name + || call.tool_fingerprint() != expected.fingerprint + || call.arguments() != &json!({"Limit": 3}) + { + return Err(ProviderError::Rejected); + } + ProviderDynamicToolResult::new(&call, json!({"count": 3})) + } + } + + struct LiveDynamicResponder; + + #[async_trait] + impl ProviderDynamicToolResponder for LiveDynamicResponder { + async fn respond( + &self, + call: ProviderDynamicToolCall, + ) -> Result { + let expected = dynamic_tool(); + if call.tool_id() != expected.tool_id + || call.provider_name() != expected.provider_name + || call.tool_fingerprint() != expected.fingerprint + || call.arguments() != &json!({"Limit": 3}) { return Err(ProviderError::Rejected); } @@ -3403,38 +5069,199 @@ mod tests { } #[test] - fn provider_advertises_only_implemented_retained_local_capabilities() { - let counters = Arc::new(Counters::new()); - let provider = AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters, 1, 2)); - let capabilities = provider.capabilities(); - assert!(capabilities.streaming); - assert!(capabilities.local); - assert!(capabilities.provider_retained_continuation); - assert!(!capabilities.custom_tools); - assert!(!capabilities.stateless_continuation); - assert!(!capabilities.web_search); - assert!(!capabilities.code_execution); + fn retained_bootstrap_is_static_registration_bound_and_active_on_first_turn() { + let bootstrap = bootstrap_instructions(); + let without_bootstrap = dynamic_registration("1.0.0"); + let with_bootstrap = AiCodexAppServerRegistration::new( + "profile-1", + "model-1", + "a".repeat(64), + "1.0.0", + "sandbox-empty", + AI_CODEX_APP_SERVER_PROTOCOL_V2, + ) + .expect("registration should validate") + .with_launch_profile( + AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, + ) + .expect("direct profile should validate"), + ) + .with_bootstrap_instructions(bootstrap.clone()); + assert_ne!(without_bootstrap.identity(), with_bootstrap.identity()); - let dynamic_provider = AiCodexAppServerProvider::new( - dynamic_registration("1.0.0"), - pool(Arc::new(Counters::new()), 1, 2), - ); - assert!(dynamic_provider.capabilities().custom_tools); - assert!( - dynamic_provider - .capabilities() - .provider_retained_continuation + let mut actor = initialized_protocol_actor(); + let frame = actor + .start_persistent_empty_thread("model-1", &bootstrap, &[dynamic_tool()]) + .expect("static bootstrap thread should encode"); + let value: Value = serde_json::from_slice(&frame).expect("frame should be JSON"); + assert_eq!( + value.pointer("/params/developerInstructions"), + Some(&Value::String( + "Use the exact registered application tool when it is required to answer the request." + .to_owned(), + )) ); - } + assert!(value.pointer("/params/input").is_none()); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-bootstrap"}}}"#) + .expect("thread response should bind"); + actor + .accept(&thread_started_notification("thread-bootstrap")) + .expect("thread notification should bind"); - fn turn() -> AiCodexAppServerTurnInput { - AiCodexAppServerTurnInput::new( - "model-1", - vec!["trusted".to_owned()], - vec!["hello".to_owned()], - 128, + let mut request = dynamic_model_request(); + request.instructions.clear(); + let input = AiCodexAppServerTurnInput::try_from_retained_dynamic_request( + request.clone(), + &bootstrap, ) - .expect("test turn should validate") + .expect("registration bootstrap should project into retained input"); + actor + .start_turn("thread-bootstrap", &input) + .expect("first bound turn should retain the static bootstrap"); + + request.instructions = vec!["request-local replacement".to_owned()]; + assert!(matches!( + AiCodexAppServerTurnInput::try_from_retained_dynamic_request(request, &bootstrap), + Err(ProviderError::Rejected) + )); + assert!(AiCodexAppServerBootstrapInstructions::from_static(&["bad\0text"]).is_err()); + } + + #[test] + fn canonical_generated_schema_projects_closed_bounds_for_codex() { + let tool = dynamic_tool(); + assert_eq!( + tool.parameters.pointer("/$schema").and_then(Value::as_str), + Some("https://json-schema.org/draft/2020-12/schema") + ); + assert_eq!( + tool.parameters + .pointer("/properties/Limit/minimum") + .and_then(Value::as_i64), + Some(1) + ); + assert_eq!( + tool.parameters + .pointer("/properties/Limit/maximum") + .and_then(Value::as_i64), + Some(25) + ); + let projected = project_codex_dynamic_tools(std::slice::from_ref(&tool)) + .expect("canonical generated schema should project"); + let schema = &projected.protocol_values[0]["inputSchema"]; + assert!(schema.get("$schema").is_none()); + assert!(schema.pointer("/properties/Limit/minimum").is_none()); + assert!(schema.pointer("/properties/Limit/maximum").is_none()); + let description = schema + .pointer("/properties/Limit/description") + .and_then(Value::as_str) + .expect("Codex projection should preserve constraints semantically"); + assert!(description.contains("minimum 1")); + assert!(description.contains("maximum 25")); + + let mut reordered = tool.clone(); + reordered.parameters = json!({ + "additionalProperties": false, + "required": ["Limit"], + "properties": { + "Limit": { + "maximum": 25, + "minimum": 1, + "description": "Maximum records to consider", + "type": "integer" + } + }, + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }); + let reordered_projection = project_codex_dynamic_tools(&[reordered]) + .expect("object-key ordering must not alter projection"); + assert_eq!(projected.fingerprints, reordered_projection.fingerprints); + + let mut substituted = tool; + substituted.fingerprint = "f".repeat(64); + let substituted_projection = project_codex_dynamic_tools(&[substituted]) + .expect("syntactically valid substituted descriptor should project distinctly"); + assert_ne!(projected.fingerprints, substituted_projection.fingerprints); + } + + #[test] + fn dynamic_tools_only_profile_requires_direct_model_and_closes_native_surfaces() { + for mode in [ + AiCodexAppServerModelToolMode::CodeMode, + AiCodexAppServerModelToolMode::CodeModeOnly, + ] { + assert!(matches!( + AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1(mode), + Err(ProviderError::InvalidConfiguration(_)) + )); + } + let profile = AiCodexAppServerLaunchProfile::experimental_dynamic_tools_only_v1( + AiCodexAppServerModelToolMode::Direct, + ) + .expect("direct-tool profile should validate"); + assert!(profile.supports_experimental_dynamic_tools()); + assert!(profile.requires_isolated_configuration_home()); + let arguments = profile.codex_arguments(); + assert_eq!( + &arguments[..3], + ["app-server", "--stdio", "--strict-config"] + ); + assert!(!arguments.contains(&"--enable")); + for feature in DYNAMIC_TOOLS_ONLY_DISABLED_FEATURES { + assert!( + arguments + .windows(2) + .any(|pair| pair == ["--disable", *feature]), + "missing disabled feature {feature}" + ); + } + } + + #[test] + fn provider_advertises_only_implemented_retained_local_capabilities() { + let counters = Arc::new(Counters::new()); + let provider = AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters, 1, 2)); + let capabilities = provider.capabilities(); + assert!(capabilities.streaming); + assert!(capabilities.local); + assert!(capabilities.provider_retained_continuation); + assert!(!capabilities.custom_tools); + assert!(!capabilities.stateless_continuation); + assert!(!capabilities.web_search); + assert!(!capabilities.code_execution); + + let dynamic_provider = AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + pool(Arc::new(Counters::new()), 1, 2), + ); + assert!(dynamic_provider.capabilities().custom_tools); + assert!( + dynamic_provider + .capabilities() + .provider_retained_continuation + ); + + let unverified_dynamic_provider = AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + AiCodexAppServerRunPool::new( + Arc::new(TextOnlyFactory), + AiCodexAppServerRunLimits::default(), + ), + ); + assert!(!unverified_dynamic_provider.capabilities().custom_tools); + } + + fn turn() -> AiCodexAppServerTurnInput { + AiCodexAppServerTurnInput::new( + "model-1", + vec!["trusted".to_owned()], + vec!["hello".to_owned()], + 128, + ) + .expect("test turn should validate") } fn model_request() -> ModelRequest { @@ -3457,6 +5284,7 @@ mod tests { fn dynamic_model_request() -> ModelRequest { ModelRequest { + instructions: Vec::new(), continuation_mode: ModelContinuationMode::ProviderRetained, tools: vec![dynamic_tool()], ..model_request() @@ -3505,14 +5333,50 @@ mod tests { .expect("lifecycle notification should encode") } + fn runtime_warning_notification(thread_id: Option<&str>, message: &str) -> Vec { + let params = thread_id.map_or_else( + || json!({"message": message}), + |thread_id| json!({"threadId": thread_id, "message": message}), + ); + lifecycle_notification(RUNTIME_WARNING, params) + } + + fn reasoning_lifecycle_notification( + method: &str, + item_id: &str, + content: Value, + summary: Value, + ) -> Vec { + let timestamp_key = if method == "item/started" { + "startedAtMs" + } else { + "completedAtMs" + }; + let mut params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "item": { + "id": item_id, + "type": "reasoning", + "content": content, + "summary": summary, + }, + }); + params + .as_object_mut() + .expect("reasoning params should be an object") + .insert(timestamp_key.to_owned(), json!(1)); + lifecycle_notification(method, params) + } + fn thread_started_notification(thread_id: &str) -> Vec { lifecycle_notification("thread/started", json!({"thread": {"id": thread_id}})) } - fn thread_not_loaded_notification(thread_id: &str) -> Vec { + fn thread_status_notification(thread_id: &str, status: Value) -> Vec { lifecycle_notification( "thread/status/changed", - json!({"threadId": thread_id, "status": {"type": "notLoaded"}}), + json!({"threadId": thread_id, "status": status}), ) } @@ -3526,6 +5390,45 @@ mod tests { ) } + fn turn_completed_notification(thread_id: &str, turn_id: &str) -> Vec { + lifecycle_notification( + "turn/completed", + json!({ + "threadId": thread_id, + "turn": {"id": turn_id, "items": [], "status": "completed"}, + }), + ) + } + + fn token_usage_notification(thread_id: &str, turn_id: &str) -> Vec { + lifecycle_notification( + THREAD_TOKEN_USAGE_UPDATED, + json!({ + "threadId": thread_id, + "turnId": turn_id, + "tokenUsage": { + "last": { + "cacheWriteInputTokens": 0, + "cachedInputTokens": 0, + "inputTokens": 1, + "outputTokens": 1, + "reasoningOutputTokens": 0, + "totalTokens": 2, + }, + "total": { + "cacheWriteInputTokens": 0, + "cachedInputTokens": 0, + "inputTokens": 1, + "outputTokens": 1, + "reasoningOutputTokens": 0, + "totalTokens": 2, + }, + "modelContextWindow": 128000, + }, + }), + ) + } + fn active_protocol_actor() -> AiCodexAppServerProtocolActor { let mut actor = initialized_protocol_actor(); actor @@ -3629,8 +5532,14 @@ mod tests { ProviderRequestContext::new(session_id, run_id, "test", budget, manifest, proof) .expect("context should validate") .with_run_binding( - AiProviderRunBinding::new(session_id, run_id, attempt_id, 1, [1; 32]) - .expect("binding should validate"), + AiProviderRunBinding::new_for_principal_reference( + session_id, + run_id, + attempt_id, + 1, + &principal_reference(), + ) + .expect("binding should validate"), ) .expect("binding should match context") } @@ -3695,7 +5604,7 @@ mod tests { } #[tokio::test] - async fn retained_dynamic_turn_uses_exact_opened_cursor_and_coordinator_responder() { + async fn newly_bound_dynamic_turn_uses_creating_process_without_resume() { let counters = Arc::new(Counters::new()); let pool = pool(counters.clone(), 1, 2); let binding = binding(); @@ -3704,34 +5613,13 @@ mod tests { .create_empty_thread(binding, registration.clone(), vec![dynamic_tool()]) .await .expect("retained dynamic thread should create"); - let descriptor = crate::AiProviderSessionDescriptor::new( - ProviderKind::LocalHarness, - registration.provider_profile_id(), - registration.logical_model(), - registration.identity(), - registration.protocol_version(), - "d".repeat(64), - ) - .expect("provider-session descriptor should validate"); - let claim = crate::AiProviderSessionClaim { - binding_id: Uuid::new_v4(), - session_id: binding.session_id(), - run_id: binding.run_id(), - attempt_id: binding.attempt_id(), - run_lease_generation: binding.lease_generation(), - binding_claim_generation: 1, - binding_row_version: 1, - claim_expires_at: time::OffsetDateTime::now_utc() + time::Duration::minutes(1), - through_message_sequence: 0, - transcript_fingerprint: "c".repeat(64), - principal_reference: principal_reference(), - descriptor, - }; - let opened = crate::AiOpenedProviderSession::new(claim, cursor); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("executor activation should match the exact cursor and run"); let input = AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) .expect("dynamic request should convert"); let events = pool - .start_retained_dynamic_turn( + .start_bound_dynamic_turn( binding, registration, opened, @@ -3744,285 +5632,625 @@ mod tests { .await; assert_eq!(events.len(), 7); assert!(events.iter().all(Result::is_ok)); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); assert_eq!(counters.created_threads.load(Ordering::SeqCst), 1); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 0); assert_eq!(counters.turns.load(Ordering::SeqCst), 1); } - #[test] - fn model_request_conversion_is_closed_and_preserves_authority_boundaries() { - let converted = AiCodexAppServerTurnInput::try_from_model_request(model_request()) - .expect("text-only request should convert"); - assert_eq!(converted.instructions(), &["trusted"]); - assert_eq!(converted.input(), &["hello"]); - assert!(!format!("{converted:?}").contains("trusted")); - assert!(!format!("{converted:?}").contains("hello")); - - let mut json = model_request(); - json.input = vec![ModelInputBlock::Json { - value: json!({"unreviewed": true}), - }]; - assert!(matches!( - AiCodexAppServerTurnInput::try_from_model_request(json), - Err(ProviderError::Unsupported) - )); - - let mut retained = model_request(); - retained.continuation_mode = ModelContinuationMode::ProviderRetained; - assert!(matches!( - AiCodexAppServerTurnInput::try_from_model_request(retained), - Err(ProviderError::Unsupported) - )); - - let mut reasoning = model_request(); - reasoning.reasoning_summary = ModelReasoningSummaryRequest::auto(1_024) - .expect("test summary request should validate"); - assert!(matches!( - AiCodexAppServerTurnInput::try_from_model_request(reasoning), - Err(ProviderError::Unsupported) - )); - } - #[tokio::test] - async fn provider_rejects_profile_swap_before_process_launch() { + async fn provider_dispatches_executor_marked_session_to_initial_direct_turn() { let counters = Arc::new(Counters::new()); + let registration = dynamic_registration("1.0.0"); let provider = - AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters.clone(), 1, 2)); - let request = model_request(); - let context = provider_context("another-profile", &request); - assert!(matches!( - provider.stream(request, context).await, - Err(ProviderError::EgressDenied) - )); - assert_eq!(counters.launches.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn experimental_dynamic_tools_are_explicit_and_process_bounded() { - let counters = Arc::new(Counters::new()); + AiCodexAppServerProvider::new(registration.clone(), pool(counters.clone(), 1, 2)); let request = dynamic_model_request(); - let context = provider_context("profile-1", &request); - let disabled = - AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters.clone(), 1, 2)); - assert!(matches!( - disabled - .stream_with_dynamic_tools( - request.clone(), - context.clone(), - Arc::new(FakeDynamicResponder), - ) - .await, - Err(ProviderError::Unsupported) - )); - assert_eq!(counters.launches.load(Ordering::SeqCst), 0); - - let enabled = AiCodexAppServerProvider::new( - dynamic_registration("1.0.0"), - pool(counters.clone(), 1, 2), - ); - let events = enabled + let context = provider_context(registration.provider_profile_id(), &request); + let binding = context + .run_binding() + .expect("executor context should carry the exact run binding"); + let descriptor = crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.logical_model(), + registration.identity(), + registration.protocol_version(), + "d".repeat(64), + ) + .expect("descriptor should validate"); + let cursor = provider + .create_empty_session(&binding, &descriptor, &request) + .await + .expect("provider should create an empty thread"); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("executor should mark only the exact new binding"); + let context = context + .with_provider_session(opened) + .expect("opened provider session should match the run context"); + let events = provider .stream_with_dynamic_tools(request, context, Arc::new(FakeDynamicResponder)) .await - .expect("explicit dynamic turn should start") + .expect("provider should dispatch initial activation directly") .collect::>() .await; assert_eq!(events.len(), 7); - assert!(events.iter().all(Result::is_ok)); assert_eq!(counters.launches.load(Ordering::SeqCst), 1); - assert_eq!(counters.turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn provider_trait_dispatches_exact_interrupt_and_terminal_close() { + async fn provider_dispatches_tool_free_newly_bound_session_directly() { let counters = Arc::new(Counters::new()); - counters.pending.store(true, Ordering::SeqCst); - let provider: Arc = Arc::new(AiCodexAppServerProvider::new( - registration("1.0.0"), - pool(counters.clone(), 1, 2), - )); - let request = model_request(); - let context = provider_context("profile-1", &request); + let registration = registration("1.0.0"); + let provider = + AiCodexAppServerProvider::new(registration.clone(), pool(counters.clone(), 1, 2)); + let mut request = model_request(); + request.continuation_mode = ModelContinuationMode::ProviderRetained; + request.instructions.clear(); + let context = provider_context(registration.provider_profile_id(), &request); let binding = context .run_binding() - .expect("test context should carry the exact run binding"); - let active = provider + .expect("executor context should carry the exact run binding"); + let descriptor = crate::AiProviderSessionDescriptor::new( + ProviderKind::LocalHarness, + registration.provider_profile_id(), + registration.logical_model(), + registration.identity(), + registration.protocol_version(), + "d".repeat(64), + ) + .expect("descriptor should validate"); + let cursor = provider + .create_empty_session(&binding, &descriptor, &request) + .await + .expect("provider should create a tool-free empty thread"); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("executor should mark only the exact new binding"); + let context = context + .with_provider_session(opened) + .expect("opened provider session should match the run context"); + let events = provider .stream(request, context) .await - .expect("provider turn should start"); - assert_eq!( - provider - .interrupt_run(&binding) - .await - .expect("interrupt should dispatch"), - AiProviderRunInterruptOutcome::Requested - ); - assert_eq!( - provider - .close_run(&binding, AiProviderRunCloseReason::Cancelled) - .await - .expect("close should dispatch"), - AiProviderRunCloseOutcome::Closed - ); - drop(active); - assert_eq!(counters.interrupts.load(Ordering::SeqCst), 1); - assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); - assert_eq!(counters.kills.load(Ordering::SeqCst), 1); + .expect("provider should dispatch tool-free activation directly") + .collect::>() + .await; + assert_eq!(events.len(), 4); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn registration_swap_and_concurrent_turn_fail_closed() { + async fn newly_bound_tool_free_activation_is_exact_and_one_shot() { let counters = Arc::new(Counters::new()); - counters.pending.store(true, Ordering::SeqCst); - let pool = pool(counters, 2, 4); + let pool = pool(counters.clone(), 2, 3); let binding = binding(); - let active = pool - .start_fresh_turn(binding, registration("1.0.0"), turn()) + let registration = registration("1.0.0"); + let cursor = pool + .create_empty_thread(binding, registration.clone(), Vec::new()) .await - .expect("first turn should start"); - assert!(matches!( - pool.start_fresh_turn(binding, registration("1.0.0"), turn()) + .expect("empty retained thread should create"); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("executor activation should match"); + let replay = opened.clone(); + let events = pool + .start_bound_turn(binding, registration.clone(), opened, turn()) + .await + .expect("initial bound turn should start directly") + .collect::>() + .await; + assert_eq!(events.len(), 4); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 1); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 0); + assert!(matches!( + pool.start_bound_turn(binding, registration, replay, turn()) .await, Err(ProviderError::Rejected) )); - drop(active); + } + + #[tokio::test] + async fn newly_bound_activation_rejects_cursor_process_and_tool_swaps() { + let counters = Arc::new(Counters::new()); + let primary_pool = pool(counters.clone(), 2, 3); + let binding = binding(); + let registration = dynamic_registration("1.0.0"); + let created = primary_pool + .create_empty_thread(binding, registration.clone(), vec![dynamic_tool()]) + .await + .expect("empty retained thread should create"); + let other_binding = AiProviderRunBinding::new_for_principal_reference( + binding.session_id(), + binding.run_id(), + binding.attempt_id(), + binding.lease_generation(), + &principal_reference_for_owner(2), + ) + .expect("same fence with another owner should construct for rejection testing"); assert!(matches!( - pool.start_fresh_turn(binding, registration("2.0.0"), turn()) + opened_session(binding, ®istration, created.clone()) + .activate_newly_bound_empty(other_binding, &created), + Err(crate::AiError::Conflict) + )); + let swapped = crate::AiProviderSessionCursor::new( + "codex.app_server.thread.v2", + "thread-retained-swapped", + ) + .expect("swapped cursor should be structurally valid"); + let opened = opened_session(binding, ®istration, swapped.clone()) + .activate_newly_bound_empty(binding, &swapped) + .expect("crate marker alone is not the process correlation proof"); + let input = AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) + .expect("dynamic input should validate"); + assert!(matches!( + primary_pool + .start_bound_dynamic_turn( + binding, + registration.clone(), + opened, + input, + Arc::new(FakeDynamicResponder), + ) + .await, + Err(ProviderError::Rejected) + )); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 0); + + let other_counters = Arc::new(Counters::new()); + let other_pool = pool(other_counters.clone(), 1, 2); + let opened = opened_session(binding, ®istration, created.clone()) + .activate_newly_bound_empty(binding, &created) + .expect("exact activation should validate"); + assert!(matches!( + other_pool + .start_bound_dynamic_turn( + binding, + registration, + opened, + AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) + .expect("dynamic input should validate"), + Arc::new(FakeDynamicResponder), + ) .await, Err(ProviderError::Rejected) )); + assert_eq!(other_counters.launches.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn stream_failure_invalidates_process_before_retry() { + async fn later_run_resumes_committed_cursor_on_a_new_process() { let counters = Arc::new(Counters::new()); - counters.stream_error.store(true, Ordering::SeqCst); - let pool = pool(counters.clone(), 2, 4); - let binding = binding(); + let pool = pool(counters.clone(), 1, 2); + let later_binding = binding_for_owner(1); + let registration = dynamic_registration("1.0.0"); + let cursor = crate::AiProviderSessionCursor::new( + "codex.app_server.thread.v2", + "thread-retained-test", + ) + .expect("committed cursor should validate"); + let opened = opened_session(later_binding, ®istration, cursor); + let input = AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) + .expect("dynamic input should validate"); let events = pool - .start_fresh_turn(binding, registration("1.0.0"), turn()) + .start_retained_dynamic_turn( + later_binding, + registration, + opened, + input, + Arc::new(FakeDynamicResponder), + ) .await - .expect("turn should start") + .expect("later retained claim should use resume path") .collect::>() .await; - assert!(matches!(events.as_slice(), [Err(ProviderError::Rejected)])); - assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); + assert_eq!(events.len(), 7); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 0); + assert_eq!(counters.retained_turns.load(Ordering::SeqCst), 1); + } - counters.stream_error.store(false, Ordering::SeqCst); - pool.start_fresh_turn(binding, registration("1.0.0"), turn()) + #[tokio::test] + async fn newly_bound_activation_rejects_changed_frozen_tool_definition() { + let counters = Arc::new(Counters::new()); + let pool = pool(counters.clone(), 1, 2); + let binding = binding(); + let registration = dynamic_registration("1.0.0"); + let cursor = pool + .create_empty_thread(binding, registration.clone(), vec![dynamic_tool()]) .await - .expect("retry should use a replacement process") - .collect::>() - .await; - assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + .expect("empty dynamic thread should create"); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("exact activation should validate"); + let mut request = dynamic_model_request(); + request.tools[0].description = "Changed after durable binding.".to_owned(); + let changed = AiCodexAppServerTurnInput::try_from_dynamic_request(request) + .expect("changed definition remains structurally valid"); + assert!(matches!( + pool.start_bound_dynamic_turn( + binding, + registration, + opened, + changed, + Arc::new(FakeDynamicResponder), + ) + .await, + Err(ProviderError::Rejected) + )); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn capacity_and_turn_limits_apply_without_extra_launches() { + async fn close_before_newly_bound_turn_prevents_business_content() { let counters = Arc::new(Counters::new()); - let pool = pool(counters.clone(), 1, 1); - let first = binding(); - pool.start_fresh_turn(first, registration("1.0.0"), turn()) + let pool = pool(counters.clone(), 1, 2); + let binding = binding(); + let registration = registration("1.0.0"); + let cursor = pool + .create_empty_thread(binding, registration.clone(), Vec::new()) .await - .expect("first turn should start") - .collect::>() - .await; + .expect("empty thread should create"); + let opened = opened_session(binding, ®istration, cursor.clone()) + .activate_newly_bound_empty(binding, &cursor) + .expect("activation should validate"); + assert_eq!( + pool.close_run(&binding, AiProviderRunCloseReason::Cancelled) + .await + .expect("exact cancellation should close the process"), + AiProviderRunCloseOutcome::Closed + ); assert!(matches!( - pool.start_fresh_turn(first, registration("1.0.0"), turn()) + pool.start_bound_turn(binding, registration, opened, turn()) .await, - Err(ProviderError::RateLimited) + Err(ProviderError::Rejected) )); - let second = binding(); + assert_eq!(counters.bound_turns.load(Ordering::SeqCst), 0); + assert_eq!(counters.turns.load(Ordering::SeqCst), 0); + } + + #[test] + fn model_request_conversion_is_closed_and_preserves_authority_boundaries() { + let converted = AiCodexAppServerTurnInput::try_from_model_request(model_request()) + .expect("text-only request should convert"); + assert_eq!(converted.instructions(), &["trusted"]); + assert_eq!(converted.input(), &["hello"]); + assert!(!format!("{converted:?}").contains("trusted")); + assert!(!format!("{converted:?}").contains("hello")); + + let mut json = model_request(); + json.input = vec![ModelInputBlock::Json { + value: json!({"unreviewed": true}), + }]; assert!(matches!( - pool.start_fresh_turn(second, registration("1.0.0"), turn()) - .await, - Err(ProviderError::RateLimited) + AiCodexAppServerTurnInput::try_from_model_request(json), + Err(ProviderError::Unsupported) )); - assert_eq!(counters.launches.load(Ordering::SeqCst), 1); - pool.close_run(&first, AiProviderRunCloseReason::Completed) - .await - .expect("closing the admitted run should succeed"); - pool.start_fresh_turn(second, registration("2.0.0"), turn()) - .await - .expect("rejected admission must not freeze a registration identity") - .collect::>() - .await; - assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + let mut retained = model_request(); + retained.continuation_mode = ModelContinuationMode::ProviderRetained; + assert!(matches!( + AiCodexAppServerTurnInput::try_from_model_request(retained.clone()), + Err(ProviderError::Unsupported) + )); + retained.instructions.clear(); + AiCodexAppServerTurnInput::try_from_retained_model_request( + retained, + &bootstrap_instructions(), + ) + .expect("tool-free retained initial input should use its explicit converter"); + + let mut reasoning = model_request(); + reasoning.reasoning_summary = ModelReasoningSummaryRequest::auto(1_024) + .expect("test summary request should validate"); + assert!(matches!( + AiCodexAppServerTurnInput::try_from_model_request(reasoning), + Err(ProviderError::Unsupported) + )); } #[tokio::test] - async fn per_owner_admission_prevents_one_subject_from_exhausting_the_pool() { + async fn provider_rejects_profile_swap_before_process_launch() { let counters = Arc::new(Counters::new()); - let limits = AiCodexAppServerRunLimits::new( - 3, - 2, - Duration::from_secs(1), - Duration::from_secs(1), - Duration::from_secs(1), - Duration::from_secs(1), - ) - .expect("test limits should validate") - .with_maximum_processes_per_owner(1) - .expect("per-owner limit should validate"); - let pool = AiCodexAppServerRunPool::new( - Arc::new(FakeFactory { - counters: counters.clone(), - }), - limits, + let provider = + AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters.clone(), 1, 2)); + let request = model_request(); + let context = provider_context("another-profile", &request); + assert!(matches!( + provider.stream(request, context).await, + Err(ProviderError::EgressDenied) + )); + assert_eq!(counters.launches.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn experimental_dynamic_tools_are_explicit_and_process_bounded() { + let counters = Arc::new(Counters::new()); + let request = dynamic_model_request(); + let context = provider_context("profile-1", &request); + let disabled = + AiCodexAppServerProvider::new(registration("1.0.0"), pool(counters.clone(), 1, 2)); + assert!(matches!( + disabled + .stream_with_dynamic_tools( + request.clone(), + context.clone(), + Arc::new(FakeDynamicResponder), + ) + .await, + Err(ProviderError::Unsupported) + )); + assert_eq!(counters.launches.load(Ordering::SeqCst), 0); + + let unverified = AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + AiCodexAppServerRunPool::new( + Arc::new(TextOnlyFactory), + AiCodexAppServerRunLimits::default(), + ), ); - pool.start_fresh_turn(binding_for_owner(1), registration("1.0.0"), turn()) - .await - .expect("first owner turn should start") - .collect::>() - .await; assert!(matches!( - pool.start_fresh_turn(binding_for_owner(1), registration("1.0.0"), turn()) + unverified + .stream_with_dynamic_tools( + request.clone(), + context.clone(), + Arc::new(FakeDynamicResponder), + ) .await, - Err(ProviderError::RateLimited) + Err(ProviderError::Unsupported) )); - pool.start_fresh_turn(binding_for_owner(2), registration("1.0.0"), turn()) + + let enabled = AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + pool(counters.clone(), 1, 2), + ); + let events = enabled + .stream_with_dynamic_tools(request, context, Arc::new(FakeDynamicResponder)) .await - .expect("another owner should retain independent capacity") + .expect("explicit dynamic turn should start") .collect::>() .await; - assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + assert_eq!(events.len(), 7); + assert!(events.iter().all(Result::is_ok)); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + assert_eq!(counters.turns.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn interrupt_and_close_are_exact_and_idempotent() { + async fn active_dynamic_turn_uses_exact_interrupt_and_close_lifecycle() { let counters = Arc::new(Counters::new()); counters.pending.store(true, Ordering::SeqCst); - let pool = pool(counters.clone(), 1, 2); - let binding = binding(); - let active = pool - .start_fresh_turn(binding, registration("1.0.0"), turn()) + let provider: Arc = Arc::new(AiCodexAppServerProvider::new( + dynamic_registration("1.0.0"), + pool(counters.clone(), 1, 2), + )); + let request = dynamic_model_request(); + let context = provider_context("profile-1", &request); + let binding = context + .run_binding() + .expect("test context should carry the exact run binding"); + let active = provider + .stream_with_dynamic_tools(request, context, Arc::new(FakeDynamicResponder)) .await - .expect("turn should start"); + .expect("dynamic provider turn should start"); assert_eq!( - pool.interrupt_run(&binding) + provider + .interrupt_run(&binding) .await - .expect("interrupt should succeed"), + .expect("dynamic interrupt should dispatch"), AiProviderRunInterruptOutcome::Requested ); - assert_eq!(counters.interrupts.load(Ordering::SeqCst), 1); assert_eq!( - pool.close_run(&binding, AiProviderRunCloseReason::Cancelled) + provider + .close_run(&binding, AiProviderRunCloseReason::Cancelled) .await - .expect("close should succeed"), + .expect("dynamic close should dispatch"), AiProviderRunCloseOutcome::Closed ); - assert_eq!( - pool.close_run(&binding, AiProviderRunCloseReason::Cancelled) - .await - .expect("duplicate close should be inert"), - AiProviderRunCloseOutcome::NotActive - ); - assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); drop(active); + assert_eq!(counters.interrupts.load(Ordering::SeqCst), 1); + assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); assert_eq!(counters.kills.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn final_pool_drop_synchronously_invokes_process_tree_kill() { + async fn provider_trait_dispatches_exact_interrupt_and_terminal_close() { + let counters = Arc::new(Counters::new()); + counters.pending.store(true, Ordering::SeqCst); + let provider: Arc = Arc::new(AiCodexAppServerProvider::new( + registration("1.0.0"), + pool(counters.clone(), 1, 2), + )); + let request = model_request(); + let context = provider_context("profile-1", &request); + let binding = context + .run_binding() + .expect("test context should carry the exact run binding"); + let active = provider + .stream(request, context) + .await + .expect("provider turn should start"); + assert_eq!( + provider + .interrupt_run(&binding) + .await + .expect("interrupt should dispatch"), + AiProviderRunInterruptOutcome::Requested + ); + assert_eq!( + provider + .close_run(&binding, AiProviderRunCloseReason::Cancelled) + .await + .expect("close should dispatch"), + AiProviderRunCloseOutcome::Closed + ); + drop(active); + assert_eq!(counters.interrupts.load(Ordering::SeqCst), 1); + assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); + assert_eq!(counters.kills.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn registration_swap_and_concurrent_turn_fail_closed() { + let counters = Arc::new(Counters::new()); + counters.pending.store(true, Ordering::SeqCst); + let pool = pool(counters, 2, 4); + let binding = binding(); + let active = pool + .start_fresh_turn(binding, registration("1.0.0"), turn()) + .await + .expect("first turn should start"); + assert!(matches!( + pool.start_fresh_turn(binding, registration("1.0.0"), turn()) + .await, + Err(ProviderError::Rejected) + )); + drop(active); + assert!(matches!( + pool.start_fresh_turn(binding, registration("2.0.0"), turn()) + .await, + Err(ProviderError::Rejected) + )); + } + + #[tokio::test] + async fn stream_failure_invalidates_process_before_retry() { + let counters = Arc::new(Counters::new()); + counters.stream_error.store(true, Ordering::SeqCst); + let pool = pool(counters.clone(), 2, 4); + let binding = binding(); + let events = pool + .start_fresh_turn(binding, registration("1.0.0"), turn()) + .await + .expect("turn should start") + .collect::>() + .await; + assert!(matches!(events.as_slice(), [Err(ProviderError::Rejected)])); + assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); + + counters.stream_error.store(false, Ordering::SeqCst); + pool.start_fresh_turn(binding, registration("1.0.0"), turn()) + .await + .expect("retry should use a replacement process") + .collect::>() + .await; + assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn capacity_and_turn_limits_apply_without_extra_launches() { + let counters = Arc::new(Counters::new()); + let pool = pool(counters.clone(), 1, 1); + let first = binding(); + pool.start_fresh_turn(first, registration("1.0.0"), turn()) + .await + .expect("first turn should start") + .collect::>() + .await; + assert!(matches!( + pool.start_fresh_turn(first, registration("1.0.0"), turn()) + .await, + Err(ProviderError::RateLimited) + )); + let second = binding(); + assert!(matches!( + pool.start_fresh_turn(second, registration("1.0.0"), turn()) + .await, + Err(ProviderError::RateLimited) + )); + assert_eq!(counters.launches.load(Ordering::SeqCst), 1); + + pool.close_run(&first, AiProviderRunCloseReason::Completed) + .await + .expect("closing the admitted run should succeed"); + pool.start_fresh_turn(second, registration("2.0.0"), turn()) + .await + .expect("rejected admission must not freeze a registration identity") + .collect::>() + .await; + assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn per_owner_admission_prevents_one_subject_from_exhausting_the_pool() { + let counters = Arc::new(Counters::new()); + let limits = AiCodexAppServerRunLimits::new( + 3, + 2, + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("test limits should validate") + .with_maximum_processes_per_owner(1) + .expect("per-owner limit should validate"); + let pool = AiCodexAppServerRunPool::new( + Arc::new(FakeFactory { + counters: counters.clone(), + }), + limits, + ); + pool.start_fresh_turn(binding_for_owner(1), registration("1.0.0"), turn()) + .await + .expect("first owner turn should start") + .collect::>() + .await; + assert!(matches!( + pool.start_fresh_turn(binding_for_owner(1), registration("1.0.0"), turn()) + .await, + Err(ProviderError::RateLimited) + )); + pool.start_fresh_turn(binding_for_owner(2), registration("1.0.0"), turn()) + .await + .expect("another owner should retain independent capacity") + .collect::>() + .await; + assert_eq!(counters.launches.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn interrupt_and_close_are_exact_and_idempotent() { + let counters = Arc::new(Counters::new()); + counters.pending.store(true, Ordering::SeqCst); + let pool = pool(counters.clone(), 1, 2); + let binding = binding(); + let active = pool + .start_fresh_turn(binding, registration("1.0.0"), turn()) + .await + .expect("turn should start"); + assert_eq!( + pool.interrupt_run(&binding) + .await + .expect("interrupt should succeed"), + AiProviderRunInterruptOutcome::Requested + ); + assert_eq!(counters.interrupts.load(Ordering::SeqCst), 1); + assert_eq!( + pool.close_run(&binding, AiProviderRunCloseReason::Cancelled) + .await + .expect("close should succeed"), + AiProviderRunCloseOutcome::Closed + ); + assert_eq!( + pool.close_run(&binding, AiProviderRunCloseReason::Cancelled) + .await + .expect("duplicate close should be inert"), + AiProviderRunCloseOutcome::NotActive + ); + assert_eq!(counters.shutdowns.load(Ordering::SeqCst), 1); + drop(active); + assert_eq!(counters.kills.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn final_pool_drop_synchronously_invokes_process_tree_kill() { let counters = Arc::new(Counters::new()); let pool = pool(counters.clone(), 1, 2); let binding = binding(); @@ -4069,6 +6297,7 @@ mod tests { .expect("frame should be UTF-8"); assert!(initialize.contains("\"method\":\"initialize\"")); assert!(!initialize.contains("experimentalApi")); + assert!(initialize.contains("\"optOutNotificationMethods\"")); assert!(thread.contains("\"ephemeral\":true")); assert!(thread.contains("\"developerInstructions\":\"trusted\"")); assert!(thread.contains("\"approvalPolicy\":\"never\"")); @@ -4076,6 +6305,71 @@ mod tests { assert!(!thread.contains("dynamicTools")); assert!(!turn.contains("trusted")); assert!(!turn.contains("\"model\"")); + assert!(turn.contains("\"summary\":\"none\"")); + } + + #[test] + fn protocol_initialization_uses_only_the_closed_notification_opt_out_profile() { + let expected_opt_outs = json!([ + "thread/status/changed", + "thread/settings/updated", + "thread/goal/cleared", + "mcpServer/startupStatus/updated", + "account/rateLimits/updated", + ]); + + let mut text_actor = + AiCodexAppServerProtocolActor::new(64 * 1024).expect("actor should validate"); + let text: Value = serde_json::from_slice( + &text_actor + .initialize("test_client", "Test Client", "1.0.0") + .expect("stable initialization should encode"), + ) + .expect("stable initialization should be JSON"); + assert_eq!( + text, + json!({ + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "test_client", + "title": "Test Client", + "version": "1.0.0", + }, + "capabilities": { + "optOutNotificationMethods": expected_opt_outs, + }, + }, + }) + ); + + let mut dynamic_actor = + AiCodexAppServerProtocolActor::new(64 * 1024).expect("actor should validate"); + let dynamic: Value = serde_json::from_slice( + &dynamic_actor + .initialize_with_dynamic_tools("test_client", "Test Client", "1.0.0") + .expect("dynamic initialization should encode"), + ) + .expect("dynamic initialization should be JSON"); + assert_eq!( + dynamic, + json!({ + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "test_client", + "title": "Test Client", + "version": "1.0.0", + }, + "capabilities": { + "experimentalApi": true, + "optOutNotificationMethods": expected_opt_outs, + }, + }, + }) + ); } #[test] @@ -4234,6 +6528,418 @@ mod tests { } } + #[test] + fn protocol_admits_only_content_free_runtime_warnings_during_a_correlated_turn() { + let mut actor = initialized_protocol_actor(); + actor + .start_fresh_thread(&turn()) + .expect("thread start should encode"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-1"}}}"#) + .expect("thread response should bind"); + actor + .accept(&thread_started_notification("thread-1")) + .expect("thread notification should bind"); + + let warning = runtime_warning_notification( + Some("thread-1"), + "Code Mode is unavailable and remains disabled.", + ); + assert!(matches!( + actor.accept(&warning), + Err(ProviderError::Rejected) + )); + + actor + .start_turn("thread-1", &turn()) + .expect("turn should begin the warning admission window"); + let inbound = actor + .accept(&warning) + .expect("correlated pending-turn warning should be admitted"); + assert!(matches!(inbound, AiCodexAppServerInbound::RuntimeWarning)); + assert_eq!( + format!("{inbound:?}"), + "AiCodexAppServerInbound::RuntimeWarning" + ); + assert!(!format!("{inbound:?}").contains("Code Mode")); + assert!(matches!( + actor.accept(&runtime_warning_notification(None, "Still unavailable.")), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + assert!(matches!( + actor.accept(&lifecycle_notification( + RUNTIME_WARNING, + json!({"threadId": null, "message": "Still safely unavailable."}), + )), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + + actor + .accept(br#"{"id":3,"result":{"turn":{"id":"turn-1"}}}"#) + .expect("turn response should bind"); + actor + .accept(&turn_started_notification("thread-1", "turn-1")) + .expect("turn notification should bind"); + assert!(matches!( + actor.accept(&runtime_warning_notification( + Some("thread-1"), + "Warning while the correlated turn is active.", + )), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + actor + .accept(&turn_completed_notification("thread-1", "turn-1")) + .expect("turn should complete"); + assert!(matches!( + actor.accept(&warning), + Err(ProviderError::Rejected) + )); + + actor + .start_turn("thread-1", &turn()) + .expect("a later turn should have an independent warning budget"); + assert!(matches!( + actor.accept(&warning), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + } + + #[test] + fn protocol_rejects_malformed_mismatched_late_or_flooding_runtime_warnings() { + let malformed = [ + br#"{"method":"warning","params":{"message":"bounded"}}"#.as_slice(), + br#"{"emittedAtMs":0,"method":"warning","params":{"message":"bounded"}}"#, + br#"{"emittedAtMs":-1,"method":"warning","params":{"message":"bounded"}}"#, + br#"{"emittedAtMs":9223372036854775808,"method":"warning","params":{"message":"bounded"}}"#, + br#"{"emittedAtMs":"1","method":"warning","params":{"message":"bounded"}}"#, + br#"{"emittedAtMs":1,"emittedAtMs":2,"method":"warning","params":{"message":"bounded"}}"#, + br#"{"emittedAtMs":1,"method":"warning","params":{"message":"bounded"},"extra":true}"#, + br#"{"emittedAtMs":1,"method":"warning","params":{}}"#, + br#"{"emittedAtMs":1,"method":"warning","params":{"message":"bounded","extra":true}}"#, + br#"{"emittedAtMs":1,"method":"warning","params":{"message":"bounded","threadId":7}}"#, + ]; + for frame in malformed { + let mut actor = active_protocol_actor(); + assert!(matches!(actor.accept(frame), Err(ProviderError::Rejected))); + } + + for message in ["", " ", "contains\ncontrol", "contains\u{7f}control"] { + let mut actor = active_protocol_actor(); + assert!(matches!( + actor.accept(&runtime_warning_notification(Some("thread-1"), message)), + Err(ProviderError::Rejected) + )); + } + let mut oversized = active_protocol_actor(); + assert!(matches!( + oversized.accept(&runtime_warning_notification( + Some("thread-1"), + &"x".repeat(MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES + 1), + )), + Err(ProviderError::Rejected) + )); + let mut mismatched = active_protocol_actor(); + assert!(matches!( + mismatched.accept(&runtime_warning_notification( + Some("thread-other"), + "bounded", + )), + Err(ProviderError::Rejected) + )); + + let mut count_limited = active_protocol_actor(); + for _ in 0..MAXIMUM_RUNTIME_WARNINGS_PER_TURN { + assert!(matches!( + count_limited.accept(&runtime_warning_notification(None, "bounded")), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + } + assert!(matches!( + count_limited.accept(&runtime_warning_notification(None, "one too many")), + Err(ProviderError::Rejected) + )); + + let mut byte_limited = active_protocol_actor(); + let maximum_message = "x".repeat(MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES); + for _ in 0..(MAXIMUM_RUNTIME_WARNING_BYTES_PER_TURN / MAXIMUM_RUNTIME_WARNING_MESSAGE_BYTES) + { + assert!(matches!( + byte_limited.accept(&runtime_warning_notification(None, &maximum_message)), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + } + assert!(matches!( + byte_limited.accept(&runtime_warning_notification(None, "overflow")), + Err(ProviderError::Rejected) + )); + } + + #[test] + fn protocol_admits_only_content_free_reasoning_item_lifecycle() { + let mut actor = active_protocol_actor(); + let started = actor + .accept(&reasoning_lifecycle_notification( + "item/started", + "reasoning-1", + json!([]), + json!([]), + )) + .expect("empty reasoning start should be admitted"); + assert_eq!( + started, + AiCodexAppServerInbound::ReasoningLifecycle { completed: false } + ); + assert_eq!( + format!("{started:?}"), + "AiCodexAppServerInbound::ReasoningLifecycle { completed: false }" + ); + let completed = actor + .accept(&reasoning_lifecycle_notification( + "item/completed", + "reasoning-1", + json!([]), + json!([]), + )) + .expect("empty reasoning completion should be admitted"); + assert_eq!( + completed, + AiCodexAppServerInbound::ReasoningLifecycle { completed: true } + ); + + for (content, summary) in [ + (json!(["hidden reasoning"]), json!([])), + (json!([]), json!(["unrequested summary"])), + (json!({}), json!([])), + (json!([]), json!({})), + ] { + let mut actor = active_protocol_actor(); + assert!(matches!( + actor.accept(&reasoning_lifecycle_notification( + "item/started", + "reasoning-1", + content, + summary, + )), + Err(ProviderError::Rejected) + )); + } + + let mut actor = active_protocol_actor(); + assert!(matches!( + actor.accept(&lifecycle_notification( + "item/started", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1, + "item": { + "id": "reasoning-1", + "type": "reasoning", + "content": [], + "summary": [], + "extra": true, + }, + }), + )), + Err(ProviderError::Rejected) + )); + } + + #[test] + fn protocol_admits_one_content_free_retained_usage_snapshot_without_recharging_it() { + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("cursor should validate"); + let mut actor = initialized_protocol_actor(); + actor + .resume_thread(&cursor, &turn()) + .expect("resume should begin a retained lifecycle"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should bind"); + let snapshot = token_usage_notification("thread-retained-1", "turn-previous"); + let inbound = actor + .accept(&snapshot) + .expect("one retained cumulative snapshot should be admitted"); + assert_eq!( + inbound, + AiCodexAppServerInbound::RetainedResumeUsageSnapshot + ); + assert_eq!( + format!("{inbound:?}"), + "AiCodexAppServerInbound::RetainedResumeUsageSnapshot" + ); + assert!(matches!( + actor.accept(&snapshot), + Err(ProviderError::Rejected) + )); + assert_eq!(actor.thread_lifecycle_phase, ThreadLifecyclePhase::Complete); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("a provider that also emits thread started remains supported"); + assert!(matches!( + actor.accept(&thread_started_notification("thread-retained-1")), + Err(ProviderError::Rejected) + )); + actor + .start_turn("thread-retained-1", &turn()) + .expect("the new turn should start"); + actor + .accept(br#"{"id":3,"result":{"turn":{"id":"turn-current"}}}"#) + .expect("turn response should bind"); + actor + .accept(&turn_started_notification( + "thread-retained-1", + "turn-current", + )) + .expect("turn notification should bind"); + assert!(matches!( + actor.accept(&token_usage_notification( + "thread-retained-1", + "turn-current", + )), + Ok(AiCodexAppServerInbound::Notification { ref method, .. }) + if method == THREAD_TOKEN_USAGE_UPDATED + )); + + let mut notification_first = initialized_protocol_actor(); + notification_first + .resume_thread(&cursor, &turn()) + .expect("resume should begin"); + assert!(matches!( + notification_first.accept(&snapshot), + Ok(AiCodexAppServerInbound::RetainedResumeUsageSnapshot) + )); + notification_first + .accept(&thread_started_notification("thread-retained-1")) + .expect("thread notification should bind first"); + notification_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("thread response should complete the lifecycle"); + + let mut snapshot_first = initialized_protocol_actor(); + snapshot_first + .resume_thread(&cursor, &turn()) + .expect("snapshot-first resume should begin"); + snapshot_first + .accept(&snapshot) + .expect("snapshot may precede the response"); + snapshot_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("response should complete snapshot-first resume"); + snapshot_first + .start_turn("thread-retained-1", &turn()) + .expect("snapshot-first resume should permit a bounded turn"); + + let mut fallback_consumed = initialized_protocol_actor(); + fallback_consumed + .resume_thread(&cursor, &turn()) + .expect("fallback fixture resume should begin"); + fallback_consumed + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should bind"); + fallback_consumed + .accept(&snapshot) + .expect("usage snapshot should make a direct turn available"); + fallback_consumed + .start_turn("thread-retained-1", &turn()) + .expect("turn start should consume the resume fallback"); + assert!(matches!( + fallback_consumed.accept(&thread_started_notification("thread-retained-1")), + Err(ProviderError::Rejected) + )); + + let mut new_thread = initialized_protocol_actor(); + new_thread + .start_persistent_empty_thread( + "model-1", + &AiCodexAppServerBootstrapInstructions::disabled(), + &[], + ) + .expect("new persistent thread should start"); + new_thread + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-new"}}}"#) + .expect("new thread response should bind"); + assert!(matches!( + new_thread.accept(&token_usage_notification("thread-new", "turn-empty")), + Err(ProviderError::Rejected) + )); + assert_eq!( + new_thread.thread_lifecycle_phase, + ThreadLifecyclePhase::AwaitingStarted + ); + new_thread + .accept(&thread_started_notification("thread-new")) + .expect("usage cannot replace new-thread lifecycle evidence"); + + let mut negative = initialized_protocol_actor(); + negative + .resume_thread(&cursor, &turn()) + .expect("negative fixture resume should begin"); + let mut invalid_usage: Value = serde_json::from_slice(&token_usage_notification( + "thread-retained-1", + "turn-previous", + )) + .expect("usage fixture should decode"); + *invalid_usage + .pointer_mut("/params/tokenUsage/last/inputTokens") + .expect("input token field should exist") = json!(-1); + assert!(matches!( + negative + .accept(&serde_json::to_vec(&invalid_usage).expect("invalid usage should encode")), + Err(ProviderError::Rejected) + )); + } + + #[test] + fn protocol_admits_runtime_warning_after_strict_retained_resume() { + let mut actor = initialized_protocol_actor(); + actor + .start_persistent_empty_thread("model-1", &trusted_bootstrap(), &[]) + .expect("persistent empty thread should encode"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("thread response should bind"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("thread notification should bind"); + actor + .start_turn("thread-retained-1", &turn()) + .expect("newly bound first turn should start directly"); + actor + .accept(br#"{"id":3,"result":{"turn":{"id":"turn-1"}}}"#) + .expect("first turn response should bind"); + actor + .accept(&turn_started_notification("thread-retained-1", "turn-1")) + .expect("first turn notification should bind"); + actor + .accept(&turn_completed_notification("thread-retained-1", "turn-1")) + .expect("first turn should complete"); + + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("cursor should validate"); + actor + .resume_thread(&cursor, &turn()) + .expect("later retained lifecycle should resume"); + actor + .accept(br#"{"id":4,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should bind"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("resume notification should bind"); + actor + .start_turn("thread-retained-1", &turn()) + .expect("resumed turn should start"); + assert!(matches!( + actor.accept(&runtime_warning_notification( + Some("thread-retained-1"), + "Code Mode remains unavailable after resume.", + )), + Ok(AiCodexAppServerInbound::RuntimeWarning) + )); + } + #[test] fn protocol_accepts_timestamped_thread_started_in_either_correlated_order() { let mut response_first = initialized_protocol_actor(); @@ -4245,41 +6951,190 @@ mod tests { .expect("thread response should bind"); let started = thread_started_notification("thread-1"); assert!(matches!( - response_first.accept(&started), - Ok(AiCodexAppServerInbound::Notification { method, params }) - if method == "thread/started" - && params.pointer("/thread/id").and_then(Value::as_str) == Some("thread-1") - && params.get("emittedAtMs").is_none() + response_first.accept(&started), + Ok(AiCodexAppServerInbound::Notification { method, params }) + if method == "thread/started" + && params.pointer("/thread/id").and_then(Value::as_str) == Some("thread-1") + && params.get("emittedAtMs").is_none() + )); + assert!(matches!( + response_first.accept(&started), + Err(ProviderError::Rejected) + )); + + let mut notification_first = initialized_protocol_actor(); + notification_first + .start_fresh_thread(&turn()) + .expect("thread start should encode"); + notification_first + .accept(&thread_started_notification("thread-2")) + .expect("notification may precede its correlated response"); + notification_first + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-2"}}}"#) + .expect("matching response should bind after the notification"); + + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("retained cursor should validate"); + let mut resumed = initialized_protocol_actor(); + resumed + .resume_thread(&cursor, &turn()) + .expect("thread resume should encode"); + resumed + .accept(&thread_started_notification("thread-retained-1")) + .expect("resume notification may precede its response"); + resumed + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should match the protected cursor"); + } + + #[test] + fn retained_actor_repeats_response_first_create_resume_and_turn_lifecycle() { + let mut actor = initialized_protocol_actor(); + actor + .start_persistent_empty_thread("model-1", &trusted_bootstrap(), &[]) + .expect("persistent create should encode"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("create response should bind"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("create notification should complete the lifecycle"); + + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("retained cursor should validate"); + actor + .resume_thread(&cursor, &turn()) + .expect("the same actor should begin a new resume lifecycle"); + actor + .accept(br#"{"id":3,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should belong to the new lifecycle"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("resume notification should complete the new lifecycle"); + actor + .start_turn("thread-retained-1", &turn()) + .expect("turn should start only after both resume observations"); + } + + #[test] + fn retained_actor_repeats_notification_first_create_and_resume_lifecycle() { + let mut actor = initialized_protocol_actor(); + actor + .start_persistent_empty_thread("model-1", &trusted_bootstrap(), &[]) + .expect("persistent create should encode"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("create notification may arrive first"); + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("retained cursor should validate"); + assert!(matches!( + actor.resume_thread(&cursor, &turn()), + Err(ProviderError::Rejected) + )); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("create response should complete the lifecycle"); + + actor + .resume_thread(&cursor, &turn()) + .expect("resume should begin a fresh observation phase"); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("resume notification may arrive first"); + actor + .accept(br#"{"id":3,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should complete the lifecycle"); + actor + .start_turn("thread-retained-1", &turn()) + .expect("turn should start after notification-first correlation"); + } + + #[test] + fn retained_actor_requires_each_lifecycle_pair_and_preserves_dynamic_definitions() { + let input = AiCodexAppServerTurnInput::try_from_dynamic_request(dynamic_model_request()) + .expect("dynamic input should validate"); + let mut actor = initialized_protocol_actor(); + actor + .start_persistent_empty_thread( + "model-1", + &AiCodexAppServerBootstrapInstructions::disabled(), + input.tools(), + ) + .expect("persistent dynamic create should encode"); + actor + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("create response should bind"); + + let cursor = + crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") + .expect("retained cursor should validate"); + assert!(matches!( + actor.resume_thread(&cursor, &input), + Err(ProviderError::Rejected) )); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("missing create notification should complete the lifecycle"); + actor + .resume_thread(&cursor, &input) + .expect("complete create lifecycle should permit exact resume"); + actor + .accept(br#"{"id":3,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("resume response should bind"); assert!(matches!( - response_first.accept(&started), + actor.resume_thread(&cursor, &input), Err(ProviderError::Rejected) )); + actor + .accept(&thread_started_notification("thread-retained-1")) + .expect("resume notification should complete the lifecycle"); - let mut notification_first = initialized_protocol_actor(); - notification_first - .start_fresh_thread(&turn()) - .expect("thread start should encode"); - notification_first - .accept(&thread_started_notification("thread-2")) - .expect("notification may precede its correlated response"); - notification_first - .accept(br#"{"id":2,"result":{"thread":{"id":"thread-2"}}}"#) - .expect("matching response should bind after the notification"); + actor + .start_turn("thread-retained-1", &input) + .expect("first retained turn should encode"); + actor + .accept(br#"{"id":4,"result":{"turn":{"id":"turn-retained-1"}}}"#) + .expect("turn response should bind"); + actor + .accept(&turn_started_notification( + "thread-retained-1", + "turn-retained-1", + )) + .expect("turn notification should bind"); + actor + .accept(&turn_completed_notification( + "thread-retained-1", + "turn-retained-1", + )) + .expect("tool-free terminal turn should complete"); - let cursor = - crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") - .expect("retained cursor should validate"); - let mut resumed = initialized_protocol_actor(); - resumed - .resume_thread(&cursor, &turn()) - .expect("thread resume should encode"); - resumed + let mut changed_request = dynamic_model_request(); + changed_request.tools[0].description = "Changed after binding.".to_owned(); + let changed_input = AiCodexAppServerTurnInput::try_from_dynamic_request(changed_request) + .expect("changed definition remains structurally valid"); + assert!(matches!( + actor.resume_thread(&cursor, &changed_input), + Err(ProviderError::Rejected) + )); + actor + .resume_thread(&cursor, &input) + .expect("second exact resume should begin after terminal turn"); + actor + .accept(br#"{"id":5,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("second resume response should bind"); + actor .accept(&thread_started_notification("thread-retained-1")) - .expect("resume notification may precede its response"); - resumed - .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) - .expect("resume response should match the protected cursor"); + .expect("second resume notification should bind"); + assert!(matches!( + actor.start_turn("thread-retained-1", &changed_input), + Err(ProviderError::InvalidRequest) + )); + actor + .start_turn("thread-retained-1", &input) + .expect("frozen definitions should remain usable"); } #[test] @@ -4355,7 +7210,13 @@ mod tests { Err(ProviderError::Rejected) )); - for status in ["idle", "systemError", "active", "unknown"] { + let mut malformed_delete = deleting_protocol_actor(); + assert!(matches!( + malformed_delete.accept(br#"{"id":3,"result":{"deleted":true}}"#), + Err(ProviderError::Rejected) + )); + + for status in ["idle", "systemError", "active", "notLoaded", "unknown"] { let mut deleting = deleting_protocol_actor(); let status = if status == "active" { json!({"type": status, "activeFlags": []}) @@ -4372,7 +7233,10 @@ mod tests { } let mut wrong_status_thread = deleting_protocol_actor(); assert!(matches!( - wrong_status_thread.accept(&thread_not_loaded_notification("thread-other")), + wrong_status_thread.accept(&thread_status_notification( + "thread-other", + json!({"type": "notLoaded"}), + )), Err(ProviderError::Rejected) )); let mut extra_status_field = deleting_protocol_actor(); @@ -4386,6 +7250,48 @@ mod tests { )), Err(ProviderError::Rejected) )); + + let mut mcp = active_protocol_actor(); + assert!(matches!( + mcp.accept(&lifecycle_notification( + "mcpServer/startupStatus/updated", + json!({ + "threadId": "thread-1", + "name": "untrusted", + "status": "ready", + "error": null, + "failureReason": null, + }), + )), + Err(ProviderError::Rejected) + )); + + let mut account_limits = active_protocol_actor(); + assert!(matches!( + account_limits.accept(&lifecycle_notification( + "account/rateLimits/updated", + json!({"rateLimits": {"primary": {"usedPercent": 1.0}}}), + )), + Err(ProviderError::Rejected) + )); + + let mut thread_settings = active_protocol_actor(); + assert!(matches!( + thread_settings.accept(&lifecycle_notification( + "thread/settings/updated", + json!({"threadId": "thread-1", "threadSettings": {"summary": "none"}}), + )), + Err(ProviderError::Rejected) + )); + + let mut thread_goal = active_protocol_actor(); + assert!(matches!( + thread_goal.accept(&lifecycle_notification( + "thread/goal/cleared", + json!({"threadId": "thread-1"}), + )), + Err(ProviderError::Rejected) + )); } #[test] @@ -4398,7 +7304,6 @@ mod tests { "collabToolCall", "webSearch", "imageView", - "reasoning", ]; for item_type in forbidden_items { let mut guard = active_protocol_actor(); @@ -4670,15 +7575,59 @@ mod tests { assert!(start.contains("\"ephemeral\":true")); assert!(start.contains("\"approvalPolicy\":\"never\"")); assert!(start.contains("\"sandbox\":\"read-only\"")); + let start_value: Value = + serde_json::from_str(start.trim()).expect("dynamic start should remain valid JSON"); + assert_eq!( + start_value.pointer("/params/environments"), + Some(&json!([])) + ); + assert_eq!( + start_value.pointer("/params/config/features.shell_tool"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/features.unified_exec"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/features.code_mode"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/features.apps"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/features.browser_use"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/features.computer_use"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/tools.update_plan.enabled"), + Some(&Value::Bool(false)) + ); + assert_eq!( + start_value.pointer("/params/config/web_search"), + Some(&Value::String("disabled".to_owned())) + ); guard .accept(br#"{"id":2,"result":{"thread":{"id":"thread-1"}}}"#) .expect("thread response should bind"); guard .accept(&thread_started_notification("thread-1")) .expect("thread notification should bind"); - guard - .start_turn("thread-1", &input) - .expect("turn request should encode"); + let turn = String::from_utf8( + guard + .start_turn("thread-1", &input) + .expect("turn request should encode"), + ) + .expect("turn frame should be UTF-8"); + let turn_value: Value = + serde_json::from_str(turn.trim()).expect("turn should remain valid JSON"); + assert_eq!(turn_value.pointer("/params/environments"), Some(&json!([]))); guard .accept(br#"{"id":3,"result":{"turn":{"id":"turn-dynamic-1"}}}"#) .expect("turn response should bind"); @@ -4690,7 +7639,7 @@ mod tests { "item/started", json!({ "item": { - "arguments": {"query": "bounded"}, + "arguments": {"Limit": 3}, "id": "call-dynamic-1", "namespace": null, "status": "inProgress", @@ -4711,10 +7660,10 @@ mod tests { )); let request = serde_json::to_vec(&json!({ - "id": 41, + "id": 0, "method": "item/tool/call", "params": { - "arguments": {"query": "bounded"}, + "arguments": {"Limit": 3}, "callId": "call-dynamic-1", "namespace": null, "threadId": "thread-1", @@ -4733,7 +7682,7 @@ mod tests { turn_id, call, } => { - assert_eq!(request_id, 41); + assert_eq!(request_id, 0); assert_eq!(thread_id, "thread-1"); assert_eq!(turn_id, "turn-dynamic-1"); call @@ -4744,7 +7693,7 @@ mod tests { .expect("result should validate"); let response = String::from_utf8( guard - .dynamic_tool_response(41, &result) + .dynamic_tool_response(0, &result) .expect("exact response should encode"), ) .expect("response should be UTF-8"); @@ -4754,7 +7703,7 @@ mod tests { "item/completed", json!({ "item": { - "arguments": {"query": "bounded"}, + "arguments": {"Limit": 3}, "contentItems": [{"type": "inputText", "text": "{\"count\":3}"}], "durationMs": 2, "id": "call-dynamic-1", @@ -4777,7 +7726,7 @@ mod tests { }) )); assert!(matches!( - guard.dynamic_tool_response(41, &result), + guard.dynamic_tool_response(0, &result), Err(ProviderError::Rejected) )); @@ -4803,7 +7752,7 @@ mod tests { "id": 43, "method": "item/tool/call", "params": { - "arguments": {"query": "bounded"}, + "arguments": {"Limit": 3}, "callId": "call-dynamic-2", "namespace": null, "threadId": "thread-other", @@ -4823,7 +7772,11 @@ mod tests { let mut empty_guard = initialized_protocol_actor(); let create = String::from_utf8( empty_guard - .start_persistent_empty_thread("codex-test-model", &[]) + .start_persistent_empty_thread( + "codex-test-model", + &AiCodexAppServerBootstrapInstructions::disabled(), + &[], + ) .expect("empty retained thread should encode"), ) .expect("frame should be UTF-8"); @@ -4848,14 +7801,21 @@ mod tests { empty_guard .delete_thread(&empty_cursor) .expect("persistent readiness thread should delete"); - empty_guard - .accept(&thread_not_loaded_notification("thread-retained-empty")) - .expect("not-loaded status may precede its delete response"); + assert!(matches!( + empty_guard.accept(&thread_status_notification( + "thread-retained-empty", + json!({"type": "notLoaded"}), + )), + Err(ProviderError::Rejected) + )); empty_guard .accept(br#"{"id":3,"result":{}}"#) .expect("persistent delete response should bind"); assert!(matches!( - empty_guard.accept(&thread_not_loaded_notification("thread-retained-empty")), + empty_guard.accept(&thread_status_notification( + "thread-retained-empty", + json!({"type": "notLoaded"}), + )), Err(ProviderError::Rejected) )); @@ -4864,13 +7824,31 @@ mod tests { let mut dynamic_create = initialized_protocol_actor(); let create = String::from_utf8( dynamic_create - .start_persistent_empty_thread("model-1", input.tools()) + .start_persistent_empty_thread( + "model-1", + &AiCodexAppServerBootstrapInstructions::disabled(), + input.tools(), + ) .expect("empty dynamic retained thread should encode"), ) .expect("frame should be UTF-8"); assert!(create.contains("\"dynamicTools\"")); assert!(create.contains("\"inventory_count\"")); assert!(!create.contains("turn/start")); + dynamic_create + .accept(br#"{"id":2,"result":{"thread":{"id":"thread-retained-1"}}}"#) + .expect("persistent dynamic create response should bind"); + dynamic_create + .accept(&thread_started_notification("thread-retained-1")) + .expect("persistent dynamic create notification should bind"); + let direct = String::from_utf8( + dynamic_create + .start_turn("thread-retained-1", &input) + .expect("newly bound persistent thread should start directly"), + ) + .expect("direct turn frame should be UTF-8"); + assert!(direct.contains("\"method\":\"turn/start\"")); + assert!(!direct.contains("thread/resume")); let cursor = crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", "thread-retained-1") @@ -4916,9 +7894,13 @@ mod tests { guard .accept(br#"{"id":3,"result":{}}"#) .expect("delete response should bind"); - guard - .accept(&thread_not_loaded_notification("thread-retained-1")) - .expect("not-loaded status may follow its delete response"); + assert!(matches!( + guard.accept(&thread_status_notification( + "thread-retained-1", + json!({"type": "notLoaded"}), + )), + Err(ProviderError::Rejected) + )); let swapped = crate::AiProviderSessionCursor::new("other.thread", "thread-retained-1") .expect("bounded swapped cursor should construct"); @@ -4932,9 +7914,9 @@ mod tests { )); } - #[test] - #[ignore = "requires an explicitly selected reviewed Codex CLI 0.147.0 binary"] - fn live_codex_0147_persistent_create_delete_uses_the_strict_actor() { + #[tokio::test(flavor = "multi_thread")] + #[ignore = "requires a reviewed Codex CLI 0.147.0 binary and disposable configured home"] + async fn live_codex_0147_bound_first_turn_then_later_resume_uses_strict_actor() { let executable = std::env::var("GRAPHQL_ORM_AI_CODEX_0147_BIN") .expect("set GRAPHQL_ORM_AI_CODEX_0147_BIN to the reviewed absolute binary path"); assert!(PathBuf::from(&executable).is_absolute()); @@ -4951,12 +7933,16 @@ mod tests { "codex-cli 0.147.0" ); - let mut process = LiveCodexProcess::launch(&executable); + let configured_home = PathBuf::from( + std::env::var_os("GRAPHQL_ORM_AI_CODEX_0147_HOME") + .expect("set GRAPHQL_ORM_AI_CODEX_0147_HOME to a disposable configured home"), + ); + let mut process = LiveCodexProcess::launch(&executable, configured_home.clone()); let mut actor = AiCodexAppServerProtocolActor::new(MAXIMUM_FRAME_BYTES).expect("actor should validate"); process.send( &actor - .initialize( + .initialize_with_dynamic_tools( "graphql_orm_ai_live_test", "GraphQL ORM AI live test", "0.147.0", @@ -4983,7 +7969,11 @@ mod tests { ); process.send( &actor - .start_persistent_empty_thread("gpt-5.6-sol", &[]) + .start_persistent_empty_thread( + "gpt-5.4", + &bootstrap_instructions(), + &[dynamic_tool()], + ) .expect("persistent thread start should encode"), ); @@ -5034,13 +8024,269 @@ mod tests { let thread_id = response_thread_id.expect("both thread lifecycle frames should arrive"); let cursor = crate::AiProviderSessionCursor::new("codex.app_server.thread.v2", thread_id) .expect("live thread cursor should validate"); + let mut live_request = dynamic_model_request(); + live_request.model = "gpt-5.4".to_owned(); + live_request.instructions.clear(); + live_request.input = vec![ModelInputBlock::Text { + text: "Call inventory_count exactly once with Limit set to 3, then report the count." + .to_owned(), + }]; + live_request.maximum_output_tokens = Some(128); + let input = AiCodexAppServerTurnInput::try_from_retained_dynamic_request( + live_request, + &bootstrap_instructions(), + ) + .expect("live retained dynamic input should validate"); + + process.send( + &actor + .start_turn(cursor.expose_to_provider_adapter(), &input) + .expect("newly bound thread should start its first turn without resume"), + ); + let mut turn_response_observed = false; + let mut turn_started_observed = false; + let mut turn_completed_observed = false; + let mut dynamic_tool_calls = 0; + for _ in 0..64 { + let frame = process.receive(); + let inbound = actor.accept(&frame).unwrap_or_else(|error| { + let envelope: Value = serde_json::from_slice(&frame) + .expect("rejected turn frame should remain valid JSON"); + let params_keys = envelope + .get("params") + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + let item = envelope.pointer("/params/item"); + let item_keys = item + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + panic!( + "retained turn frame was rejected: {error:?}; id_is_unsigned={}; method={:?}; params_keys={params_keys:?}; active_thread_matches={}; active_turn_matches={}; started_dynamic_call_count={}; item_type={:?}; item_keys={item_keys:?}", + envelope.get("id").and_then(Value::as_u64).is_some(), + envelope.get("method").and_then(Value::as_str), + envelope.pointer("/params/threadId").and_then(Value::as_str) + == actor.active_thread_id.as_deref(), + envelope.pointer("/params/turnId").and_then(Value::as_str) + == actor.active_turn_id.as_deref(), + actor.started_dynamic_calls.len(), + item.and_then(|item| item.get("type")).and_then(Value::as_str), + ); + }); + match inbound { + AiCodexAppServerInbound::Response { + method: "turn/start", + .. + } => turn_response_observed = true, + AiCodexAppServerInbound::Notification { method, .. } + if method == "turn/started" => + { + turn_started_observed = true; + } + AiCodexAppServerInbound::Notification { method, .. } + if method == "turn/completed" => + { + turn_completed_observed = true; + } + AiCodexAppServerInbound::DynamicToolCall { + request_id, call, .. + } => { + dynamic_tool_calls += 1; + let result = LiveDynamicResponder + .respond(call) + .await + .expect("live responder should bind to the exact canonical call"); + process.send( + &actor + .dynamic_tool_response(request_id, &result) + .expect("live dynamic response should encode"), + ); + } + AiCodexAppServerInbound::DynamicToolLifecycle { .. } => {} + AiCodexAppServerInbound::RuntimeWarning => {} + AiCodexAppServerInbound::ReasoningLifecycle { .. } => {} + AiCodexAppServerInbound::Notification { .. } => {} + other => panic!("unexpected retained turn frame: {other:?}"), + } + if turn_completed_observed { + break; + } + } + assert!(turn_response_observed); + assert!(turn_started_observed); + assert!(turn_completed_observed); + assert_eq!(dynamic_tool_calls, 1); + + drop(process); + let mut process = LiveCodexProcess::launch(&executable, configured_home); + let mut actor = AiCodexAppServerProtocolActor::new(MAXIMUM_FRAME_BYTES) + .expect("resume actor should validate"); + process.send( + &actor + .initialize_with_dynamic_tools( + "graphql_orm_ai_live_test", + "GraphQL ORM AI live test", + "0.147.0", + ) + .expect("resume initialize should encode"), + ); + loop { + match actor + .accept(&process.receive()) + .expect("resume initialization frame should be admitted") + { + AiCodexAppServerInbound::Response { + method: "initialize", + .. + } => break, + AiCodexAppServerInbound::RemoteControlDisabled => {} + other => panic!("unexpected resume initialization frame: {other:?}"), + } + } + process.send( + &actor + .initialized() + .expect("resume initialized notification should encode"), + ); + process.send( + &actor + .resume_thread(&cursor, &input) + .expect("later process should resume the committed thread"), + ); + let mut resume_response_observed = false; + let mut resume_notification_observed = false; + for _ in 0..8 { + let frame = process + .frames + .recv_timeout(Duration::from_secs(10)) + .unwrap_or_else(|error| { + panic!( + "resume lifecycle timed out: {error:?}; response_observed={resume_response_observed}; notification_observed={resume_notification_observed}; phase={:?}", + actor.thread_lifecycle_phase, + ) + }); + let inbound = actor.accept(&frame).unwrap_or_else(|error| { + let envelope: Value = serde_json::from_slice(&frame) + .expect("rejected resume frame should remain valid JSON"); + let keys = envelope + .as_object() + .map(|object| object.keys().cloned().collect::>()); + let params_keys = envelope + .get("params") + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + let thread_matches = envelope + .pointer("/params/threadId") + .and_then(Value::as_str) + == Some(cursor.expose_to_provider_adapter()); + let usage_keys = envelope + .pointer("/params/tokenUsage") + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + let last_keys = envelope + .pointer("/params/tokenUsage/last") + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + let total_keys = envelope + .pointer("/params/tokenUsage/total") + .and_then(Value::as_object) + .map(|object| object.keys().cloned().collect::>()); + let turn_valid = envelope + .pointer("/params/turnId") + .and_then(Value::as_str) + .is_some_and(valid_reference); + panic!( + "later resume frame was rejected: {error:?}; method={:?}; keys={keys:?}; params_keys={params_keys:?}; thread_matches={thread_matches}; turn_valid={turn_valid}; usage_keys={usage_keys:?}; last_keys={last_keys:?}; total_keys={total_keys:?}; phase={:?}; snapshot_observed={}", + envelope.get("method").and_then(Value::as_str), + actor.thread_lifecycle_phase, + actor.retained_usage_snapshot_observed, + ); + }); + match inbound { + AiCodexAppServerInbound::Response { + method: "thread/resume", + result, + .. + } => { + assert_eq!( + nested_reference(&result, "thread", "id") + .expect("resume response thread should validate"), + cursor.expose_to_provider_adapter() + ); + resume_response_observed = true; + } + AiCodexAppServerInbound::Notification { method, params } + if method == "thread/started" => + { + assert_eq!( + nested_reference(¶ms, "thread", "id") + .expect("resume notification thread should validate"), + cursor.expose_to_provider_adapter() + ); + resume_notification_observed = true; + } + AiCodexAppServerInbound::RemoteControlDisabled => {} + AiCodexAppServerInbound::RetainedResumeUsageSnapshot => { + resume_notification_observed = true; + } + other => panic!("unexpected later resume frame: {other:?}"), + } + if resume_response_observed && resume_notification_observed { + break; + } + } + assert!(resume_response_observed && resume_notification_observed); + + process.send( + &actor + .start_turn(cursor.expose_to_provider_adapter(), &input) + .expect("resumed thread should start a second turn"), + ); + let mut second_completed = false; + let mut second_dynamic_tool_calls = 0; + for _ in 0..64 { + match actor + .accept(&process.receive()) + .expect("second turn frame should be admitted") + { + AiCodexAppServerInbound::DynamicToolCall { + request_id, call, .. + } => { + second_dynamic_tool_calls += 1; + let result = LiveDynamicResponder + .respond(call) + .await + .expect("resumed responder should bind to the exact canonical call"); + process.send( + &actor + .dynamic_tool_response(request_id, &result) + .expect("second dynamic response should encode"), + ); + } + AiCodexAppServerInbound::Notification { method, .. } + if method == "turn/completed" => + { + second_completed = true + } + AiCodexAppServerInbound::Response { .. } + | AiCodexAppServerInbound::Notification { .. } + | AiCodexAppServerInbound::DynamicToolLifecycle { .. } + | AiCodexAppServerInbound::RuntimeWarning + | AiCodexAppServerInbound::ReasoningLifecycle { .. } => {} + other => panic!("unexpected second turn frame: {other:?}"), + } + if second_completed { + break; + } + } + assert!(second_completed); + assert_eq!(second_dynamic_tool_calls, 1); + process.send( &actor .delete_thread(&cursor) .expect("live readiness thread delete should encode"), ); let mut delete_response_observed = false; - let mut not_loaded_observed = false; for _ in 0..4 { let frame = process.receive(); let inbound = actor.accept(&frame).unwrap_or_else(|error| { @@ -5059,20 +8305,12 @@ mod tests { method: "thread/delete", .. } => delete_response_observed = true, - AiCodexAppServerInbound::Notification { method, params } - if method == "thread/status/changed" - && params.pointer("/status/type").and_then(Value::as_str) - == Some("notLoaded") => - { - not_loaded_observed = true; - } other => panic!("unexpected delete frame: {other:?}"), } - if delete_response_observed && not_loaded_observed { + if delete_response_observed { break; } } assert!(delete_response_observed); - assert!(not_loaded_observed); } } diff --git a/crates/graphql-orm-ai/src/providers/mock.rs b/crates/graphql-orm-ai/src/providers/mock.rs index 06eeea3f..80bde44a 100644 --- a/crates/graphql-orm-ai/src/providers/mock.rs +++ b/crates/graphql-orm-ai/src/providers/mock.rs @@ -48,6 +48,10 @@ pub struct MockProvider { background_observation: Option, #[cfg(test)] background_retrieval_failure: Option, + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + provider_session_cursor: Option, + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + provider_session_activations: Arc>>, request_count: Arc, } @@ -77,6 +81,10 @@ impl MockProvider { background_observation: None, #[cfg(test)] background_retrieval_failure: None, + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + provider_session_cursor: None, + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + provider_session_activations: Arc::new(Mutex::new(Vec::new())), request_count: Arc::new(AtomicU64::new(0)), } } @@ -112,6 +120,23 @@ impl MockProvider { self } + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + pub(crate) fn with_provider_session_cursor( + mut self, + cursor: crate::AiProviderSessionCursor, + ) -> Self { + self.provider_session_cursor = Some(cursor); + self + } + + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + pub(crate) fn provider_session_activations(&self) -> Vec { + self.provider_session_activations + .lock() + .expect("mock provider-session activation lock should remain available") + .clone() + } + #[cfg(test)] #[cfg_attr(not(feature = "provider-openai"), allow(dead_code))] pub(crate) fn with_background_submission( @@ -181,6 +206,13 @@ impl AiProvider for MockProvider { context: ProviderRequestContext, ) -> Result { context.validate_request(&self.kind, &request)?; + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + if let Some(session) = context.provider_session() { + self.provider_session_activations + .lock() + .map_err(|_| ProviderError::Unavailable)? + .push(session.activation()); + } self.request_count.fetch_add(1, Ordering::AcqRel); #[cfg(test)] let events = match &self.event_batches { @@ -198,6 +230,26 @@ impl AiProvider for MockProvider { ))) } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + async fn create_empty_session( + &self, + _binding: &crate::AiProviderRunBinding, + descriptor: &crate::AiProviderSessionDescriptor, + request: &ModelRequest, + ) -> Result { + if descriptor.provider_kind() != &self.kind || descriptor.provider_model() != request.model + { + return Err(ProviderError::Rejected); + } + #[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] + return self + .provider_session_cursor + .clone() + .ok_or(ProviderError::Unsupported); + #[cfg(not(all(test, any(feature = "sqlite", feature = "postgres"))))] + Err(ProviderError::Unsupported) + } + async fn submit_background( &self, request: ModelRequest, diff --git a/crates/graphql-orm-ai/src/tools.rs b/crates/graphql-orm-ai/src/tools.rs index b8eea71a..d741928c 100644 --- a/crates/graphql-orm-ai/src/tools.rs +++ b/crates/graphql-orm-ai/src/tools.rs @@ -7,7 +7,6 @@ use async_trait::async_trait; use graphql_orm::graphql::orm::{GraphqlOperationCatalog, GraphqlOperationKind}; use serde::{Deserialize, Serialize}; -#[cfg(any(feature = "sqlite", feature = "postgres"))] use crate::{AiApprovalRule, AiToolRisk, ModelToolDefinition}; use crate::{ AiDisclosureSchema, AiError, AiGeneratedGraphqlOperationPolicy, AiGraphqlToolManifestCatalog, @@ -225,6 +224,50 @@ impl AiToolCatalog { self.tools.values().map(|tool| &tool.descriptor) } + /// Builds one provider-facing definition from the exact registered + /// read-only descriptor. + /// + /// This is a canonical projection of catalog metadata, not a second tool + /// declaration and not authorization. The caller supplies only the + /// provider-safe alias used to correlate one model request; the stable ID, + /// description, argument schema, and fingerprint are copied from the + /// registered descriptor. Ordinary policy, current-principal, delegated + /// authority, resolver, and disclosure checks still run when a plan is + /// built and when a call executes. + /// + /// # Errors + /// + /// Returns a safe error when the tool is absent, is not an idempotent + /// read-only application query, or the provider alias is malformed. + pub fn read_only_model_definition( + &self, + id: &AiToolId, + provider_name: impl Into, + ) -> Result { + let descriptor = self.descriptor(id).ok_or(AiError::Forbidden)?; + if descriptor.operation_kind != AiToolOperationKind::Query + || descriptor.operation_domain != AiToolOperationDomain::Application + || descriptor.maturity != ToolMaturity::ReadOnly + || descriptor.risk != AiToolRisk::ReadOnly + || descriptor.approval != AiApprovalRule::None + || !descriptor.idempotent + { + return Err(AiError::Forbidden); + } + let definition = ModelToolDefinition { + tool_id: descriptor.id.as_str().to_owned(), + provider_name: provider_name.into(), + fingerprint: descriptor.fingerprint.clone(), + description: descriptor.description.clone(), + parameters: descriptor.argument_schema.clone(), + strict: true, + }; + definition.validate().map_err(|_| { + AiError::InvalidConfiguration("provider-facing tool alias is invalid".to_owned()) + })?; + Ok(definition) + } + pub(crate) fn validate_execution_request( &self, id: &AiToolId, diff --git a/crates/graphql-orm-backup/AGENTS.md b/crates/graphql-orm-backup/AGENTS.md index 35c28b82..89eea9d5 100644 --- a/crates/graphql-orm-backup/AGENTS.md +++ b/crates/graphql-orm-backup/AGENTS.md @@ -20,7 +20,8 @@ This crate is a reusable backup and restore companion for applications that use ## Rules - Keep the crate generic and reusable. -- Do not add Digitise-specific domain names, entity names, collection semantics, accession logic, record logic, media workflows, or policy assumptions. +- Do not add consumer-specific domain names, entity names, collection + semantics, record workflows, or policy assumptions. - Do not store file bytes in a database. - Prefer traits and small adapters over application-specific coupling. - Keep provider-specific code behind feature flags. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index d372e91a..79775b66 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -4,6 +4,7 @@ version = "0.21.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." +license = "MIT" readme = "README.md" repository = "https://github.com/Dastari/graphql-orm" homepage = "https://github.com/Dastari/graphql-orm/tree/main/crates/graphql-orm-macros" diff --git a/crates/graphql-orm-router-protocol/AGENTS.md b/crates/graphql-orm-router-protocol/AGENTS.md index 1deb622c..4aa1ab86 100644 --- a/crates/graphql-orm-router-protocol/AGENTS.md +++ b/crates/graphql-orm-router-protocol/AGENTS.md @@ -15,8 +15,8 @@ supersedes: [] - Keep this crate project-neutral, serializable declarations and deterministic utilities only. - Do not add Hive, Axum, a GraphQL server, database backend, `graphql-orm`, - `agql-auth`, GEMA, application types, network I/O, URL parsing, credentials, - or deployment overrides. + `agql-auth`, product-specific code, application types, network I/O, URL + parsing, credentials, or deployment overrides. - Endpoint strings are inert advertisements. Router code owns SSRF policy, DNS and network validation, credentials, and override selection. - Unknown additive fields remain compatible. New semantics that a reader must diff --git a/crates/graphql-orm-router/AGENTS.md b/crates/graphql-orm-router/AGENTS.md index da75e09a..63a3f065 100644 --- a/crates/graphql-orm-router/AGENTS.md +++ b/crates/graphql-orm-router/AGENTS.md @@ -11,7 +11,7 @@ supersedes: [] # graphql-orm-router agent guide - Keep this crate project-neutral. It must not depend on `graphql-orm`, AI, - backup, storage, GEMA, or application types. + backup, storage, a consuming product, or application types. - Keep Federation composition, Hive, planner, executor, parser, and `ArcSwap` types behind private adapters. Public errors and data must be router-owned. - Do not expose or initialize Hive JWT, S3, or `object_store` configuration. diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index d487d10a..929c7c4a 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -20,7 +20,8 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Rules - Keep the crate generic and reusable. -- Do not add Digitise-specific domain names, entity names, collection semantics, accession logic, record logic, media workflows, or policy assumptions. +- Do not add consumer-specific domain names, entity names, collection + semantics, record workflows, or policy assumptions. - Do not store file bytes in a database. - Prefer traits and small adapters over application-specific coupling. - Keep provider-specific code behind feature flags. @@ -31,7 +32,7 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Current Agent Handoff - Current crate version is `0.6.0`. -- The workspace resolves `graphql-orm` 0.19.0 and `graphql-orm-backup` 0.7.0 +- The workspace resolves `graphql-orm` 0.21.0 and `graphql-orm-backup` 0.7.0 through workspace path dependencies and one root `Cargo.lock`. Keep downstream companion packages on one reviewed monorepo revision. - `graphql-orm` owns the optional `agql-auth` integration, pinned at diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 17e624f9..1fa7fa95 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm" description = "Provider-neutral object storage primitives for graphql-orm applications" +publish = false [features] default = ["local"] diff --git a/crates/graphql-orm/tests/backend_coexistence_fixture.rs b/crates/graphql-orm/tests/backend_coexistence_fixture.rs index 363d1325..a29d28bd 100644 --- a/crates/graphql-orm/tests/backend_coexistence_fixture.rs +++ b/crates/graphql-orm/tests/backend_coexistence_fixture.rs @@ -19,11 +19,11 @@ fn sqlite_and_mssql_services_can_share_one_graphql_orm_build() { "-p", "auth-service", "-p", - "jim-service", + "legacy-service", "-p", - "fame-ai-runtime", + "ai-runtime", "--features", - "jim-service/graphql-orm-mssql-poc", + "legacy-service/graphql-orm-mssql-poc", ]) .status() .expect("run backend coexistence fixture cargo check"); diff --git a/crates/graphql-orm/tests/composite_primary_keys.rs b/crates/graphql-orm/tests/composite_primary_keys.rs index a858d168..7d6fa820 100644 --- a/crates/graphql-orm/tests/composite_primary_keys.rs +++ b/crates/graphql-orm/tests/composite_primary_keys.rs @@ -16,17 +16,17 @@ mod sqlite_fixture { )] #[graphql_entity( backend = "sqlite", - table = "jim_labour", - plural = "JimLabourEntries", - default_sort = "JimObjectType ASC, RefNo ASC, LineNum ASC" + table = "legacy_labour", + plural = "LegacyLabourEntries", + default_sort = "LegacyObjectType ASC, RefNo ASC, LineNum ASC" )] - pub struct JimLabourEntry { + pub struct LegacyLabourEntry { #[primary_key] - #[graphql(name = "JimObjectType")] - #[graphql_orm(db_column = "JimObjectType", write = false)] + #[graphql(name = "LegacyObjectType")] + #[graphql_orm(db_column = "LegacyObjectType", write = false)] #[filterable(type = "number")] #[sortable] - pub jim_object_type: i32, + pub legacy_object_type: i32, #[primary_key] #[graphql(name = "RefNo")] @@ -74,7 +74,7 @@ mod sqlite_fixture { schema_roots! { backend: "sqlite", query_custom_ops: [], - entities: [JimLabourEntry, SingleKeyRecord], + entities: [LegacyLabourEntry, SingleKeyRecord], } async fn setup_pool() -> sqlx::SqlitePool { @@ -85,17 +85,17 @@ mod sqlite_fixture { #[test] fn composite_primary_key_metadata_exposes_all_keys() { - assert_eq!(JimLabourEntry::PRIMARY_KEY, "JimObjectType"); + assert_eq!(LegacyLabourEntry::PRIMARY_KEY, "LegacyObjectType"); assert_eq!( - JimLabourEntry::PRIMARY_KEYS, - &["JimObjectType", "RefNo", "LineNum"] + LegacyLabourEntry::PRIMARY_KEYS, + &["LegacyObjectType", "RefNo", "LineNum"] ); - let metadata = ::metadata(); - assert_eq!(metadata.primary_key, "JimObjectType"); + let metadata = ::metadata(); + assert_eq!(metadata.primary_key, "LegacyObjectType"); assert_eq!( metadata.primary_keys.as_ref(), - ["JimObjectType", "RefNo", "LineNum"] + ["LegacyObjectType", "RefNo", "LineNum"] ); let primary_fields = metadata @@ -106,7 +106,7 @@ mod sqlite_fixture { .collect::>(); assert_eq!( primary_fields, - vec!["jim_object_type", "ref_no", "line_num"] + vec!["legacy_object_type", "ref_no", "line_num"] ); } @@ -122,18 +122,18 @@ mod sqlite_fixture { #[test] fn sqlite_composite_lookup_uses_ordered_qmark_conditions_and_values() { - let key = JimLabourEntryKey { - jim_object_type: 1, + let key = LegacyLabourEntryKey { + legacy_object_type: 1, ref_no: 12_345, line_num: 2, }; assert_eq!( - JimLabourEntry::__gom_key_where_clause(), - "\"JimObjectType\" = ? AND \"RefNo\" = ? AND \"LineNum\" = ?" + LegacyLabourEntry::__gom_key_where_clause(), + "\"LegacyObjectType\" = ? AND \"RefNo\" = ? AND \"LineNum\" = ?" ); assert_eq!( - JimLabourEntry::__gom_key_values(&key), + LegacyLabourEntry::__gom_key_values(&key), vec![SqlValue::Int(1), SqlValue::Int(12_345), SqlValue::Int(2)] ); } @@ -145,12 +145,12 @@ mod sqlite_fixture { let sdl = schema.sdl(); assert!(sdl.contains( - "jimLabourEntry(jimObjectType: Int!, refNo: Int!, lineNum: Int!): JimLabourEntry" + "legacyLabourEntry(legacyObjectType: Int!, refNo: Int!, lineNum: Int!): LegacyLabourEntry" )); assert!(sdl.contains("singleKeyRecord(id: String!): SingleKeyRecord")); - assert!(!sdl.contains("createJimLabourEntry(")); - assert!(!sdl.contains("updateJimLabourEntry(")); - assert!(!sdl.contains("deleteJimLabourEntry(")); + assert!(!sdl.contains("createLegacyLabourEntry(")); + assert!(!sdl.contains("updateLegacyLabourEntry(")); + assert!(!sdl.contains("deleteLegacyLabourEntry(")); assert!(sdl.contains( "createSingleKeyRecord(input: CreateSingleKeyRecordInput!): SingleKeyRecordResult!" )); @@ -165,16 +165,16 @@ mod postgres_fixture { #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq)] #[graphql_entity( backend = "postgres", - table = "jim_labour", - plural = "JimLabourEntries", - default_sort = "JimObjectType ASC, RefNo ASC, LineNum ASC" + table = "legacy_labour", + plural = "LegacyLabourEntries", + default_sort = "LegacyObjectType ASC, RefNo ASC, LineNum ASC" )] - pub struct JimLabourEntry { + pub struct LegacyLabourEntry { #[primary_key] - #[graphql(name = "JimObjectType")] - #[graphql_orm(db_column = "JimObjectType", write = false)] + #[graphql(name = "LegacyObjectType")] + #[graphql_orm(db_column = "LegacyObjectType", write = false)] #[sortable] - pub jim_object_type: i32, + pub legacy_object_type: i32, #[primary_key] #[graphql(name = "RefNo")] @@ -191,18 +191,18 @@ mod postgres_fixture { #[test] fn postgres_composite_lookup_uses_ordered_numbered_conditions() { - let key = JimLabourEntryKey { - jim_object_type: 1, + let key = LegacyLabourEntryKey { + legacy_object_type: 1, ref_no: 12_345, line_num: 2, }; assert_eq!( - JimLabourEntry::__gom_key_where_clause(), - "\"JimObjectType\" = $1 AND \"RefNo\" = $2 AND \"LineNum\" = $3" + LegacyLabourEntry::__gom_key_where_clause(), + "\"LegacyObjectType\" = $1 AND \"RefNo\" = $2 AND \"LineNum\" = $3" ); assert_eq!( - JimLabourEntry::__gom_key_values(&key), + LegacyLabourEntry::__gom_key_values(&key), vec![SqlValue::Int(1), SqlValue::Int(12_345), SqlValue::Int(2)] ); } @@ -216,16 +216,16 @@ mod mssql_fixture { #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq)] #[graphql_entity( backend = "mssql", - table = "dbo.JimLabour", - plural = "JimLabourEntries", - default_sort = "[JimObjectType] ASC, [RefNo] ASC, [LineNum] ASC" + table = "dbo.LegacyLabour", + plural = "LegacyLabourEntries", + default_sort = "[LegacyObjectType] ASC, [RefNo] ASC, [LineNum] ASC" )] - pub struct JimLabourEntry { + pub struct LegacyLabourEntry { #[primary_key] - #[graphql(name = "JimObjectType")] - #[graphql_orm(db_column = "JimObjectType", write = false)] + #[graphql(name = "LegacyObjectType")] + #[graphql_orm(db_column = "LegacyObjectType", write = false)] #[sortable] - pub jim_object_type: i32, + pub legacy_object_type: i32, #[primary_key] #[graphql(name = "RefNo")] @@ -247,31 +247,31 @@ mod mssql_fixture { schema_roots! { backend: "mssql", query_custom_ops: [], - entities: [JimLabourEntry], + entities: [LegacyLabourEntry], } #[test] fn mssql_composite_lookup_uses_ordered_tiberius_conditions() { - let key = JimLabourEntryKey { - jim_object_type: 1, + let key = LegacyLabourEntryKey { + legacy_object_type: 1, ref_no: 12_345, line_num: 2, }; assert_eq!( - JimLabourEntry::TABLE_NAME, - DatabaseBackend::Mssql.quote_identifier_path("dbo.JimLabour") + LegacyLabourEntry::TABLE_NAME, + DatabaseBackend::Mssql.quote_identifier_path("dbo.LegacyLabour") ); assert_eq!( - JimLabourEntry::PRIMARY_KEYS, - &["[JimObjectType]", "[RefNo]", "[LineNum]"] + LegacyLabourEntry::PRIMARY_KEYS, + &["[LegacyObjectType]", "[RefNo]", "[LineNum]"] ); assert_eq!( - JimLabourEntry::__gom_key_where_clause(), - "[JimObjectType] = @P1 AND [RefNo] = @P2 AND [LineNum] = @P3" + LegacyLabourEntry::__gom_key_where_clause(), + "[LegacyObjectType] = @P1 AND [RefNo] = @P2 AND [LineNum] = @P3" ); assert_eq!( - JimLabourEntry::__gom_key_values(&key), + LegacyLabourEntry::__gom_key_values(&key), vec![SqlValue::Int(1), SqlValue::Int(12_345), SqlValue::Int(2)] ); } @@ -287,7 +287,7 @@ mod mssql_fixture { let sdl = schema.sdl(); assert!(sdl.contains( - "jimLabourEntry(jimObjectType: Int!, refNo: Int!, lineNum: Int!): JimLabourEntry" + "legacyLabourEntry(legacyObjectType: Int!, refNo: Int!, lineNum: Int!): LegacyLabourEntry" )); assert!(!sdl.contains("type Mutation")); assert!(!sdl.contains("type Subscription")); diff --git a/crates/graphql-orm/tests/composite_relations.rs b/crates/graphql-orm/tests/composite_relations.rs index 1d651c74..cfc1ec1d 100644 --- a/crates/graphql-orm/tests/composite_relations.rs +++ b/crates/graphql-orm/tests/composite_relations.rs @@ -19,12 +19,12 @@ use graphql_orm::sqlx::Row; #[graphql(complex)] #[graphql_entity( backend = "sqlite", - table = "JimCardFile", - plural = "JimCardFiles", + table = "LegacyCardFile", + plural = "LegacyCardFiles", schema_policy = "external_read_only", default_sort = "CardNo ASC" )] -pub struct JimCardFile { +pub struct LegacyCardFile { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo")] @@ -44,13 +44,13 @@ pub struct JimCardFile { #[graphql(skip, name = "Contacts")] #[relation( - target = "JimCardFileContact", + target = "LegacyCardFileContact", from = "card_no", to = "CardNo", multiple, emit_fk = false )] - pub contacts: Vec, + pub contacts: Vec, } #[derive( @@ -68,12 +68,12 @@ pub struct JimCardFile { #[graphql(complex)] #[graphql_entity( backend = "sqlite", - table = "JimCardFileContacts", - plural = "JimCardFileContacts", + table = "LegacyCardFileContacts", + plural = "LegacyCardFileContacts", schema_policy = "external_read_only", default_sort = "CardNo ASC, ContNo ASC" )] -pub struct JimCardFileContact { +pub struct LegacyCardFileContact { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo")] @@ -95,13 +95,13 @@ pub struct JimCardFileContact { #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] - pub details: Vec, + pub details: Vec, } #[derive( @@ -110,12 +110,12 @@ pub struct JimCardFileContact { #[graphql(rename_fields = "PascalCase")] #[graphql_entity( backend = "sqlite", - table = "JimCardFileDetails", - plural = "JimCardFileDetails", + table = "LegacyCardFileDetails", + plural = "LegacyCardFileDetails", schema_policy = "external_read_only", default_sort = "CardNo ASC, ContNo ASC, LineNum ASC" )] -pub struct JimCardFileDetail { +pub struct LegacyCardFileDetail { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo")] @@ -149,7 +149,7 @@ pub struct JimCardFileDetail { } impl graphql_orm::graphql::loaders::BatchLoadEntity - for JimCardFileContact + for LegacyCardFileContact { fn batch_column() -> &'static str { "CardNo" @@ -164,7 +164,7 @@ impl graphql_orm::graphql::loaders::BatchLoadEntity } impl graphql_orm::graphql::loaders::BatchLoadEntity - for JimCardFileDetail + for LegacyCardFileDetail { fn batch_column() -> &'static str { "CardNo" @@ -182,7 +182,7 @@ schema_roots! { backend: "sqlite", schema_policy: "external_read_only", query_custom_ops: [], - entities: [JimCardFile, JimCardFileContact, JimCardFileDetail], + entities: [LegacyCardFile, LegacyCardFileContact, LegacyCardFileDetail], } type TestSchema = Schema; @@ -191,7 +191,7 @@ async fn setup_schema() -> Result> { let pool = sqlx::SqlitePool::connect("sqlite::memory:").await?; sqlx::query( - "CREATE TABLE JimCardFile ( + "CREATE TABLE LegacyCardFile ( CardNo INTEGER PRIMARY KEY, CardCode TEXT NOT NULL, Name TEXT NULL @@ -201,7 +201,7 @@ async fn setup_schema() -> Result> { .await?; sqlx::query( - "CREATE TABLE JimCardFileContacts ( + "CREATE TABLE LegacyCardFileContacts ( CardNo INTEGER NOT NULL, ContNo INTEGER NOT NULL, DName TEXT NULL, @@ -212,7 +212,7 @@ async fn setup_schema() -> Result> { .await?; sqlx::query( - "CREATE TABLE JimCardFileDetails ( + "CREATE TABLE LegacyCardFileDetails ( CardNo INTEGER NOT NULL, ContNo INTEGER NOT NULL, LineNum INTEGER NOT NULL, @@ -225,7 +225,7 @@ async fn setup_schema() -> Result> { .await?; for (card_no, card_code, name) in [(1001, "ACME", "Acme Pty Ltd"), (1002, "GLOB", "Globex")] { - sqlx::query("INSERT INTO JimCardFile (CardNo, CardCode, Name) VALUES (?, ?, ?)") + sqlx::query("INSERT INTO LegacyCardFile (CardNo, CardCode, Name) VALUES (?, ?, ?)") .bind(card_no) .bind(card_code) .bind(name) @@ -238,7 +238,7 @@ async fn setup_schema() -> Result> { (1001, 2, "Alex Accounts"), (1002, 1, "Bob Buyer"), ] { - sqlx::query("INSERT INTO JimCardFileContacts (CardNo, ContNo, DName) VALUES (?, ?, ?)") + sqlx::query("INSERT INTO LegacyCardFileContacts (CardNo, ContNo, DName) VALUES (?, ?, ?)") .bind(card_no) .bind(cont_no) .bind(display_name) @@ -254,7 +254,7 @@ async fn setup_schema() -> Result> { (1002, 1, 2, "Phone", "555-0201"), ] { sqlx::query( - "INSERT INTO JimCardFileDetails (CardNo, ContNo, LineNum, Type, Value) + "INSERT INTO LegacyCardFileDetails (CardNo, ContNo, LineNum, Type, Value) VALUES (?, ?, ?, ?, ?)", ) .bind(card_no) @@ -282,7 +282,7 @@ async fn nested_composite_relations_batch_without_n_plus_one() .execute( r#" query { - jimCardFiles(orderBy: [{ CardNo: ASC }]) { + legacyCardFiles(orderBy: [{ CardNo: ASC }]) { edges { node { CardNo @@ -321,7 +321,7 @@ async fn nested_composite_relations_batch_without_n_plus_one() assert!(response.errors.is_empty(), "{:?}", response.errors); let data = response.data.into_json()?; - let cards = data["jimCardFiles"]["edges"].as_array().unwrap(); + let cards = data["legacyCardFiles"]["edges"].as_array().unwrap(); assert_eq!(cards.len(), 2); let first_contacts = cards[0]["node"]["Contacts"]["edges"].as_array().unwrap(); @@ -364,7 +364,7 @@ fn composite_relation_predicates_render_for_all_backends() { let sqlite = render_select_query( DatabaseBackend::Sqlite, &SelectQuery { - table: "JimCardFileDetails", + table: "LegacyCardFileDetails", columns: vec!["*".to_string()], filter: Some(relation_key_filter(&["CardNo", "ContNo"], &keys)), sorts: Vec::new(), @@ -382,7 +382,7 @@ fn composite_relation_predicates_render_for_all_backends() { let postgres = render_select_query( DatabaseBackend::Postgres, &SelectQuery { - table: "\"JimCardFileDetails\"", + table: "\"LegacyCardFileDetails\"", columns: vec!["*".to_string()], filter: Some(relation_key_filter(&["\"CardNo\"", "\"ContNo\""], &keys)), sorts: Vec::new(), @@ -397,7 +397,7 @@ fn composite_relation_predicates_render_for_all_backends() { let mssql = render_select_query( DatabaseBackend::Mssql, &SelectQuery { - table: "[dbo].[JimCardFileDetails]", + table: "[dbo].[LegacyCardFileDetails]", columns: vec!["*".to_string()], filter: Some(relation_key_filter(&["[CardNo]", "[ContNo]"], &keys)), sorts: Vec::new(), diff --git a/crates/graphql-orm/tests/compound_foreign_keys.rs b/crates/graphql-orm/tests/compound_foreign_keys.rs index c6d42d8f..87673410 100644 --- a/crates/graphql-orm/tests/compound_foreign_keys.rs +++ b/crates/graphql-orm/tests/compound_foreign_keys.rs @@ -417,19 +417,19 @@ async fn fresh_sqlite_schema_round_trips_compound_fk_checks_and_descending_index .apply_migration(&plan, ApplyOptions::default()) .await?; - insert_snapshot(&database, "fame", "tenant-a", 1, "digest-a").await?; - insert_snapshot(&database, "fame", "tenant-a", 2, "digest-a") + insert_snapshot(&database, "edge", "tenant-a", 1, "digest-a").await?; + insert_snapshot(&database, "edge", "tenant-a", 2, "digest-a") .await .expect_err("digest uniqueness must remain partitioned by provider and tenant"); - insert_snapshot(&database, "fame", "tenant-b", 2, "digest-a").await?; - insert_snapshot(&database, "fame", "tenant-c", 0, "digest-zero") + insert_snapshot(&database, "edge", "tenant-b", 2, "digest-a").await?; + insert_snapshot(&database, "edge", "tenant-c", 0, "digest-zero") .await .expect_err("generation must remain positive"); graphql_orm::sqlx::query( "INSERT INTO decommish_snapshot_records (provider, tenant_key, tenant_id, generation, schema_version, record_count, serialized_bytes, digest, payload, created_at) - VALUES ('fame', 'tenant-c', NULL, 1, 1, -1, 0, 'digest-negative', '{}', 'now')", + VALUES ('edge', 'tenant-c', NULL, 1, 1, -1, 0, 'digest-negative', '{}', 'now')", ) .execute(database.pool()) .await @@ -438,7 +438,7 @@ async fn fresh_sqlite_schema_round_trips_compound_fk_checks_and_descending_index "INSERT INTO decommish_snapshot_records (provider, tenant_key, tenant_id, generation, schema_version, record_count, serialized_bytes, digest, payload, created_at) - VALUES ('fame', 'tenant-d', NULL, 1, 1, 0, -1, 'digest-negative', '{}', 'now')", + VALUES ('edge', 'tenant-d', NULL, 1, 1, 0, -1, 'digest-negative', '{}', 'now')", ) .execute(database.pool()) .await @@ -467,16 +467,16 @@ async fn fresh_sqlite_schema_round_trips_compound_fk_checks_and_descending_index } #[tokio::test] -async fn exact_gema_legacy_schema_is_recorded_without_ddl_or_row_loss() -> graphql_orm::Result<()> { +async fn exact_legacy_schema_is_recorded_without_ddl_or_row_loss() -> graphql_orm::Result<()> { let database = legacy_database().await?; - insert_snapshot(&database, "fame", "tenant-a", 1, "digest-a").await?; - insert_outbox(&database, "fame", "tenant-a", 1, "digest-a").await?; + insert_snapshot(&database, "edge", "tenant-a", 1, "digest-a").await?; + insert_outbox(&database, "edge", "tenant-a", 1, "digest-a").await?; let entities = snapshot_entities(); let plan = database .schema() .plan_migration_to_entities( "snapshot-adopt-v1", - "adopt exact GEMA snapshot schema", + "adopt exact host application snapshot schema", &entities, ) .await?; @@ -543,12 +543,12 @@ async fn exact_gema_legacy_schema_is_recorded_without_ddl_or_row_loss() -> graph async fn compound_fk_rejects_partial_matches_and_cascades_only_exact_tuple() -> graphql_orm::Result<()> { let database = legacy_database().await?; - insert_snapshot(&database, "fame-a", "tenant-a", 1, "digest-a").await?; - insert_snapshot(&database, "fame-b", "tenant-b", 2, "digest-b").await?; - insert_outbox(&database, "fame-a", "tenant-a", 1, "digest-a").await?; - insert_outbox(&database, "fame-b", "tenant-b", 2, "digest-b").await?; + insert_snapshot(&database, "edge-a", "tenant-a", 1, "digest-a").await?; + insert_snapshot(&database, "edge-b", "tenant-b", 2, "digest-b").await?; + insert_outbox(&database, "edge-a", "tenant-a", 1, "digest-a").await?; + insert_outbox(&database, "edge-b", "tenant-b", 2, "digest-b").await?; - let mismatch = insert_outbox(&database, "fame-a", "tenant-b", 2, "bad") + let mismatch = insert_outbox(&database, "edge-a", "tenant-b", 2, "bad") .await .expect_err("partial tuple must not satisfy the foreign key"); assert!( @@ -561,7 +561,7 @@ async fn compound_fk_rejects_partial_matches_and_cascades_only_exact_tuple() "DELETE FROM decommish_snapshot_records WHERE provider = ? AND tenant_key = ? AND generation = ?", ) - .bind("fame-a") + .bind("edge-a") .bind("tenant-a") .bind(1_i64) .execute(database.pool()) @@ -572,7 +572,7 @@ async fn compound_fk_rejects_partial_matches_and_cascades_only_exact_tuple() .fetch_all(database.pool()) .await?; assert_eq!(rows.len(), 1); - assert_eq!(rows[0].try_get::("provider")?, "fame-b"); + assert_eq!(rows[0].try_get::("provider")?, "edge-b"); assert_eq!(rows[0].try_get::("tenant_key")?, "tenant-b"); assert_eq!(rows[0].try_get::("generation")?, 2); let violations = graphql_orm::sqlx::query("PRAGMA foreign_key_check") diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/.gitignore b/crates/graphql-orm/tests/fixtures/backend-coexistence/.gitignore index 1e7caa9e..2f7896d1 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/.gitignore +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/.gitignore @@ -1,2 +1 @@ -Cargo.lock target/ diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock new file mode 100644 index 00000000..59e48a21 --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -0,0 +1,3888 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "agql-auth" +version = "0.14.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=413fda3435f060604cd653c11e2cc18a668aace1#413fda3435f060604cd653c11e2cc18a668aace1" +dependencies = [ + "argon2", + "async-graphql", + "async-trait", + "base64 0.22.1", + "data-encoding", + "hmac", + "jsonwebtoken", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "serde_json", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.20", + "time", + "uuid", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "ai-runtime" +version = "0.1.0" +dependencies = [ + "graphql-orm-ai", + "graphql-orm-ai-tool-profiles", + "legacy-service", + "serde_json", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.9", + "stable_deref_trait", +] + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-io", + "async-trait", + "asynk-strim", + "base64 0.22.1", + "bytes", + "fast_chemail", + "fnv", + "futures-channel", + "futures-util", + "handlebars", + "http", + "indexmap", + "lru", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "async-graphql-derive" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling 0.23.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "auth-service" +version = "0.1.0" +dependencies = [ + "agql-auth", + "async-graphql", + "graphql-orm", + "serde", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.9", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array 0.14.9", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array 0.14.9", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "float_next_after" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37007738a80ea34f969af54a3390dd72cacdef654974cfd449c9f6f72dbaac10" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "geo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30eb1fdc57c1e5cfd11826fe0caec4b9dc7901f3758263bb506228d88c8d9e9a" +dependencies = [ + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "rand 0.10.2", + "rand_pcg", + "robust", + "rstar 0.12.2", + "sif-itree", +] + +[[package]] +name = "geo-types" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" +dependencies = [ + "approx", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.13.0", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "geojson" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510c094bfc76ea34d02eee00833254945b70491d79a9c0b050abed6eaa799ffb" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinyvec", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "graphql-orm" +version = "0.21.0" +dependencies = [ + "agql-auth", + "async-graphql", + "chrono", + "futures", + "geo", + "geo-types", + "geojson", + "graphql-orm-macros", + "graphql-orm-operation-catalog", + "serde", + "serde_json", + "sha2", + "sqlx", + "tiberius", + "tokio", + "tokio-stream", + "tokio-util", + "uuid", +] + +[[package]] +name = "graphql-orm-ai" +version = "0.73.3" +dependencies = [ + "agql-auth", + "async-graphql", + "async-graphql-parser", + "async-stream", + "async-trait", + "futures", + "graphql-orm", + "graphql-orm-ai-tool-profiles", + "graphql-orm-storage", + "hex", + "jsonschema", + "secrecy", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "graphql-orm-ai-tool-profiles" +version = "0.3.0" +dependencies = [ + "async-graphql", + "async-graphql-parser", + "graphql-orm-operation-catalog", + "hex", + "jsonschema", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", +] + +[[package]] +name = "graphql-orm-macros" +version = "0.21.0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "graphql-orm-operation-catalog" +version = "0.1.0" +dependencies = [ + "serde", + "sha2", +] + +[[package]] +name = "graphql-orm-storage" +version = "0.6.0" +dependencies = [ + "async-trait", + "bytes", + "futures-core", + "futures-util", + "serde", + "sha2", + "thiserror 2.0.20", + "time", + "uuid", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "handlebars" +version = "6.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.9", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "i_float" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813145bb0ad5b60f55cbbf3c74cdceda1c0a9d253b35c4cc36ae0df7887cb78f" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d122b937fca067feb0ad74f62388920272b27c356d4df2d0cfdd59e044cf0" + +[[package]] +name = "i_overlay" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd314b4668e2b3a12508f2e125558c82a6c0a8636fa5107a900f79ce414e450" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", +] + +[[package]] +name = "i_shape" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa9eac533d7509a8ab87672b60ac610c17240f9ea4851d26227689fdfe349c8" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4804bdc1dc124eb7e1aa9e144ecc04096bcf787a10a15fa44af682b51f0f6cce" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281c43ff06dcb331e9356d30e38853d559ce3d0a3f693e0b0e102667dec14fb1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0b351864e7ffbc5db9273daf7fa1b4d5177b0946713d667ca571b83c0b4045" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.7", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "legacy-service" +version = "0.1.0" +dependencies = [ + "async-graphql", + "graphql-orm", + "graphql-orm-ai-tool-profiles", + "serde", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-modular" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.9", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sif-itree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array 0.14.9", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "serde_core", + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.toml b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.toml index 0ff6d9a6..00003079 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.toml +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["auth-service", "fame-ai-runtime", "jim-service"] +members = ["auth-service", "ai-runtime", "legacy-service"] resolver = "3" diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/Cargo.toml b/crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/Cargo.toml similarity index 72% rename from crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/Cargo.toml rename to crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/Cargo.toml index 1ff8ed64..381e9fab 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/Cargo.toml +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "fame-ai-runtime" +name = "ai-runtime" version = "0.1.0" edition = "2024" [dependencies] graphql-orm-ai = { path = "../../../../../graphql-orm-ai", default-features = false, features = ["sqlite"] } graphql-orm-ai-tool-profiles = { path = "../../../../../graphql-orm-ai-tool-profiles" } -jim-service = { path = "../jim-service", features = ["graphql-orm-mssql-poc"] } +legacy-service = { path = "../legacy-service", features = ["graphql-orm-mssql-poc"] } serde_json = "1" diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/src/lib.rs b/crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/src/lib.rs similarity index 86% rename from crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/src/lib.rs rename to crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/src/lib.rs index bc746b69..78db75d4 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/fame-ai-runtime/src/lib.rs +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/ai-runtime/src/lib.rs @@ -6,7 +6,7 @@ mod tests { #[test] fn mssql_producer_and_sqlite_runtime_share_the_canonical_manifest() { - let (sdl, produced) = jim_service::ai_tool_manifest().expect("compile Jim manifest"); + let (sdl, produced) = legacy_service::ai_tool_manifest().expect("compile Legacy manifest"); let payload = produced.extension_payload().expect("encode extension"); let decoded = AiGraphqlToolManifest::from_extension_payload(payload) .expect("SQLite runtime decodes canonical producer payload"); @@ -19,7 +19,7 @@ mod tests { let set = AiGraphqlToolManifestSet::aggregate( [decoded], - &BTreeMap::from([("jim-service".to_owned(), sdl)]), + &BTreeMap::from([("legacy-service".to_owned(), sdl)]), ) .expect("aggregate exact active schema"); let mut catalog = AiToolCatalog::new(); diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/Cargo.toml b/crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/Cargo.toml similarity index 95% rename from crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/Cargo.toml rename to crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/Cargo.toml index f9ddae7b..3e770fa4 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/Cargo.toml +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "jim-service" +name = "legacy-service" version = "0.1.0" edition = "2024" diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/src/lib.rs b/crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/src/lib.rs similarity index 81% rename from crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/src/lib.rs rename to crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/src/lib.rs index d01d61d9..cdb75efa 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/jim-service/src/lib.rs +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/legacy-service/src/lib.rs @@ -3,8 +3,8 @@ use graphql_orm::prelude::*; use graphql_orm_ai_tool_profiles::{ AiDisclosureRule, AiDisclosureSchema, AiDisclosureShape, AiGeneratedGraphqlOperationPolicy, AiGraphqlArgumentPlan, AiGraphqlArgumentValue, AiGraphqlProfileInput, AiGraphqlSelection, - AiGraphqlToolManifest, AiGraphqlToolManifestBuilder, AiGraphqlToolProfile, - DataClassification, GraphqlExecutionTargetId, + AiGraphqlToolManifest, AiGraphqlToolManifestBuilder, AiGraphqlToolProfile, DataClassification, + GraphqlExecutionTargetId, }; #[derive( @@ -42,7 +42,13 @@ pub struct Job { pub is_active: bool, #[graphql(skip)] - #[relation(target = "JobLabour", from = "job_id", to = "JobId", multiple, emit_fk = false)] + #[relation( + target = "JobLabour", + from = "job_id", + to = "JobId", + multiple, + emit_fk = false + )] pub labour_entries: Vec, } @@ -105,12 +111,12 @@ pub struct JobLabour { #[graphql(complex)] #[graphql_entity( backend = "mssql", - table = "dbo.JimCardFile", - plural = "JimCardFiles", + table = "dbo.LegacyCardFile", + plural = "LegacyCardFiles", schema_policy = "external_read_only", default_sort = "[CardNo] ASC" )] -pub struct JimCardFile { +pub struct LegacyCardFile { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo", write = false)] @@ -130,13 +136,13 @@ pub struct JimCardFile { #[graphql(skip, name = "Contacts")] #[relation( - target = "JimCardFileContact", + target = "LegacyCardFileContact", from = "card_no", to = "CardNo", multiple, emit_fk = false )] - pub contacts: Vec, + pub contacts: Vec, } #[derive( @@ -153,12 +159,12 @@ pub struct JimCardFile { #[graphql(complex)] #[graphql_entity( backend = "mssql", - table = "dbo.JimCardFileContacts", - plural = "JimCardFileContacts", + table = "dbo.LegacyCardFileContacts", + plural = "LegacyCardFileContacts", schema_policy = "external_read_only", default_sort = "[CardNo] ASC, [ContNo] ASC" )] -pub struct JimCardFileContact { +pub struct LegacyCardFileContact { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo", write = false)] @@ -185,32 +191,25 @@ pub struct JimCardFileContact { #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] - pub details: Vec, + pub details: Vec, } -#[derive( - GraphQLEntity, - GraphQLOperations, - Clone, - Debug, - serde::Serialize, - serde::Deserialize, -)] +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] #[graphql(rename_fields = "PascalCase")] #[graphql_entity( backend = "mssql", - table = "dbo.JimCardFileDetails", - plural = "JimCardFileDetails", + table = "dbo.LegacyCardFileDetails", + plural = "LegacyCardFileDetails", schema_policy = "external_read_only", default_sort = "[CardNo] ASC, [ContNo] ASC, [LineNum] ASC" )] -pub struct JimCardFileDetail { +pub struct LegacyCardFileDetail { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo", write = false)] @@ -256,7 +255,8 @@ impl graphql_orm::graphql::loaders::BatchLoadEntity f fn batch_key_from_row( row: &graphql_orm::db::mssql::MssqlRow, ) -> Result { - row.try_get::("JobId").map(|value| value.to_string()) + row.try_get::("JobId") + .map(|value| value.to_string()) } } @@ -268,11 +268,14 @@ impl graphql_orm::graphql::loaders::BatchLoadEntity f fn batch_key_from_row( row: &graphql_orm::db::mssql::MssqlRow, ) -> Result { - row.try_get::("JobId").map(|value| value.to_string()) + row.try_get::("JobId") + .map(|value| value.to_string()) } } -impl graphql_orm::graphql::loaders::BatchLoadEntity for JimCardFileContact { +impl graphql_orm::graphql::loaders::BatchLoadEntity + for LegacyCardFileContact +{ fn batch_column() -> &'static str { "CardNo" } @@ -280,11 +283,14 @@ impl graphql_orm::graphql::loaders::BatchLoadEntity f fn batch_key_from_row( row: &graphql_orm::db::mssql::MssqlRow, ) -> Result { - row.try_get::("CardNo").map(|value| value.to_string()) + row.try_get::("CardNo") + .map(|value| value.to_string()) } } -impl graphql_orm::graphql::loaders::BatchLoadEntity for JimCardFileDetail { +impl graphql_orm::graphql::loaders::BatchLoadEntity + for LegacyCardFileDetail +{ fn batch_column() -> &'static str { "CardNo" } @@ -292,23 +298,24 @@ impl graphql_orm::graphql::loaders::BatchLoadEntity f fn batch_key_from_row( row: &graphql_orm::db::mssql::MssqlRow, ) -> Result { - row.try_get::("CardNo").map(|value| value.to_string()) + row.try_get::("CardNo") + .map(|value| value.to_string()) } } #[derive(Clone, Debug, SimpleObject)] #[graphql(rename_fields = "PascalCase")] -pub struct JimComment { +pub struct LegacyComment { pub line_no: i32, pub comment: String, } #[derive(Clone, Copy, Debug, Default)] -pub struct JimCustomQuery; +pub struct LegacyCustomQuery; #[Object(rename_fields = "PascalCase", rename_args = "PascalCase")] -impl JimCustomQuery { - async fn jim_work_item_comments(&self, job_no: String, first: i32) -> Vec { +impl LegacyCustomQuery { + async fn legacy_work_item_comments(&self, job_no: String, first: i32) -> Vec { let _ = (job_no, first); Vec::new() } @@ -318,29 +325,28 @@ schema_roots! { backend: "mssql", schema_policy: "external_read_only", query_custom_ops: [], - extra_query_types: [JimCustomQuery], - entities: [Job, JobLabour, JimCardFile, JimCardFileContact, JimCardFileDetail], + extra_query_types: [LegacyCustomQuery], + entities: [Job, JobLabour, LegacyCardFile, LegacyCardFileContact, LegacyCardFileDetail], } pub fn build_schema( pool: graphql_orm::db::mssql::MssqlPool, ) -> graphql_orm::async_graphql::Schema { - schema_builder(graphql_orm::db::Database::::new(pool)).finish() + schema_builder(graphql_orm::db::Database::::new( + pool, + )) + .finish() } -struct AdmitJimGenerated; +struct AdmitLegacyGenerated; -impl AiGeneratedGraphqlOperationPolicy for AdmitJimGenerated { +impl AiGeneratedGraphqlOperationPolicy for AdmitLegacyGenerated { fn is_application_operation(&self, operation: &GraphqlResolverOperationDescriptor) -> bool { operation.entity_name() == "Job" } } -fn disclosure( - version: &str, - root: &str, - root_shape: AiDisclosureShape, -) -> AiDisclosureSchema { +fn disclosure(version: &str, root: &str, root_shape: AiDisclosureShape) -> AiDisclosureSchema { let rule = AiDisclosureRule::exportable(DataClassification::Confidential); AiDisclosureSchema::new( version, @@ -354,8 +360,7 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { config.host("fixture.invalid"); config.port(1433); config.authentication(graphql_orm::tiberius::AuthMethod::sql_server( - "fixture", - "fixture", + "fixture", "fixture", )); let pool = graphql_orm::db::mssql::MssqlPool::new(config); let sdl = build_schema(pool).sdl(); @@ -375,13 +380,13 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { let generated = AiGraphqlToolProfile::read_only( "details", operation.field_name(), - "Show a reviewed subset of one visible Jim job", + "Show a reviewed subset of one visible Legacy job", vec![ AiGraphqlSelection::scalar("jobId"), AiGraphqlSelection::scalar("jobName"), ], disclosure( - "jim-job-details-v1", + "legacy-job-details-v1", operation.field_name(), AiDisclosureShape::object( rule, @@ -396,7 +401,7 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { ) .with_inputs([AiGraphqlProfileInput::integer( "JobNo", - "Public Jim job number", + "Public Legacy job number", true, 1, i64::from(i32::MAX), @@ -407,15 +412,15 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { )]); let custom = AiGraphqlToolProfile::read_only( "comments", - "JimWorkItemComments", - "List a bounded reviewed set of comments for one Jim work item", + "LegacyWorkItemComments", + "List a bounded reviewed set of comments for one Legacy work item", vec![ AiGraphqlSelection::scalar("LineNo"), AiGraphqlSelection::scalar("Comment"), ], disclosure( - "jim-comments-v1", - "JimWorkItemComments", + "legacy-comments-v1", + "LegacyWorkItemComments", AiDisclosureShape::list( rule, 25, @@ -433,7 +438,7 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { ) .with_root_list_bound(25) .with_inputs([ - AiGraphqlProfileInput::string("JobNo", "Public Jim job number", true, 1, 64), + AiGraphqlProfileInput::string("JobNo", "Public Legacy job number", true, 1, 64), AiGraphqlProfileInput::integer("Limit", "Maximum comment count", true, 1, 25), ]) .with_arguments([ @@ -441,11 +446,12 @@ pub fn ai_tool_manifest() -> Result<(String, AiGraphqlToolManifest), String> { AiGraphqlArgumentPlan::new("First", AiGraphqlArgumentValue::input("Limit")), ]); - let target = GraphqlExecutionTargetId::parse("jim-graph").map_err(|error| error.to_string())?; - let mut builder = AiGraphqlToolManifestBuilder::new("jim-service", target, &sdl) + let target = + GraphqlExecutionTargetId::parse("legacy-graph").map_err(|error| error.to_string())?; + let mut builder = AiGraphqlToolManifestBuilder::new("legacy-service", target, &sdl) .map_err(|error| error.to_string())?; builder - .add_generated_profile(generated, catalog, &AdmitJimGenerated) + .add_generated_profile(generated, catalog, &AdmitLegacyGenerated) .map_err(|error| error.to_string())?; builder .add_custom_profile(custom) diff --git a/docs/README.md b/docs/README.md index 886dc88b..566be432 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,8 +67,7 @@ Investigations and incident evidence are archived rather than deleted. Temporary agent/session material belongs in the ignored `.handoff/` directory. The complete policy and exceptions are defined by -[ADR-0001](decisions/ADR-0001-documentation-authority-and-lifecycle.md). The -[disposition inventory](document-inventory.md) records the 2026 cleanup. +[ADR-0001](decisions/ADR-0001-documentation-authority-and-lifecycle.md). ## Required metadata diff --git a/docs/architecture/system-context.md b/docs/architecture/system-context.md index 87d15082..c11cee09 100644 --- a/docs/architecture/system-context.md +++ b/docs/architecture/system-context.md @@ -80,3 +80,13 @@ internal dependencies. The accepted decisions in [`docs/decisions`](../decisions/README.md) define these boundaries precisely. + +## Distribution boundary + +Packages retain independent SemVer identities while one immutable workspace +release identifies the exact set tested together. Consumers pin the release's +full commit SHA. Qualified package tags and the generated release manifest +provide discoverability and machine-readable package, dependency, schema, and +wire identities. ADR-0010 and the +[workspace release process](../operations/release/process.md) define the +versioning and publication contract. diff --git a/docs/archive/2026/README.md b/docs/archive/2026/README.md index 17e0b436..22c77399 100644 --- a/docs/archive/2026/README.md +++ b/docs/archive/2026/README.md @@ -16,7 +16,8 @@ ledgers, migration prompts, provider roadmaps, and agent briefs retained by the the [documentation index](../../README.md) links authoritative material. - [Consumer monorepo migration prompt](consumer-monorepo-migration-agent-prompt.md) -- [Digitise native SMB integration brief](digitise-native-smb-integration-brief.md) +- [Documentation disposition inventory](documentation-disposition-inventory.md) +- [Native SMB host-integration brief](native-smb-integration-brief.md) - [`graphql-orm-ai` completion ledger](graphql-orm-ai-completion-ledger.md) - [`graphql-orm-ai` implementation ledger](graphql-orm-ai-implementation-ledger.md) - [`graphql-orm-backup` agent brief](graphql-orm-backup-agent-brief.md) diff --git a/docs/document-inventory.md b/docs/archive/2026/documentation-disposition-inventory.md similarity index 97% rename from docs/document-inventory.md rename to docs/archive/2026/documentation-disposition-inventory.md index 4457fb61..b7674e52 100644 --- a/docs/document-inventory.md +++ b/docs/archive/2026/documentation-disposition-inventory.md @@ -1,10 +1,10 @@ --- title: Documentation disposition inventory kind: reference -status: active +status: archived owner: workspace-maintainers last_reviewed: 2026-08-01 -review_by: 2026-11-01 +review_by: none supersedes: [] --- @@ -28,7 +28,7 @@ No first-party document is deleted in this run. Historical release, plan, investigation, prompt, and handoff material is retained under `docs/archive/` or `docs/plans/completed/`. -The source proposal also named FAME, frontend, `PrivilegedWrite`, and shell +The source proposal also named consumer UI, privileged-write, and shell substrate decisions. This repository contains none of those systems or active requirements/design documents, so the cleanup does not fabricate authority for them. The initial ADR set instead covers the equivalent durable decisions that @@ -110,7 +110,7 @@ guidance, and repaired links: | --- | --- | --- | | `crates/graphql-orm-ai/docs/completion-plan.md` | supersede/archive | `docs/archive/2026/graphql-orm-ai-completion-ledger.md` and `docs/plans/active/ai-production-readiness/README.md` | | `crates/graphql-orm-ai/docs/implementation-status.md` | split/rewrite | concise local current-state page plus `docs/archive/2026/graphql-orm-ai-implementation-ledger.md` | -| `crates/graphql-orm-backup/docs/digitise-native-smb.md` | archive | `docs/archive/2026/digitise-native-smb-integration-brief.md` | +| legacy host-specific native SMB brief | archive | `docs/archive/2026/native-smb-integration-brief.md` | | `crates/graphql-orm-backup/docs/graphql-orm-agent-brief.md` | archive | `docs/archive/2026/graphql-orm-backup-agent-brief.md` | | `crates/graphql-orm-backup/docs/plan.md` | supersede/archive | `docs/archive/2026/graphql-orm-backup-plan.md` | | `crates/graphql-orm-backup/docs/provider-roadmap.md` | supersede/archive | `docs/plans/backlog/backup-providers/README.md` plus `docs/archive/2026/graphql-orm-backup-provider-roadmap.md` | diff --git a/docs/archive/2026/graphql-orm-backup-plan.md b/docs/archive/2026/graphql-orm-backup-plan.md index b922dbe9..251f408a 100644 --- a/docs/archive/2026/graphql-orm-backup-plan.md +++ b/docs/archive/2026/graphql-orm-backup-plan.md @@ -37,7 +37,7 @@ Create a reusable backup and restore crate for applications using `graphql-orm`. - Application authentication. - Application authorization or row policy decisions. - UI or scheduling. -- Digitise-specific entity names or workflow assumptions. +- Host application-specific entity names or workflow assumptions. - Primary object storage implementation details beyond reading objects through `BackupObjectIndex`. ## Historical Initial Implementation Order diff --git a/docs/archive/2026/digitise-native-smb-integration-brief.md b/docs/archive/2026/native-smb-integration-brief.md similarity index 85% rename from docs/archive/2026/digitise-native-smb-integration-brief.md rename to docs/archive/2026/native-smb-integration-brief.md index 15ace193..b455b4eb 100644 --- a/docs/archive/2026/digitise-native-smb-integration-brief.md +++ b/docs/archive/2026/native-smb-integration-brief.md @@ -1,5 +1,5 @@ --- -title: "Digitise Native SMB Integration Brief" +title: "Native SMB host-integration brief" kind: reference status: archived owner: graphql-orm-backup-maintainers @@ -12,15 +12,15 @@ supersedes: [] > as historical context and is not guidance for reusable backup or storage > work. -# Digitise Native SMB Integration Brief +# Native SMB host-integration brief -Keep Digitise settings and policy in the host; reusable crates expose storage -and backup primitives only. +Keep application settings and policy in the host; reusable crates expose +storage and backup primitives only. - Replace `backup.smb.mountPath` for the native provider with server, port, share, optional root prefix, username, optional domain/workgroup, minimum dialect, signing/encryption requirements, and timeout fields. -- Persist the password through Digitise's encrypted secret-settings service. +- Persist the password through the host's encrypted secret-settings service. GraphQL and non-secret exports expose only `passwordConfigured`. - Build `SmbStorageConfig` from resolved settings and return `Arc` containing `BlobStoreBackupRepository` over an @@ -39,7 +39,7 @@ No `agql-auth` change is required. SMB authentication proves an identity to a remote storage server, separate from application-user authentication. The host already expresses platform-admin authorization and trusted internal execution. -Digitise currently builds its backup object index only from +Host application currently builds its backup object index only from `LocalStorageBackend`. Native SMB as a destination does not fix that independent restriction. Build the index from the configured `Arc` so full backups can read referenced objects from any supported primary provider. diff --git a/docs/decisions/ADR-0010-independent-package-and-workspace-release-identities.md b/docs/decisions/ADR-0010-independent-package-and-workspace-release-identities.md new file mode 100644 index 00000000..8b2e387c --- /dev/null +++ b/docs/decisions/ADR-0010-independent-package-and-workspace-release-identities.md @@ -0,0 +1,68 @@ +--- +title: ADR-0010 Independent package and workspace release identities +kind: decision +status: accepted +owner: workspace-maintainers +last_reviewed: 2026-08-11 +review_by: 2027-08-11 +supersedes: [] +--- + +# ADR-0010: Independent package and workspace release identities + +## Context + +The repository is a virtual Cargo workspace containing independently +consumable libraries and one executable. Packages evolve at different rates, +while consumers commonly select several packages from one reviewed Git +revision. Historical unqualified `vX.Y.Z` tags became ambiguous once package +versions diverged. A full commit SHA is precise but does not by itself provide +release notes, a tested compatibility-set identity, or artifact provenance. + +The workspace is intentionally Git-only. Registry publication is disabled, +and exact external Git dependencies are part of the reviewed source universe. + +## Decision + +Package SemVer remains independent. `graphql-orm` and +`graphql-orm-macros` stay aligned because runtime and generated code form one +compatibility boundary; no other package is forced into their version. + +Each released package version receives an immutable qualified tag in the form +`-v`. A tested repository-wide package set receives a +calendar-ordered `workspace-YYYY.MM.DD.N` release identity. The workspace +release attaches a deterministic manifest binding: + +- the exact full commit and root lockfile hash; +- every package version, qualified tag, and package source-tree identity; +- exact external Git dependencies and consumers; and +- independently versioned persistence and wire contracts. + +Consumers continue to pin the full commit SHA. Neither a package tag nor a +workspace tag replaces that requirement. + +Release publication is a protected explicit operation after the complete +release matrix passes. Tags and release assets never move. Registry +publication remains disabled. Compiled router delivery is opt-in and requires +artifact-specific distribution evidence independently of source release +approval. + +## Consequences + +- Package versions communicate package compatibility without unrelated + lockstep bumps. +- A workspace release names the exact combination tested together without + inventing a tenth Cargo-package version. +- Package-qualified tags remain unambiguous in one Git repository. +- Release manifests make source, lockfile, dependency, schema, and wire + identities machine-readable and attestable. +- A changed package cannot reuse an existing package version in a workspace + release because tag verification compares its exact source tree. +- The source-only release path remains independent of binary/container + licensing, SBOM, notice, target, and provenance approval. + +## Supersession + +A future change to unified versioning, registry distribution, mutable release +channels, or tag identity requires a later superseding ADR. Published release +identities remain immutable regardless of a later process change. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d7006fa9..50e2dcdc 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -23,6 +23,7 @@ unchanged and discoverable. - [ADR-0007: Seven-package workspace boundaries](ADR-0007-seven-package-workspace-boundaries.md) - [ADR-0008: Hive federation runtime and composition boundary](ADR-0008-hive-federation-runtime-and-composition-boundary.md) - [ADR-0009: Nine-package backend-neutral discovery boundaries](ADR-0009-nine-package-backend-neutral-discovery-boundaries.md) +- [ADR-0010: Independent package and workspace release identities](ADR-0010-independent-package-and-workspace-release-identities.md) Use the [ADR template](../templates/adr.md) for a genuinely durable decision, not for feature chronology or implementation status. diff --git a/docs/investigations/2026/router-0.1.1-source-distribution-review.md b/docs/investigations/2026/router-0.1.1-source-distribution-review.md index 993e06ae..7fe91430 100644 --- a/docs/investigations/2026/router-0.1.1-source-distribution-review.md +++ b/docs/investigations/2026/router-0.1.1-source-distribution-review.md @@ -78,7 +78,7 @@ Git revision containing this evidence satisfies ADR-0008 for that revision and channel only. No binary, container, hosted deployment, or later dependency revision is -approved by this record. GEMA and every other deployment must retain its own +approved by this record. Every deployment must retain its own artifact-derived SBOM, notices, linked-component inventory, and designated approval. diff --git a/docs/operations/README.md b/docs/operations/README.md index 07456f3f..b25f3cb7 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -12,7 +12,7 @@ supersedes: [] - [PostgreSQL test runbook](runbooks/postgres-testing.md) - [Retention-maintenance runbook](runbooks/retention-maintenance.md) -- [Release process](release/process.md) +- [Workspace versioning and release process](release/process.md) - [Incident evidence index](incident/README.md) Runbooks contain repeatable commands, prerequisites, validation, rollback, and diff --git a/docs/operations/release/process.md b/docs/operations/release/process.md index 3f60f4fc..3ceb2198 100644 --- a/docs/operations/release/process.md +++ b/docs/operations/release/process.md @@ -3,60 +3,166 @@ title: Workspace release process kind: runbook status: active owner: workspace-maintainers -last_reviewed: 2026-08-07 -review_by: 2026-11-01 +last_reviewed: 2026-08-11 +review_by: 2026-11-11 supersedes: [] --- # Workspace release process -## Preconditions +The repository uses independent package versions and immutable Git-only +workspace releases. It is a virtual Cargo workspace, not one Cargo package, so +there is deliberately no single SemVer value for the repository. -- Work from a clean branch based on the intended release baseline. -- Identify every affected package and its direct workspace dependants. -- Keep package versions, changelogs, migration guidance, and examples aligned. -- Use a reviewed full Git revision for consumers; workspace packages are not - published to crates.io. +## Release identities -## Procedure +Three identities have different purposes: -1. Classify the public/API/schema effect and update each affected package’s - `CHANGELOG.md` and `MIGRATION.md` as required by its local `AGENTS.md`. -2. Update package versions in their manifests. Do not hand-edit the generated - workspace inventory; run: +- A **development snapshot** is one full 40-character commit SHA on `main`. + It is not a release merely because it is reachable. +- A **package release** uses the package's own SemVer and a qualified tag such + as `graphql-orm-ai-v0.73.0`. A package tag never moves. +- A **workspace release** is a tested package set named + `workspace-YYYY.MM.DD.N`, for example `workspace-2026.08.11.1`. Its attached + manifest binds the source SHA, lockfile, every package source tree and + version, package tags, external Git revisions, and durable wire/schema + contract versions. + +Consumers must use the workspace release's full commit SHA in Cargo `rev`. +Tags improve discovery, comparison, and support but are not authority for a +dependency update. + +## Version policy + +Packages advance independently because they have different public contracts +and release cadence. `graphql-orm` and `graphql-orm-macros` remain aligned +because generated code and runtime support are one compatibility boundary. + +A package version changes when its public Rust API, generated code, Cargo +features, wire contract, runtime behavior, or documented compatibility changes +under that package's release rules. A workspace release does not require a new +version for an unchanged package. + +`graphql-orm-ai` additionally versions its persistent schema module. Router, +tool-manifest, and operation-assurance protocols keep their own contract +versions. The generated release manifest records these values separately from +package SemVer. + +The workspace remains deliberately unpublished on crates.io. Every member +sets `publish = false`, and `scripts/check-release-state.py` enforces that +boundary. Registry publication would be a separate distribution project, not +a side effect of this process. + +## Prepare the release commit + +1. Start from a clean branch based on current `main`. +2. Identify every changed package and direct workspace dependant. +3. Classify public API, GraphQL SDL, persistence, configuration, security, + backup/restore, provider, and operational effects. +4. Update affected package versions, `CHANGELOG.md`, `MIGRATION.md`, README, + and examples according to the package-local `AGENTS.md`. +5. Move completed work out of `docs/plans/active/`; an active plan must describe + genuine remaining implementation rather than release chronology. +6. Regenerate the package inventory after manifest changes: ```bash python3 scripts/generate-workspace-inventory.py ``` -3. Run documentation and dependency checks: +7. Run the local release metadata gates: ```bash python3 scripts/check-documentation.py python3 scripts/generate-workspace-inventory.py --check + python3 scripts/check-release-state.py scripts/check-workspace-dependencies.sh + cargo fmt --all -- --check ``` -4. Run `cargo fmt --all -- --check` and every package/backend/provider lane - required by the root and package-local `AGENTS.md` files. Never use - workspace `--all-features`; database backends are alternative profiles. -5. Run warnings-denied Clippy and Rustdoc for affected packages, plus SemVer - checks when a public surface changed. -6. For a router-containing delivery, apply the distribution boundary in - ADR-0008. Generate CycloneDX inventories from the exact router manifest, - root lockfile, target, and explicit feature lane; review non-strict license - metadata, MPL components, native/bundled components, advisories, and the - actual files in the artifact. Retain the inventory, hashes, channel, and - designated approval. A source review does not approve a binary or - container. -7. Review `git diff`, generated manifests, dependency trees, migration text, - and documentation links before committing. -8. Push the reviewed commit. A tag or publication is a separate explicit owner - action. +8. Run every package, backend, provider, Clippy, Rustdoc, SemVer, migration, + restore, and release-policy lane required by the root and package-local + instructions. Never use workspace `--all-features`; database backends are + alternative profiles. +9. Review the complete diff, dependency trees, generated schema/manifest + changes, documentation links, and migration statements. +10. In the pull request, select exactly one documentation-impact option from + the repository template. Release changes normally select + `Documentation updated`; CI rejects missing or ambiguous declarations. +11. Merge and push the reviewed release commit to `main`. Do not tag it yet. + +## Preview the release bill of materials + +The generator is deterministic for one release ID and commit: + +```bash +python3 scripts/generate-release-manifest.py \ + --release-id workspace-2026.08.11.1 \ + --ref 0123456789abcdef0123456789abcdef01234567 \ + --check-clean \ + --verify-tags \ + --output /tmp/workspace-release.json \ + --notes-output /tmp/workspace-release.md +``` + +`--verify-tags` permits an existing package tag only when that tag's package +source tree is byte-identical to the selected commit. It therefore catches a +package change that reused an already released version. + +## Publish through GitHub Actions + +Run **Workspace release** manually and supply: + +- `release_id`: the new `workspace-YYYY.MM.DD.N` identity; +- `target_ref`: the full commit SHA, which must equal current `main`; +- `prerelease`: whether the workspace release is a candidate; +- `include_router_artifact`: normally false for source-only releases; and +- `router_distribution_approval`: required when a router binary is attached. + +The protected `release` environment is the human authorization boundary. The +workflow then: + +1. proves the requested commit is current `main` and the workspace tag is new; +2. reruns documentation, dependency, package, backend, provider, Clippy, and + Rustdoc release lanes with the lockfile fixed; +3. generates the canonical JSON manifest and Markdown release notes; +4. optionally builds the approved Linux router executable and its CycloneDX + inventory; +5. hashes and attests every release asset; +6. creates package-qualified annotated tags only for versions not already + tagged, plus the annotated workspace tag, in one atomic push; and +7. publishes the GitHub Release from the existing workspace tag. + +Enable GitHub immutable releases for the repository. Prepare every required +asset before publication because neither a release tag nor an attached asset +may be replaced after publication. + +## Router artifact boundary + +Source and compiled-router distribution are distinct approvals. A router +binary may be selected only after the exact target, features, lockfile, +linked/native components, advisories, licenses, notices, SBOM, hashes, and +delivery channel have a designated approval under ADR-0008. + +The binary lane is therefore opt-in and requires an evidence reference. It +builds the explicit `auth-agql` feature profile for +`x86_64-unknown-linux-gnu`, packages the binary with the workspace license, +CycloneDX inventory, and approval reference, and includes the archive in the +release checksums and provenance attestation. A later target or feature set +requires its own approval. + +Pure Rust libraries do not receive optimized binary artifacts. Downstream +Cargo builds compile them from the pinned Git source. ## Failure and rollback -Do not release a partial version/dependency set. If a validation lane fails, -repair it on the branch and rerun affected lanes. If an already-consumed -revision is defective, create a new revision and document the migration; do -not rewrite published Git history. +- Before tags are pushed, repair the release commit and rerun the workflow. +- If validation fails, do not publish a partial package/dependency set. +- Package and workspace tags are immutable. Never force-push, move, reuse, or + delete a published release identity. +- If tagged source is defective, make a new commit, advance every affected + package version, and publish a new workspace release. +- If tag creation succeeds but GitHub Release publication fails, retain the + immutable tags, inspect the failed run, and attach the already attested + assets to a release for that exact tag. Do not regenerate from another SHA. +- Consumers roll forward to a newly reviewed full SHA; published Git history + is never rewritten. diff --git a/docs/plans/README.md b/docs/plans/README.md index f9ea2769..a2be574d 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -12,9 +12,7 @@ supersedes: [] ## Active -- [AI provider sessions, hosted tools, and visible activity](active/ai-provider-sessions-and-hosted-tools/README.md) - [AI production readiness](active/ai-production-readiness/README.md) -- [GraphQL ORM Router](active/graphql-orm-router/README.md) ## Backlog @@ -24,6 +22,8 @@ supersedes: [] ## Completed +- [AI provider sessions, hosted tools, and visible activity](completed/ai-provider-sessions-and-hosted-tools/README.md) +- [GraphQL ORM Router](completed/graphql-orm-router/README.md) - [Monorepo consolidation](completed/monorepo-consolidation/README.md) An active plan has one canonical file at diff --git a/docs/plans/active/ai-production-readiness/README.md b/docs/plans/active/ai-production-readiness/README.md index 06d15e19..bb7fd0ad 100644 --- a/docs/plans/active/ai-production-readiness/README.md +++ b/docs/plans/active/ai-production-readiness/README.md @@ -3,7 +3,7 @@ title: GraphQL ORM AI production-readiness plan kind: plan status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-01 +last_reviewed: 2026-08-12 review_by: 2026-09-01 supersedes: - crates/graphql-orm-ai/docs/completion-plan.md @@ -22,15 +22,16 @@ retention, or restore proofs remain closed. ## Non-goals - MSSQL production/write parity before the ORM owns those reusable contracts. -- Provider-persistent upload/search before creation ambiguity, pricing, quota, - cleanup, and restore are all proven. +- Provider-persistent file upload/search before creation ambiguity, pricing, + quota, cleanup, and restore are all proven. Provider-hosted public web search + is a separate implemented capability. - Parallel or autonomous consequential execution. - Deployment-owned principals, policy, credentials, routes, isolation, or product-specific mutation behavior. ## Dependencies -- `graphql-orm` 0.17 schema-module, transaction, fencing, operation-metadata, +- `graphql-orm` 0.21 schema-module, transaction, fencing, operation-metadata, and restore contracts. - `graphql-orm-backup` 0.7 snapshot, repository, verification, and restore orchestration. @@ -58,26 +59,25 @@ retention, or restore proofs remain closed. ## Current checkpoint -Monorepo consolidation and the 0.17/0.13 dependency alignment are complete. -The protected runtime, provider adapters, exact completed-batch adoption, -retention foundations, restore planning, and readiness observation contracts -exist. Database-derived collection now covers bounded conservative run -classification, approval and egress-consent revalidation candidates, and—when -host-attested deployment ceilings are supplied—complete budget-policy and -immutable pricing-catalog integrity with exact creation-audit linkage. Opaque -fact/plan digests bind those inputs. A separately configured bounded generated- -ORM pass now also proves stable attachment/artifact lifecycle, ownership, -session/message parents, and unique safe local-object-reference metadata. -Target BlobStore bytes remain a distinct fatal incomplete audit, and every -other remaining audit has a fatal explicit status. The pure plan's misleading -applied-readiness helper has been removed; -the existing host-attested compatibility gate is not restore authority. The -next implementation slice binds a verified backup manifest and restored-target -byte stream to the attachment-object audit, then scans historic protected -envelope key headers and completes the remaining durable graphs before adding -the bounded repair applier, validator, recovery epoch, and non-forgeable -readiness path. Durable tool-policy management and provider-persistent upload/ -search stay closed until this gate passes. +Package 0.75.0 and AI schema module 0.55.0 provide the protected runtime, +provider adapters, exact completed-batch adoption, retention foundations, +restore planning, and readiness observation contracts. Database-derived +collection covers bounded conservative run classification, approval and +egress-consent revalidation candidates, and—when host-attested deployment +ceilings are supplied—budget-policy and immutable pricing-catalog integrity +with exact creation-audit linkage. Opaque fact/plan digests bind those inputs. +A separately configured bounded generated-ORM pass proves stable +attachment/artifact lifecycle, ownership, session/message parents, and unique +safe local-object-reference metadata. Protected provider-session cursors are +backup-redacted and block portable restore until authoritatively drained. + +Verified manifest plus restored-target object-byte integrity remains a fatal +incomplete audit. The next implementation slice binds that byte stream to the +attachment-object audit, scans historic protected-envelope key headers, and +completes the remaining durable graphs before adding the bounded repair +applier, complete validator, recovery epoch, and non-forgeable runtime-start +proof. Durable tool-policy management and provider-persistent file upload and +file search remain closed until this gate passes. Historical slice-by-slice evidence is retained in the [archived completion ledger](../../../archive/2026/graphql-orm-ai-completion-ledger.md) diff --git a/docs/plans/active/ai-provider-sessions-and-hosted-tools/README.md b/docs/plans/active/ai-provider-sessions-and-hosted-tools/README.md deleted file mode 100644 index 05d9d497..00000000 --- a/docs/plans/active/ai-provider-sessions-and-hosted-tools/README.md +++ /dev/null @@ -1,402 +0,0 @@ ---- -title: AI provider sessions, hosted tools, and visible activity -kind: plan -status: active -owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-11 -review_by: 2026-09-11 -supersedes: [] ---- - -# AI provider sessions, hosted tools, and visible activity - -## Outcome - -`graphql-orm-ai` provides project-neutral contracts for an efficient local -provider harness, provider-hosted web search, and provider-generated visible -reasoning summaries without weakening its current authorization, egress, -disclosure, budget, or durable-run boundaries. - -The first completed path keeps one strictly allowlisted local app-server -process for every claimed run and reuses it across that run's provider turns. -Later phases may keep an owner-bound process warm and resume a protected -provider thread, but warm operating-system processes and provider-retained -threads remain independently configurable resources. - -Application tools remain owned and executed by the coordinator. A local -harness receives no application bearer token, raw router access, database -access, filesystem access, shell, browser, screenshot, remote-control, dynamic -tool registration, or general JSON-RPC capability. - -## Non-goals - -- Consumer-specific provider, policy, router, authentication, or user-interface - changes. -- A generic MCP server, arbitrary GraphQL executor, URL fetcher, shell, code - runner, filesystem bridge, or browser automation endpoint. -- Raw chain-of-thought. The only visible reasoning content is a bounded summary - deliberately emitted by a provider for presentation. -- Cross-owner process or provider-thread sharing. Multiplexing remains deferred - until a provider protocol and isolation assessment prove it safe. -- An immediate visual-browser implementation. Its broker boundary is recorded - separately for future work. -- Making hosted search automatically available. Library defaults remain deny; - a host must select a reviewed provider profile and bounded search policy. - -## Dependencies - -- Existing fenced durable runs, current-principal rehydration, cancellation, - protected content, usage ledger, budget reservations, egress manifests, - application-tool descriptors, and session/inbox replay contracts. -- Existing `AiProvider`, `ModelRequest`, `ProviderRequestContext`, provider - event, coordinator, and local-harness boundaries. -- The Codex app-server initialized protocol, thread lifecycle, turn lifecycle, - and interruption methods. Provider-specific wire handling stays behind a - strict method, notification, item, and server-request allowlist. -- Provider-hosted web-search and reasoning-summary capabilities. Unsupported - providers degrade through explicit capability negotiation. -- A schema-module increment before any new durable provider-thread or activity - records become part of the supported persistence contract. - -## Durable boundaries - -### Library responsibilities - -`graphql-orm-ai` owns: - -- provider-neutral request policy, capability negotiation, normalized events, - continuation identity, budgets, egress, durable ordering, protection, - retention, fencing, and owner-authorized replay; -- a run-scoped local-harness lifecycle that can retain one process across - multiple provider turns without sharing it between runs; -- a protected provider-thread binding containing only an opaque resume cursor - and exact host-owned binding evidence; -- durable search, citation, visible-summary, and lifecycle activity records; -- cancellation propagation and the rule that a stale process, provider thread, - or run lease cannot persist later output; -- provider-specific adapters that translate only a documented, allowlisted - protocol into provider-neutral contracts. - -### Host responsibilities - -A consuming application owns: - -- provider installation, executable identity, provider credentials, process - sandboxing, state-directory protection, and provider-profile selection; -- immutable deployment ceilings and the decision to enable hosted search, - reasoning summaries, warm processes, or provider retention; -- ordinary current-principal, tenant, row, field, tool, resolver, MFA, - approval, and delegated-authority enforcement; -- managed worker lifecycle and user-interface presentation; -- deletion of provider-owned state through the library contract when a session, - provider profile, or retention policy requires it. - -The host never receives an API that can supply an unvalidated provider cursor, -forge a watermark, bypass a run fence, add model-selected tools, or widen -egress. - -## Phase 1: claimed-run local app-server lifecycle - -Add a provider-neutral run-session interface and a provider-specific Codex -app-server adapter. One process is admitted for one fenced claimed run and is -reused for independently bounded fresh text-only provider turns in that run. -Phase 1 deliberately did not claim application-tool continuation support or -retain a Codex thread across those continuations: app-server dynamic tools are synchronous -server requests inside an in-flight turn, while the existing coordinator -executes an application tool only after the provider turn finishes. Treating -them as the same contract would deadlock or move authority into the adapter. -Dynamic tools therefore remained forbidden in that phase. The existing JSONL harness remains -the supported local stateless application-tool path, and each Phase 1 -app-server turn uses a fresh, bounded provider thread that is deleted before -reuse of the process. The -process is terminated on run completion, failure, cancellation, lost lease, -worker shutdown, protocol violation, or resource-limit expiry. - -The adapter must: - -- negotiate protocol version and supported features before readiness; -- allow only initialization, thread start, turn start, turn interruption, and - bounded thread cleanup required by this phase; -- reject every unknown method, notification, server request, item type, and - response shape; -- reject shell-command, filesystem, dynamic-tool, screenshot, browser, - arbitrary command, and generic JSON-RPC use even when the provider supports - it; -- bind every response and event to the exact session, run, provider profile, - request correlation, attempt, lease generation, and policy fingerprint; -- provide bounded startup, request, idle, shutdown, output, and protocol - limits plus global, per-profile, and per-owner admission limits; -- integrate durable cancellation with `turn/interrupt` and bounded process - termination; -- settle provider usage and durable reservations exactly once. - -Application-tool execution continues through the existing coordinator. A -provider-specific request may identify an exact reviewed tool call, but it -cannot execute the tool, select the GraphQL destination/document, or receive a -delegated credential. - -### Phase 1 acceptance - -- One exact claimed-run binding with several fresh text-only turns launches one process; each turn - gets a fresh isolated thread that is deleted before the next turn. -- Concurrent runs do not share state; resource admission is bounded and - produces stable backpressure. -- Cancellation interrupts the current turn and prevents later output or tool - execution after the cancellation fence wins. -- Unknown or forbidden app-server traffic terminates the run fail-closed. -- Crash, EOF, timeout, malformed frames, stale leases, and executable identity - changes cannot produce accepted output. -- Existing one-shot local-harness registrations and other provider adapters - remain compatible. - -## Phase 2: requested visible reasoning and ordered activity - -Add a provider-neutral, host-selected reasoning-summary request mode. Disabled -is the default. Providers advertise support separately; an unsupported provider -may omit the optional summary without making an otherwise valid run -unavailable unless a future profile explicitly requires it. - -Normalize bounded reasoning-summary deltas into protected content blocks and a -single canonical ordered activity stream with application-tool, hosted-search, -citation, assistant-output, cancellation, and terminal activity. Preserve the -provider's order while assigning library-owned durable sequence numbers. - -Summary content: - -- is explicitly described as a provider-generated visible summary, never raw - internal reasoning; -- has independent byte, token, delta, and block ceilings; -- is protected at rest, owner-authorized at read time, and subject to existing - retention and purge behavior; -- never enters ordinary logs, error strings, URLs, analytics, or unprotected - lifecycle payloads; -- may end as a bounded partial summary when a turn is cancelled or fails, with - the terminal state making that incompleteness explicit. - -### Phase 2 acceptance - -- Disabled, supported-auto, and unsupported-auto provider paths are covered. -- Streaming summary, tool/search activity, citations, output, and terminal - state replay in one authoritative order after reconnect. -- Cancellation stops later deltas and output while preserving only the already - committed protected partial summary. -- Ordinary events and logs contain no summary text. -- Existing providers that emit no summary remain source and behavior - compatible. - -## Phase 3: hosted web search with application tools - -Permit a host-authored provider builtin and reviewed application tools in one -run through the already supported provider-retained continuation contract. -Extend the app-server path only after it has a separate, explicit continuation -design that preserves coordinator-owned tool execution without using dynamic -tools. Do not remove the existing stateless-replay prohibition until stateless -mixed-tool reasoning and continuation evidence can be represented and -validated completely. - -Web-search policy is explicit rather than inferred from an empty list: - -- disabled; -- public web; or -- domain-constrained web with normalized host-authored allow/block policy when - supported by the selected provider. - -The host also supplies a maximum search-call count below immutable deployment -limits. The model cannot enable search, select the policy mode, raise limits, -or add arbitrary headers, credentials, cookies, or URLs. - -Search lifecycle and source attribution are normalized from provider-authored -events. Durable citations retain validated provider source identity and URL -metadata separately from assistant Markdown. Markdown links are never promoted -to authoritative citations. - -### Phase 3 acceptance - -- One retained run can perform bounded hosted search, execute one exact - registered application tool through the coordinator, and produce a final - answer carrying authoritative citations. -- Search and application-tool calls retain distinct egress, usage, pricing, - budget, lifecycle, and disclosure accounting. -- Search content cannot register a tool, change a GraphQL target/document, - enable another provider capability, bypass current authorization, or leak - secrets into ordinary telemetry. -- Public web is available only when explicitly selected; disabled remains the - library default; domain constraints round-trip and fail closed when unsupported. -- Reconnect and replay preserve source identities and event ordering. - -## Phase 4: protected provider-thread bindings and warming - -Persist an opaque provider-thread binding only after the run-scoped lifecycle -is stable. A binding contains protected resume material plus library-owned -evidence for: - -- exact session owner/principal reference and tenant; -- target scope; -- provider profile, provider kind, model, and executable/configuration - identity; -- relevant deployment, rule, tool-manifest, disclosure, and egress - fingerprints; -- the exact durable session message sequence/watermark represented in the - provider thread; -- creation, last use, idle expiry, absolute expiry, deletion state, and - provider-retention declaration. - -Resume is allowed only when every binding is current and the provider thread's -watermark exactly matches the authoritative durable history. A recoverable -missing-thread result may start a replacement thread and reconstruct the -bounded authoritative history. It must not silently continue from only the -newest input. Any other mismatch fails closed and requires a reviewed reset. - -An in-memory warm process is a separate cache. It may be evicted without -deleting the protected provider-thread binding, and a binding may be retained -without a warm process. Idle TTL, absolute lifetime, maximum processes, -per-owner/profile limits, upgrade draining, and deletion are independently -configured and bounded. - -### Phase 4 acceptance - -- Same-session resume succeeds only at an exact authoritative watermark. -- Cross-owner, cross-tenant, cross-scope, stale-policy, stale-tool-manifest, - model, provider-profile, or executable mismatches deny. -- Cursor and provider state deletion follows archive/delete/retention/provider - removal without resurrecting a session. -- Warm-process eviction and durable-thread deletion are independently tested. -- Restart recovery fences stale workers and never accepts output for a newer - lease. -- Multiplexing remains disabled. - -## Phase 5: optional multiplexing investigation - -Do not implement cross-session multiplexing by default. Investigate one -app-server managing several provider threads only after protocol conformance, -resource accounting, cancellation isolation, output demultiplexing, state -directory isolation, crash blast radius, and cross-owner confidentiality have -dedicated tests. Any eventual implementation must retain a library-enforced -per-thread owner/run binding and a host-configurable one-thread-per-process -mode. - -## Future visual-browser broker - -Visual browsing is a separate capability broker, not an extension of hosted -search or the local provider process. - -A future project-neutral contract may expose typed, approval-aware operations -such as navigate-to-reviewed-origin, capture-bounded-screenshot, inspect a -bounded accessibility snapshot, and perform a constrained interaction. Each -operation would carry an exact browser-session capability, origin/redirect -policy, owner and tenant binding, expiry, rate/byte limits, current-principal -check, audit correlation, and disclosure classification. - -The broker would own isolated browser contexts, network and download policy, -cookie/credential prohibition, popup and protocol filtering, visual-result -protection, and destruction on revocation or expiry. `graphql-orm-ai` would own -only the typed capability, descriptor, approval, budget, event, and protected -result contracts. A host-provided broker would perform the actual browser -automation. No generic page-evaluation script, browser cookie access, arbitrary -local file upload, bearer-token injection, or reuse of an authenticated human -browser context would be exposed. - -This work remains back-burner and is not a dependency of hosted web search. - -## Cross-phase acceptance gates - -- `cargo fmt --all -- --check`. -- Focused and full `graphql-orm-ai` tests for every affected explicit backend - and provider feature; never workspace `--all-features`. -- Warnings-denied Clippy and Rustdoc for supported feature lanes. -- Existing provider, cancellation, application-tool, protected-stream, - retention, restore, SemVer, release-policy, PascalCase, and dependency - boundary checks remain green. -- Persistence changes have migrations, rollback/failure tests, restore parity, - schema-module versioning, changelog, migration guide, and backend coverage. -- Public contracts remain project-neutral and include no consumer names, router - credentials, application resolver documents, or host-specific policy. -- Security tests cover the strict negative space: no shell, filesystem, - browser, screenshot, dynamic tool, arbitrary URL, arbitrary GraphQL, - unregistered application tool, cross-owner provider state, or hidden - chain-of-thought path. - -## Current checkpoint - -Phases 1 through 4 have project-neutral upstream contracts from the 0.69.0 -development line, the retained Codex milestone is implemented in 0.70.0, -0.71.0 corrects canonical router transport of its tool manifests, and 0.72.0 -aligns the strict adapter with Codex CLI 0.147.0 initialization. Version 0.73.0 -completes the generated lifecycle envelope and live persistent-thread probe: - -- a strict fresh-turn Codex app-server adapter with exact-run reuse, global and - per-owner admission, cancellation/terminal cleanup, protocol allowlisting, - and synchronous kill-on-drop; -- host-requested bounded visible summaries plus one protected ordered activity - stream for text, summary, hosted-tool lifecycle, and citations; -- native OpenAI provider-retained mixed hosted-search/application-tool - requests, explicit public/allow/block domain policy, exact citation - provenance, and cumulative per-run web-search rule ceilings; and -- a private protected provider-session binding service with canonical - transcript watermarks, current-principal/run fencing, exact cleanup/absence, - session-retention dependency, and fail-closed portable restore audit. - -- exact protected Codex thread create/resume/interrupt/delete, with process and - provider retention governed independently; and -- default-off experimental app-server dynamic tools that route only through - the existing coordinator-owned registered GraphQL tool boundary; and -- exact disabled-only remote-control status admission, truthful retained - continuation capability negotiation, and explicit never-approval/read-only - policy on every Codex thread create or resume; and -- strict positive signed notification timestamps, independently correlated - thread response/start ordering, and the exact deletion-bound `notLoaded` - transition observed during a live Codex CLI 0.147.0 create/delete handshake. - -Phase 5 multiplexing and the visual-browser broker remain deferred -investigations. The current review boundary is full backend/provider, -documentation, SemVer, and release-policy verification for 0.73.0 / schema -module 0.55.0. - -## Current milestone: retained Codex threads - -This development line couples the existing provider-session persistence -contract to the strict Codex app-server adapter without widening the app-server -protocol into a generic bridge. - -The milestone delivers: - -- create a persistent Codex thread with no business content, bind its protected - cursor under the exact current run, and only then begin the first turn; -- resume only an exact current owner/session/scope/profile/model/executable, - protocol, policy, transcript-watermark, attempt, and lease binding; -- use `turn/interrupt` for durable cancellation and `thread/delete` for the - existing cleanup-worker absence proof; -- keep one process per exact run while allowing the protected thread to outlive - that process under independent retention limits; -- advance the durable provider-session watermark only after protected final - assistant output, its checkpoint, and terminal run completion are committed; - a retention-only commit failure quarantines the cursor without changing the - completed answer; and -- invalidate the cursor after cancellation, transport ambiguity, stale policy, - protocol failure, or output-persistence uncertainty. - -Application-tool requests use app-server `dynamicTools` only through an -explicit experimental provider capability that is disabled by default and -bound into the registration, protocol, policy, request, and tool fingerprints. -The strict protocol actor admits only the documented `item/tool/call` server -request for an exact tool offered in the current `ModelRequest`. It forwards a -typed, bounded request through a coordinator-owned in-flight bridge; the -app-server adapter cannot answer it itself. The ordinary coordinator rechecks -the run fence, cancellation, current principal, rules, tool policy, egress, -budget, and resolver authorization, executes the exact registered GraphQL -operation, and returns only the disclosure-approved result. No bearer token, -delegated authority, raw router access, or generic request callback enters the -provider process. - -Provider/tool ambiguity remains non-replayable. Cancellation, lease loss, -protocol failure, a stale tool definition, or a failed result handoff poisons -the process, invalidates the retained cursor, and moves the run through the -existing recovery path. Experimental dynamic tools do not weaken the ordinary -non-Codex provider continuation contract. - -The milestone remains closed to hosted app-server web search, arbitrary -structured output, attachments, images, shell, filesystem, patches, MCP, -skills, collaboration, screenshots, browser control, raw reasoning, and every -server-initiated request other than the exact experimental dynamic-tool call. -Provider-hosted search continues to use the native OpenAI Responses path until -a separately reviewed Codex hosted-search protocol is available. diff --git a/docs/plans/active/graphql-orm-router/README.md b/docs/plans/active/graphql-orm-router/README.md deleted file mode 100644 index e3952afb..00000000 --- a/docs/plans/active/graphql-orm-router/README.md +++ /dev/null @@ -1,660 +0,0 @@ ---- -title: GraphQL ORM Router implementation plan -kind: plan -status: active -owner: workspace-maintainers -last_reviewed: 2026-08-08 -review_by: 2026-09-07 -supersedes: [] ---- - -# GraphQL ORM Router - -## Outcome - -Provide independently consumable `graphql-orm-router-protocol` and -`graphql-orm-router` crates that expose one project-neutral federated GraphQL -HTTP and WebSocket endpoint, adopt only completely validated graph changes, -enforce declared access policy in defence in depth with authoritative subgraph -guards, and support ephemeral live notifications without requiring a durable -message broker. - -The detailed proposal is maintained in the supporting draft references: - -- [Project scope](project-scope.md) -- [Functional requirements](functional-requirements.md) -- [Technical design](technical-design.md) - -## Non-goals - -- Implementing a Federation query planner from scratch. -- Making `graphql-orm`, `agql-auth`, or GEMA mandatory for generic router use. -- Providing durable event storage, replay, queues, workflow orchestration, or - exactly-once notification delivery. -- Solving cross-instance subgraph event fan-out in the initial implementation. -- Moving application-specific policy, identity issuance, or business logic into - the router. - -## Dependencies - -- Existing `graphql-orm` generated operation metadata, conventional Federation - roots, resolver guards, and process-local subscription streams. -- A proved and version-pinned Rust Federation execution boundary; Hive Router - is the initial candidate. -- A proved Federation v2 composition path that accepts immutable candidate - subgraph inputs and produces a validated runtime supergraph. -- [ADR-0007](../../../decisions/ADR-0007-seven-package-workspace-boundaries.md), - which authorizes the protocol and router package edges and supersedes the - earlier five-package boundary. -- Accepted [ADR-0008](../../../decisions/ADR-0008-hive-federation-runtime-and-composition-boundary.md), - which records the pinned engine seam, exposure mitigations, update policy, - and artifact-specific release boundary. -- [ADR-0003](../../../decisions/ADR-0003-resolver-metadata-is-discovery-not-authority.md) - and [ADR-0004](../../../decisions/ADR-0004-authentication-authorization-and-assurance-boundaries.md), - which keep generated metadata advisory and subgraph enforcement authoritative. -- Exact compatibility semantics for JWT validation, scope matching, templated - scopes, and WebSocket authorization. The explicitly coordinated - `agql-auth` 0.14 work provides standard scope issuance and bounded legacy - validation at revision `413fda3435f060604cd653c11e2cc18a668aace1`. -- Test-owned subgraphs and clients for federation, graph reload, authorization, - and subscription evidence. GEMA remains the first migration target, not part - of the generic router contract. - -## Delivery invariants - -- Build in mergeable vertical slices. A slice is complete only when its public - behavior, failure behavior, tests, documentation, and dependency checks land - together. -- Prove an external engine or composition API behind a private adapter before - exposing any router public API that depends on it. -- Router authorization is an early denial layer. A router allow decision only - permits ordinary downstream execution; it never replaces the subgraph guard, - row policy, field policy, assurance check, or database RLS. -- Do not serialize a custom or runtime-only subgraph policy as a broader static - router permission. Unrepresentable policy remains subgraph-only and is marked - explicitly in metadata. -- Keep the generic router usable without `graphql-orm`, `agql-auth`, or GEMA. - Keep `graphql-orm` usable without the router feature. -- Static configuration is the durable source in the initial release. Dynamic - registrations and last-known-good runtime state are process-local; services - must re-register after router restart. Shared registry state and coordinated - multi-router activation remain future work. -- The initial live-event profile assumes one active instance of a write-capable - subgraph. No slice may imply cross-instance fan-out, replay, or durable event - delivery. -- Use test-owned loopback services and temporary SQLite by default. PostgreSQL - and MSSQL follow their existing disposable-infrastructure rules. Never test - against a live application database. -- Never use workspace `--all-features`; database backends and optional router - integrations are verified in explicit feature lanes. -- Update the one current checkpoint below as work advances. Detailed test - evidence belongs in code, CI, ADRs, or archived investigations rather than a - chronological plan transcript. - -## Planned package boundary - -The intended acyclic direction is: - -```text -graphql-orm-router ───────────────► graphql-orm-router-protocol - │ - ├────────────────────────► Federation runtime/composition - └─ optional ─────────────► agql-auth - -graphql-orm ─ optional ───────────► graphql-orm-router-protocol - │ - └────────────────────────────► graphql-orm-macros -``` - -`graphql-orm-macros` may emit paths to feature-gated types re-exported by -`graphql-orm`, but it must not acquire a direct runtime or protocol dependency. -The protocol package contains versioned serializable declarations only; it does -not depend on a server framework, database backend, federation engine, or -application package. - -## Test topology - -The router package will own a reusable integration harness containing: - -- one temporary-SQLite `graphql-orm` subgraph with generated queries, - mutations, entity resolution, guards, and subscriptions; -- one project-neutral non-ORM Federation subgraph that implements the protocol - by hand; -- compatible and deliberately incompatible SDL generations; -- a loopback JWT issuer/JWKS fixture with deterministic test keys, expiry, key - rotation, issuer, audience, standard `scope`, and legacy `scopes` cases; -- HTTP and `graphql-transport-ws` clients; -- bounded loopback listeners allocated by the tests, with deterministic - startup, shutdown, and timeout behavior. - -This harness is the common evidence surface for all cross-process acceptance -gates. It must not depend on GEMA or external infrastructure. - -## Implementation sequence - -The slices below are ordered by dependency and risk. Later slices may be split -into smaller pull requests, but their exit gate must remain intact. - -### Slice 0 — Prove and record the federation seams - -Deliver: - -- Prototype composition of two Federation v2 subgraph SDLs into a supergraph - and construction of an executable Hive-based runtime from that result. -- Prove single- and multi-subgraph queries, entity resolution, mutation routing, - one upstream subscription, and replacement of the executable supergraph. -- Prove the hook point used to authenticate and reject a protected root field - before any downstream request is opened. -- Establish in-flight semantics: ordinary requests remain pinned to the graph - they selected; a retired graph stops receiving new requests; subscriptions - receive a defined reload error and reconnect rather than silently migrating. -- Audit the selected crates for license compatibility, supported Federation v2 - surface, Rust/toolchain compatibility, public-versus-internal API stability, - dependency weight, security maintenance, and version-pinning strategy. -- Add the next available superseding ADR for the expanded workspace package - graph and a separate ADR for the selected federation/runtime composition - boundary. Update the ADR index and root workspace guidance. - -Verification: - -- A maintained integration test exercises the successful proof; throwaway - experiments do not enter the authoritative documentation namespace. -- A negative test proves an invalid supergraph cannot construct or replace the - executable runtime. -- A dependency tree records one selected planner/executor/composition universe - without Git dependencies between workspace packages. - -Exit gate: - -- Continue only if the router can own composition and atomic runtime selection - without exposing unstable engine types or bypassing pre-execution policy. - Otherwise revise the technical design and engine decision before scaffolding - public packages. - -### Slice 1 — Add workspace packages and protocol v1 - -Deliver: - -- Add `graphql-orm-router-protocol` and `graphql-orm-router` as workspace - members, but do not add them to the core default members. -- Use workspace path dependencies and the root `Cargo.lock`; update the - dependency-integrity script, generated package inventory, system context, - development setup, and CI package lanes. -- Define protocol-version, subgraph identity, endpoint advertisement, - capability, schema fingerprint, operation, argument, authorization, and scope - template types. -- Separate advertised service data from deployment-owned overrides and - credentials. Endpoint strings remain inert protocol data until the router - validates them against network policy. -- Define compatible-minor and incompatible-major behavior. Compatible readers - ignore unknown additive fields; unknown required semantics or major versions - fail registration clearly. -- Add crate READMEs, changelogs, package metadata, and package-local agent guides - for their security, dependency, and verification invariants. - -Verification: - -- Golden JSON round trips cover a generated-style descriptor and a hand-written - non-ORM descriptor. -- Unit and property tests cover deterministic ordering/fingerprints, unknown - fields, malformed values, incompatible versions, and stable error categories. -- `graphql-orm-router-protocol` resolves without Hive, Axum, a database backend, - `graphql-orm`, `agql-auth`, or application dependencies. - -Exit gate: - -- The wire contract is independently consumable and versioned, and workspace - inventory/dependency checks recognize exactly one path source for both new - packages. This owns FR-120 through FR-124 and the package aspects of FR-180 - through FR-184. - -### Slice 2 — Produce canonical ORM policy metadata and native events - -Deliver: - -- Extend the existing ORM operation catalogue with a core-owned, project-neutral - authorization declaration covering public, authenticated, fixed any/all - scopes, and supported argument templates. -- Preserve the current discovery fingerprint contract. Add a separately - versioned authorization fingerprint and combined router-export fingerprint - instead of silently changing the meaning of the existing fingerprint. -- Finalize one macro grammar for generated-operation policy, validate malformed - templates and statically knowable argument references at compile time, and - retain the current `auth = "required" | "optional" | "none"` migration path. -- Make the same static declaration construct the generated resolver guard and - the feature-gated protocol export. Dynamic/custom policy hooks remain - subgraph-only and cannot be represented as router permission. -- Emit standard Federation `@authenticated` and `@requiresScopes` directives - for representable fixed policies. Keep argument templates in protocol - metadata unless the composition proof accepts a project-neutral composed - directive without ambiguity. -- Expose deterministic schema and authorization metadata from the finished - generated root catalogue, including subscription ownership. -- Prove the existing post-commit Tokio broadcast path and subscription request - context; do not introduce a router-specific event bus. - -Verification: - -- Macro `trybuild` tests cover valid declarations, duplicate policy, invalid - any/all structure, malformed templates, unknown arguments, and feature-off - compilation. -- Unit tests prove deterministic descriptors and independent schema, - authorization, and combined fingerprints. -- Resolver tests prove the generated guard still denies independently when a - router-style preflight would allow. -- SQLite integration proves committed generated writes emit subscription events - and failed/rolled-back writes do not. Explicit backend compile lanes prove no - regression on PostgreSQL or MSSQL profiles. -- Feature-off dependency trees prove ordinary ORM consumers do not resolve the - protocol or router package. - -Exit gate: - -- One declaration drives representable generated guards and advisory export - without making metadata authoritative. This owns FR-090 through FR-100 and - FR-110 through FR-115, plus the subgraph side of FR-060 and FR-065 through - FR-066. - -### Slice 3 — Serve a static atomic HTTP graph - -Deliver: - -- Implement validated configuration for listener, GraphQL path, static - subgraphs, downstream headers, explicit anonymous-development mode, and - internal schema credentials. -- Implement immutable candidate inputs, composition diagnostics, runtime graph - construction, an atomic active-graph store, and graph version/fingerprint - identities. -- On startup, fetch every configured SDL, compose the complete graph, build the - runtime, and become ready only after full success. Invalid startup has no - partially active graph. -- Expose `POST /graphql`, liveness, and readiness through the library; keep - federation-engine types private. -- Route single-subgraph queries, federated queries/entities, and mutations; - preserve standard GraphQL response/error paths and only approved downstream - headers. - -Verification: - -- End-to-end tests cover valid and invalid startup, one- and two-subgraph - queries, entity resolution, mutation ownership, downstream failure paths, - header allowlisting, liveness, and readiness. -- Concurrency tests prove readers see only a complete old or complete new - `ActiveGraph` and that failed runtime construction cannot swap it. -- Tests use explicit anonymous-development configuration until the secured - default lands; no production example presents anonymous mode as the default. - -Exit gate: - -- Static subgraphs form a usable federated HTTP graph with no restart-time - partial state. This owns FR-001 and FR-003 through FR-005, FR-010, FR-015 - through FR-019, FR-050 through FR-056, and FR-130 through FR-131. - -### Slice 4 — Add fail-closed HTTP authentication and authorization - -Deliver: - -- Define engine-neutral authenticated-principal and authentication-provider - interfaces plus a configured JWT resource-server implementation supporting - signature, issuer, audience, expiry, key ID, JWKS retrieval/cache/rotation, - and explicit clock behavior. -- Keep the router resource-server-only: it does not issue tokens, own login or - session state, refresh credentials, or synthesize authentication evidence. -- Treat OAuth `scope` as a space-delimited string. Accept legacy `scopes` arrays - only under an explicit migration option; reject malformed claims and fail - closed when both forms conflict. -- Add exact scope matching by default and an explicit matcher adapter contract. - Reuse the exact-pinned `agql-auth` `AccessTokenValidator` through an optional - one-way feature. Any upstream scope-claim change and revision update is a - separately authorized external-repository task. -- Parse and select the GraphQL operation, coerce variables, enumerate every - selected protected root field across aliases, fragments, directives, and - multiple root selections, then perform advisory preflight denial before the - federation runtime opens downstream work. -- Support authenticated, any-of, all-of, and argument-template requirements. - Canonicalize only documented scalar input kinds; missing, null, complex, or - uncoercible substitutions deny. -- Bind authorization metadata to the exact active graph/fingerprint. Reject a - candidate graph whose required metadata is missing, stale, ambiguous, or - incompatible rather than guessing at request time. -- Propagate the original approved bearer credential only to configured GraphQL - destinations. Use separate service credentials for registration/SDL access - and never log either credential. - -Verification: - -- JWT tests cover valid, missing, malformed, expired, wrong issuer/audience, - unknown key, JWKS rotation/cache failure, standard/legacy scope migration, - and conflicting claims. -- Authorization tests cover public/authenticated defaults, fixed any/all rules, - exact and configured hierarchical matching, variables, literals, defaults, - aliases, fragments, skipped fields, multiple root fields, unresolved - templates, and unsupported values. -- Equivalence vectors run the same generated policy against the router preflight - and authoritative subgraph guard. Tests prove a router allow cannot bypass a - subgraph denial and a router denial opens no downstream request. -- Redaction tests inspect structured logs and GraphQL errors for raw bearer, - JWKS, service credential, and sensitive variable leakage. - -Exit gate: - -- Secured HTTP requests fail closed and router/subgraph decisions agree for all - representable generated policies. This owns FR-080 through FR-082, FR-085 - through FR-100, including the resource-server boundary in FR-087, plus - FR-162 through FR-163, FR-170, and FR-176. - -### Slice 5 — Add authenticated federated subscriptions - -Deliver: - -- Serve `graphql-transport-ws` on the public GraphQL path and connect to the - owning subgraph using the proved upstream transport. -- Authenticate `connection_init`, authorize each operation against its selected - active graph, and propagate only the approved credential. -- Define the initial long-lived-token policy: no in-place token refresh; - connections close at token expiry and clients reconnect/re-authenticate. - Optional revocation or assurance-aging hooks may close earlier but never - extend token validity. -- Pin a subscription to its selected graph. On graph retirement send the - documented schema-reload error, close the affected subscription, and require - client resubscription against the new graph. -- Bound WebSocket connections, operations per connection, upstream buffers, and - downstream fan-out. Expose lag/drop metrics; do not persist or replay. -- Use engine-supported compatible subscription deduplication only after tests - prove authenticated principals and variables are part of the deduplication - identity. - -Verification: - -- End-to-end tests cover connection authentication failure, scope denial before - upstream open, generated write/event receipt, filtered subscription, multiple - clients, disconnect/no replay, bounded lag, upstream failure isolation, token - expiry, and schema-reload reconnect. -- One test uses the hand-written non-ORM subgraph to prove the router contract is - not tied to generated subscriptions. -- Metrics tests prove active connection/subscription gauges return to baseline - after success, denial, timeout, and disconnect. - -Exit gate: - -- HTTP and WebSocket share the public graph securely, and connected clients - receive bounded ephemeral events without NATS, JetStream, or EDFS. This owns - FR-002, FR-060 through FR-072, FR-083 through FR-086, and FR-175. - -### Slice 6 — Add polling, candidate composition, and graph lifecycle - -Deliver: - -- Poll authenticated SDL endpoints with conditional ETag/fingerprint requests, - configurable intervals, request timeouts, body limits, and bounded retry - behavior. Unchanged inputs skip composition. -- Canonicalize router-relevant schema and authorization inputs before SHA-256 - fingerprinting; exclude deployment/runtime noise. -- Serialize refresh/admission attempts so a slower old candidate cannot replace - a newer accepted candidate. Cancellation and shutdown leave the active graph - untouched. -- Compose a changed candidate with every other active subgraph's last-known-good - input, validate the complete runtime, and atomically activate only on success. -- Retain active membership and schema during health, fetch, metadata, - composition, or runtime-construction failure. -- Implement authenticated manual refresh and explicit removal as candidate graph - operations; disappearance or unhealthiness never means removal. -- Expose registered, candidate, active, unhealthy, rejected, and disabled state - with safe rejection diagnostics. - -Verification: - -- Deterministic tests cover unchanged polling, valid addition/change/removal, - incompatible update, unavailable SDL, unhealthy subgraph, stale concurrent - refresh, manual refresh, explicit removal rejection, and recovery. -- In-flight HTTP requests finish on the graph they selected; new requests see - the replacement. Subscription reload behavior remains as established in - Slice 5. -- Last-known-good tests assert both the schema and executable runtime identity, - not merely a stored SDL string. - -Exit gate: - -- Valid graph changes arrive without process restart and every invalid candidate - leaves the exact executable graph unchanged. This owns FR-030 through FR-040 - and completes FR-015 through FR-020 and FR-130 through FR-134. - -### Slice 7 — Add dynamic registration and administrative security - -Deliver: - -- Implement framework-neutral versioned descriptor construction and an example - `/.well-known/graphql-router` host route, plus the router's authenticated - candidate registration endpoint. The protocol package must not acquire a - server-framework dependency. -- Bind service identity, registered subgraph name, metadata/SDL destination, - and allowed GraphQL destination. Reject duplicate or conflicting identities. -- Enforce scheme/host/port/network allowlists, post-resolution IP checks, - link-local/metadata-address denial, bounded DNS behavior, redirect policy, - response limits, and credential non-forwarding to prevent SSRF and confused - deputy behavior. -- Implement authenticated status and refresh endpoints showing active graph - version/fingerprint, known subgraphs, health, current fingerprints, last - successful composition, rejected candidates, and safe errors. -- Document and test initial restart semantics: static subgraphs rebuild at - startup; dynamic services re-register; no process-local graph is represented - as durable or shared across router instances. -- Complete structured file/environment configuration, externally supplied - secrets, public/admin listener policy, request body/parser/depth/complexity - limits, and WebSocket limits. - -Verification: - -- Tests cover trusted registration, unauthenticated/unauthorized registration, - incompatible protocol, duplicate identity, malicious advertised override, - loopback/private/link-local policy, DNS rebinding defense, redirect escape, - oversized metadata/SDL, credential isolation, explicit disable/removal, and - re-registration after restart. -- Administrative response/log snapshots contain no tokens, keys, credentials, - private variables, or unsafe downstream errors. -- Limit tests cover request body, parse work, depth, complexity/field count, - WebSocket connections, and subscriptions per connection. - -Exit gate: - -- Dynamic candidates can be admitted without weakening network or - administrative trust boundaries. This owns FR-011 through FR-014, FR-020, - FR-040, FR-140 through FR-152, and FR-171 through FR-176. - -### Slice 8 — Harden operations, public APIs, and release evidence - -Deliver: - -- Stabilize engine-neutral `RouterConfig`, `RouterBuilder`, `RouterHandle`, - startup/readiness, refresh, status, and graceful-shutdown APIs. -- Ship the `graphql-orm-router` executable with structured configuration, - signal handling, safe startup failures, and production-secure defaults. -- Complete tracing and metrics for requests, downstream latency/errors, - WebSockets/subscriptions/lag, graph versions, refresh/composition outcomes, - health, rejected candidates, and authorization denials. -- Add an ORM example and a hand-written non-ORM example, operator configuration, - schema-evolution guidance, WebSocket reconnect guidance, threat model, - troubleshooting, and explicit single-instance/event limitations. -- Add package changelogs and migration notes, public Rustdoc, feature/dependency - documentation, release-policy/semver lanes, and CI coverage for both new - packages. -- Run a bounded soak/failure campaign covering repeated reload, subgraph - timeout/recovery, JWKS outage/rotation, subscription churn, lag, and graceful - shutdown. Define resource budgets before calling the binary production-ready. - -Verification: - -- `cargo fmt --all -- --check`. -- Package tests, warnings-denied Clippy, and warnings-denied Rustdoc for the - protocol and router packages under their explicit default and optional-auth - feature profiles. -- Relevant `graphql-orm` SQLite tests plus explicit SQLite, PostgreSQL, MSSQL, - combined-backend, feature-off, and `auth-agql` compile/test/tree lanes. -- `scripts/check-workspace-dependencies.sh`, generated workspace inventory - check, duplicate dependency review, and documentation validation. -- Example smoke tests start the binary, reach readiness, execute HTTP and - WebSocket operations, and shut down without leaked tasks or listeners. - -Exit gate: - -- The generic packages are independently consumable and meet FR-001 through - FR-184 in the automated matrix. No GEMA-specific type, scope, route, service, - or deployment default exists in their public contract. - -### Slice 9 — Validate and migrate GEMA as a separate consumer track - -Deliver: - -- Create a GEMA-owned migration plan and obtain explicit authority for changes - outside this workspace, including any required `agql-auth` revision work. -- Run Cosmo/NATS and the new router path in parallel where practical. Compare - composed schema, query/mutation results, error paths, fixed and parameterized - authorization, WebSocket behavior, live notifications, and last-known-good - failure behavior. -- Cut over clients behind a reversible deployment switch. Keep rollback until - production observation meets the agreed window and error/resource budgets. -- Remove EDFS, NATS/JetStream GraphQL notification paths, WGC, Cosmo execution - configuration/router, and the Go authorization module only after proving no - remaining workload owns them. - -Verification: - -- The GEMA acceptance list in the - [functional requirements](functional-requirements.md#18-gema-acceptance-requirements) - and the production-readiness tests in - [section 19](functional-requirements.md#19-acceptance-criteria) pass against - deployment-owned disposable or shadow infrastructure. -- Direct subgraph access remains protected, Apollo HTTP/WebSocket behavior is - preserved, missed events recover through ordinary queries, and rollback is - exercised before destructive cleanup. - -Exit gate: - -- GEMA no longer requires Cosmo or NATS/JetStream for GraphQL federation and - notifications, with rollback evidence and no removal of infrastructure still - used by another workload. - -## Requirement ownership - -| Requirement area | Primary slice | -| --- | --- | -| Public GraphQL endpoint, FR-001–FR-005 | 3 and 5 | -| Registration/admission, FR-010–FR-020 | 3, 6, and 7 | -| Discovery/activation, FR-030–FR-040 | 6 | -| Federation execution, FR-050–FR-056 | 0 and 3 | -| Subscriptions, FR-060–FR-072 | 2 and 5 | -| Authentication, FR-080–FR-087 | 4 and 5 | -| Scope authorization, FR-090–FR-100 | 2 and 4 | -| ORM metadata, FR-110–FR-115 | 2 | -| Router protocol, FR-120–FR-124 | 1 | -| Health/state, FR-130–FR-134 | 3 and 6 | -| Administration, FR-140–FR-141 | 7 | -| Configuration, FR-150–FR-152 | 3 and 7 | -| Observability, FR-160–FR-163 | incremental, completed in 8 | -| Security, FR-170–FR-176 | 4, 5, and 7 | -| Compatibility, FR-180–FR-184 | 1 and 8 | -| GEMA acceptance | 9 | - -## Acceptance gates - -- A bounded prototype proves the selected Federation engine, composition API, - executable graph swap, downstream transport, and subscription reload - behavior before the production crate architecture depends on them. -- Each implementation slice has automated tests at its owning contract and an - end-to-end test when it crosses crate, process, HTTP, or WebSocket boundaries. -- Static subgraphs can serve federated queries and mutations before dynamic - discovery is introduced. -- Candidate schema admission is immutable, deterministic, and atomic; invalid - or unavailable candidates cannot replace the last-known-good executable - graph. -- Fixed and argument-templated router authorization decisions match - independently enforced subgraph decisions across HTTP and WebSocket flows. -- Connected clients receive bounded ephemeral subscription events, missed - events remain explicitly unreplayed, and schema-reload reconnection behavior - is tested. -- Dynamic registration, schema retrieval, and administrative operations have - authenticated identities, bounded inputs, SSRF-resistant endpoint policy, - secret-safe telemetry, and explicit restart/state semantics. -- Relevant formatting, tests, warnings-denied Clippy and Rustdoc, dependency - direction, backend compile lanes, and documentation checks pass at handoff. -- All FR-001 through FR-184 requirements have an automated owner in the table - above; any deliberate deferral requires a scope change in the canonical - requirements before production readiness can be claimed. -- The generic router release candidate is independently acceptable at Slice 8. - Overall initiative completion additionally requires the separately authorized - GEMA migration and cleanup evidence in Slice 9. - -## Current checkpoint - -Slices 0 through 8 are implemented. The workspace contains independently -consumable protocol and router packages with the private exact-pinned Hive -composition/execution seam accepted in ADR-0008. Generated and hand-written -subgraphs share protocol v1; representable ORM declarations drive both -authoritative guards and advisory router metadata without changing the existing -discovery fingerprint or pulling the router into ordinary ORM builds. - -The generic release candidate now provides atomic static and dynamic graph -lifecycle, last-known-good polling/admission, authenticated HTTP and -`graphql-transport-ws`, graph-bound fixed and templated scope preflight, -resource-server-only JWKS and optional `agql-auth` validation, identity-bound -administration, deny-by-default SSRF controls, strict file/environment -configuration, bounded requests/subscriptions/deadlines, structured telemetry, -authenticated metrics, an opt-in Prometheus exporter, stable library handles, -and bounded signal-driven shutdown. Operator, schema evolution, reconnect, -threat-model, troubleshooting, and migration guidance is component-local. - -Maintained evidence covers every generic FR-001 through FR-184 owner, including -the real executable's pre-bind check, authenticated HTTP and WebSocket work, -downstream timeout/recovery, and listener release. The hardening campaign also -covers repeated atomic reloads, JWKS outage/rotation, WebSocket churn, bounded -lag, rejection/recovery, and graceful drain. Package/backend, feature-off, -optional-auth, MSRV, Clippy, Rustdoc, dependency, inventory, duplicate, and -documentation gates pass, so the generic Slice 8 boundary is closed. - -The external `agql-auth` 0.14 interoperability change is implemented and the -workspace exact pin now resolves revision -`413fda3435f060604cd653c11e2cc18a668aace1`. New access tokens use the standard -OAuth `scope` string; bounded legacy `scopes` validation remains available for -a staged expiry window. The router stays validation-only and never receives -private signing material. - -The separately owned GEMA consumer has completed source migration, reversible -live cutover, and deployment validation at consumer revision -`7a3150cbe6c4332c1b786cb2a1a1d680bfbeb8bb`, using the reviewed router 0.1.1 -revision `d178af46648881d1959701b1fb56f2885bb326cb`. Its exact router artifact -has SHA-256 -`021a68d07c763ad47a78d1da6a54ef06eeb245db30598123642f472da74253a9`; -the unchanged configuration composed all eight generated protocol-v1 -descriptors with graph fingerprint -`sha256:4425e7c84a2fc4cb0f277fbcac4602b4b3758cd4bf1e1fbb074939ae8fdaf71b`. -Artifact-specific SBOM, notices, native/linked-component inventory, approval, -checksums, and deployment results are retained by the deployment owner. - -The variable-backed subscription matrix is acceptance-green: matching and -inline values each reached FAME with one subgraph request, while a mismatched -value returned `FORBIDDEN` with zero subgraph requests. Lowercase HTTP and -WebSocket authorization propagation passed. FAME now rejects ambiguous header -variants while reading names case-insensitively; FAME and its retained media -process run from the selected release root. Strict runtime status is 14/14, -public monitoring passed, and a graceful stop/readiness-close/restart cycle -preserved post-restart subscriptions without changing RBI or relay tiers. - -A later live agent log-upload operation exposed a connection-containment gap: -an oversized public WebSocket message ended the client transport, a secondary -bridge task masked that cause with generic 1011, and an unbounded consumer -retry loop exhausted the FAME connection cap. Router 0.1.2 now makes terminal -ownership first-cause-wins, closes the private bridge on public termination, -rate-limits public upgrade attempts, and proves stable operation IDs, -one-shot mutation completion, sibling-operation isolation, and continued use -of the same downstream socket after an upstream subscription failure. The -64 KiB serialized-message boundary is unchanged; the consumer must move or -chunk bulk log payloads and add mutation-safe jittered reconnect behavior. - -The live cutover remains in place, but Slice 9 acceptance is reopened until the -0.1.2 artifact and consumer containment changes pass live validation. Cosmo, -NATS, WGC, and JetStream assets remain inactive with no process, container, -unit, or listener. Permanent deletion must not occur until the deployment -owner confirms both renewed acceptance and that no other workload owns the -retained assets. Every later binary, container, hosted service, lockfile, or -delivery channel still requires its own artifact-specific review under -ADR-0008. diff --git a/docs/plans/active/graphql-orm-router/functional-requirements.md b/docs/plans/active/graphql-orm-router/functional-requirements.md deleted file mode 100644 index 689a1e92..00000000 --- a/docs/plans/active/graphql-orm-router/functional-requirements.md +++ /dev/null @@ -1,632 +0,0 @@ ---- -title: GraphQL ORM Router functional requirements -kind: reference -status: draft -owner: workspace-maintainers -last_reviewed: 2026-08-07 -review_by: 2026-11-07 -supersedes: [] ---- - -# GraphQL ORM Router — Functional Requirements - -## Status - -Proposed. - -## 1. Introduction - -`graphql-orm-router` provides a project-agnostic federated GraphQL entry point over multiple GraphQL microservices. - -It must support queries, mutations and live GraphQL subscriptions while allowing schemas to evolve independently. - -The router must integrate particularly well with `graphql-orm` and `agql-auth`, but neither application business logic nor GEMA-specific behaviour may form part of the generic router contract. - -## 2. Terminology - -### Router - -The public GraphQL gateway provided by `graphql-orm-router`. - -### Subgraph - -An independently deployed GraphQL service participating in the federated graph. - -### Candidate Subgraph - -A newly registered subgraph that has not yet been admitted into the active graph. - -### Active Graph - -The currently validated and executable federated schema. - -### Candidate Graph - -A proposed replacement graph produced after a registration or schema change. - -### Last-Known-Good Schema - -The most recent successfully admitted schema for a subgraph. - -### Router Protocol - -Project-neutral metadata used by subgraphs and the router for discovery, capabilities and authorization. - -## 3. Public GraphQL Endpoint - -### FR-001 - -The router shall expose a configurable HTTP GraphQL endpoint. - -Default: - -```text -/graphql -``` - -### FR-002 - -The router shall expose GraphQL subscriptions using `graphql-transport-ws`. - -### FR-003 - -HTTP GraphQL and WebSocket GraphQL shall be capable of sharing the same public `/graphql` path. - -### FR-004 - -Clients shall not require knowledge of individual subgraph addresses. - -### FR-005 - -Queries, mutations and subscriptions shall use the federated schema visible through the public endpoint. - -## 4. Subgraph Registration - -### FR-010 - -The router shall support statically configured subgraphs. - -### FR-011 - -The router shall support dynamically registered candidate subgraphs. - -### FR-012 - -Every registered subgraph shall have a stable unique identifier. - -### FR-013 - -A registration shall include or resolve: - -- subgraph name; -- GraphQL endpoint; -- SDL retrieval location or method; -- optional WebSocket endpoint; -- protocol version; -- advertised capabilities. - -### FR-014 - -Dynamic registration shall require authenticated service identity. - -### FR-015 - -Registering a subgraph shall not immediately modify the active graph. - -### FR-016 - -The router shall fetch and validate the candidate subgraph's SDL before admission. - -### FR-017 - -The router shall attempt composition of the complete candidate graph before activation. - -### FR-018 - -A candidate shall be activated only after all required validation succeeds. - -### FR-019 - -A failed candidate admission shall leave the active graph unchanged. - -### FR-020 - -The router shall expose the reason a candidate was rejected through administrative status and telemetry. - -## 5. Schema Discovery - -### FR-030 - -The router shall automatically monitor active subgraphs for schema changes. - -### FR-031 - -The preferred schema change mechanism shall support an inexpensive fingerprint or ETag comparison. - -### FR-032 - -Unchanged schemas shall not require full composition. - -### FR-033 - -When a schema fingerprint changes, the router shall retrieve the candidate SDL. - -### FR-034 - -The router shall compose the candidate SDL with the last-known-good SDLs of all other active subgraphs. - -### FR-035 - -The router shall validate the complete candidate graph. - -### FR-036 - -Successful composition shall atomically replace the active graph. - -### FR-037 - -Failed composition shall retain the previous active graph. - -### FR-038 - -A temporarily unavailable subgraph shall not be automatically removed from the active graph. - -### FR-039 - -Schema polling interval shall be configurable. - -### FR-040 - -Manual schema refresh shall be supported through an authenticated administrative operation. - -## 6. Federation - -### FR-050 - -The router shall support Apollo Federation-compatible subgraphs to the level provided by the selected federation engine. - -### FR-051 - -The router shall support queries spanning multiple subgraphs. - -### FR-052 - -The router shall support federated entity resolution. - -### FR-053 - -The router shall support mutations routed to their owning subgraphs. - -### FR-054 - -The router shall produce standard GraphQL responses. - -### FR-055 - -The router shall preserve appropriate GraphQL error paths when downstream errors occur. - -### FR-056 - -Federation implementation details shall not be exposed as mandatory APIs of `graphql-orm-router`. - -## 7. Subscriptions - -### FR-060 - -A subgraph may expose standard async-graphql subscription root fields. - -### FR-061 - -The router shall expose those subscription fields through the federated graph. - -### FR-062 - -A client subscription shall be routed to the subgraph owning the selected subscription root. - -### FR-063 - -The router shall establish the required upstream WebSocket subscription automatically. - -### FR-064 - -A subgraph shall not require NATS, JetStream or EDFS in order to participate in subscriptions. - -### FR-065 - -`graphql-orm` generated subscriptions shall be usable through the router. - -### FR-066 - -`graphql-orm` generated writes shall be able to publish change events to generated subscriptions through process-local asynchronous broadcast. - -### FR-067 - -Subscription delivery shall be ephemeral by default. - -### FR-068 - -The router shall not persist subscription events. - -### FR-069 - -The router shall not replay events missed while a client is disconnected. - -### FR-070 - -Subscription events may be dropped for a slow or disconnected consumer according to bounded buffering policy. - -### FR-071 - -The application's underlying state store shall remain authoritative after missed events. - -### FR-072 - -The router may deduplicate compatible upstream subscription connections and fan events out to multiple clients where supported by the federation runtime. - -## 8. Authentication - -### FR-080 - -The router shall support JWT bearer authentication. - -### FR-081 - -JWT validation shall support: - -- signature verification; -- issuer validation; -- audience validation; -- expiry validation; -- key ID selection; -- JWKS key retrieval or equivalent configured public key validation. - -### FR-082 - -HTTP GraphQL requests shall accept bearer authentication. - -### FR-083 - -WebSocket authentication shall support credentials supplied during `connection_init`. - -### FR-084 - -Invalid WebSocket authentication shall fail closed. - -### FR-085 - -The router shall support propagation of an approved authorization credential to downstream subgraphs. - -### FR-086 - -Subgraphs shall independently validate propagated authentication. - -### FR-087 - -The router shall not become the authoritative issuer of user authentication tokens. - -## 9. Scope Authorization - -### FR-090 - -Operations may declare authentication requirements. - -### FR-091 - -Operations may declare one or more required scopes. - -### FR-092 - -Scope policy shall support: - -- one required scope; -- any-of scope sets; -- all-of scope sets. - -### FR-093 - -Scope comparison semantics shall be compatible with the configured `agql-auth` scope matcher. - -### FR-094 - -The router shall reject an operation that does not satisfy its declared router policy. - -### FR-095 - -Subgraph resolver authorization shall execute independently even where the router has already authorized the operation. - -### FR-096 - -Missing router authorization metadata must not disable an authoritative subgraph guard. - -### FR-097 - -The router shall support argument-dependent scope templates. - -Example: - -```text -gema.fame.endpoint.{Id}.read -``` - -Given: - -```text -Id = endpoint-123 -``` - -the evaluated scope becomes: - -```text -gema.fame.endpoint.endpoint-123.read -``` - -### FR-098 - -Template expansion shall use GraphQL operation arguments after variable resolution. - -### FR-099 - -Failure to resolve a required scope template shall fail closed. - -### FR-100 - -Router and subgraph authorization requirements generated from `graphql-orm` shall originate from the same metadata declaration. - -## 10. graphql-orm Generated Metadata - -### FR-110 - -`graphql-orm` shall expose deterministic metadata for generated operations. - -### FR-111 - -Metadata shall include: - -- operation identity; -- root field name; -- operation type; -- argument definitions; -- result type; -- auth mode; -- declared scopes; -- any/all semantics; -- templated scope references; -- operation fingerprint. - -### FR-112 - -Generated subscription metadata shall identify subscription root fields. - -### FR-113 - -Metadata generation shall not itself authorize execution. - -### FR-114 - -The generated resolver guard shall remain the authoritative subgraph enforcement point. - -### FR-115 - -Schema and authorization metadata drift shall be detectable through deterministic fingerprints. - -## 11. Router Protocol - -### FR-120 - -A lightweight `graphql-orm-router-protocol` crate shall define the interoperable router contract. - -### FR-121 - -The protocol crate shall not depend on: - -- Hive Router; -- Axum server runtime; -- graphql-orm database backends; -- GEMA; -- application-specific types. - -### FR-122 - -Non-`graphql-orm` services shall be able to implement the protocol manually. - -### FR-123 - -Protocol payloads shall be versioned. - -### FR-124 - -Unknown incompatible protocol versions shall fail registration clearly rather than being silently accepted. - -## 12. Health and Availability - -### FR-130 - -The router shall expose liveness and readiness endpoints. - -### FR-131 - -Router readiness shall require a valid active graph. - -### FR-132 - -Subgraph health state shall be independently observable. - -### FR-133 - -Temporary subgraph unavailability shall not mutate the active schema. - -### FR-134 - -The router shall distinguish: - -- registered; -- candidate; -- active; -- unhealthy; -- rejected; -- disabled. - -## 13. Administrative Status - -### FR-140 - -The router shall expose authenticated administrative state describing: - -- active graph version; -- active graph fingerprint; -- known subgraphs; -- current subgraph fingerprints; -- last successful composition; -- rejected candidates; -- last composition errors. - -### FR-141 - -Administrative endpoints shall not expose JWTs, secrets or sensitive downstream credentials. - -## 14. Configuration - -### FR-150 - -Configuration shall support: - -- listener address; -- public GraphQL path; -- authentication/JWKS settings; -- static subgraphs; -- schema refresh interval; -- request limits; -- WebSocket limits; -- telemetry; -- administrative endpoint policy. - -### FR-151 - -Secrets shall be externally supplied and not stored in committed configuration. - -### FR-152 - -Environment variables and structured configuration files may both be supported. - -## 15. Observability - -### FR-160 - -The router shall emit structured tracing. - -### FR-161 - -Metrics shall include at minimum: - -- HTTP GraphQL request count; -- GraphQL failures; -- subgraph request latency; -- active WebSocket connections; -- active subscriptions; -- schema refresh attempts; -- composition successes; -- composition failures; -- rejected subgraphs; -- authorization denials. - -### FR-162 - -Raw bearer tokens shall never be logged. - -### FR-163 - -Sensitive GraphQL variable values shall not be logged by default. - -## 16. Security - -### FR-170 - -Public GraphQL access shall support configurable authentication-required defaults. - -### FR-171 - -Administrative operations shall require explicit authentication and authorization. - -### FR-172 - -Subgraph registration shall require trusted service identity. - -### FR-173 - -Schema retrieval endpoints shall be capable of requiring internal service authentication. - -### FR-174 - -The router shall enforce configurable request body, parser, depth and complexity limits. - -### FR-175 - -WebSocket connection and subscription counts shall be bounded. - -### FR-176 - -Authorization and schema errors shall fail closed. - -## 17. Compatibility - -### FR-180 - -The router library shall be independently usable without GEMA. - -### FR-181 - -A project shall be able to use `graphql-orm-router` without using `graphql-orm`. - -### FR-182 - -A project shall be able to use `graphql-orm` without using `graphql-orm-router`. - -### FR-183 - -`agql-auth` shall remain independently usable. - -### FR-184 - -Optional integrations shall not create cyclic crate dependencies. - -## 18. GEMA Acceptance Requirements - -GEMA will serve as the first full integration target. - -The integration shall demonstrate: - -1. Removal of Cosmo Router. -2. Removal of Cosmo WGC runtime composition. -3. Removal of NATS/JetStream from GraphQL notifications. -4. Removal of generated EDFS event routing. -5. Removal of the Cosmo Go subscription-auth module. -6. Automatic composition of existing GEMA subgraphs. -7. Automatic adoption of schema changes. -8. Native `graphql-orm` subscriptions over the federated endpoint. -9. Existing GEMA scope semantics. -10. Existing parameterised endpoint scopes. -11. Apollo HTTP and WebSocket client compatibility. -12. Last-known-good graph behaviour during invalid or unavailable subgraph updates. - -## 19. Acceptance Criteria - -The implementation shall not be considered production-ready until automated integration tests demonstrate: - -- valid graph startup; -- invalid graph startup rejection; -- new subgraph admission; -- incompatible subgraph rejection; -- live schema addition; -- live schema removal where explicitly approved; -- failed update rollback; -- HTTP query federation; -- mutation federation; -- WebSocket subscription; -- authentication failure; -- fixed-scope authorization failure; -- templated-scope authorization failure; -- valid scope acceptance; -- token expiry handling; -- WebSocket reauthentication policy; -- subgraph-side guard enforcement; -- notification delivery without NATS. diff --git a/docs/plans/active/graphql-orm-router/project-scope.md b/docs/plans/active/graphql-orm-router/project-scope.md deleted file mode 100644 index e13f3a69..00000000 --- a/docs/plans/active/graphql-orm-router/project-scope.md +++ /dev/null @@ -1,448 +0,0 @@ ---- -title: GraphQL ORM Router project scope -kind: reference -status: draft -owner: workspace-maintainers -last_reviewed: 2026-08-07 -review_by: 2026-11-07 -supersedes: [] ---- - -# GraphQL ORM Router — Project Scope - -## Status - -Proposed. - -## Project Name - -`graphql-orm-router` - -## Repository - -The project will live within the existing `graphql-orm` Rust workspace as independently consumable crates. - -Proposed workspace layout: - -```text -graphql-orm/ -├── crates/ -│ ├── graphql-orm -│ ├── graphql-orm-macros -│ ├── graphql-orm-storage -│ ├── graphql-orm-backup -│ ├── graphql-orm-ai -│ ├── graphql-orm-router -│ └── graphql-orm-router-protocol -└── docs/ - └── plans/ - └── active/ - └── graphql-orm-router/ - ├── README.md - ├── project-scope.md - ├── functional-requirements.md - └── technical-design.md -``` - -## 1. Purpose - -The purpose of `graphql-orm-router` is to provide a project-agnostic GraphQL federation router for Rust applications, with first-class integration with: - -- `graphql-orm` -- `async-graphql` -- `agql-auth` - -The router will provide a single public GraphQL HTTP and WebSocket endpoint over multiple independently deployed GraphQL microservices. - -The project is intended to remove the requirement for application projects to depend on external GraphQL routing and event infrastructure solely to provide: - -- federated GraphQL queries; -- federated GraphQL mutations; -- GraphQL subscriptions; -- live change notifications; -- schema composition; -- schema discovery; -- scope-based authorization. - -The initial motivating project is GEMA, where the target architecture replaces both WunderGraph Cosmo Router and NATS/JetStream. - -The router itself must not contain GEMA-specific business logic. - -## 2. Objectives - -The project will provide: - -1. A reusable Rust GraphQL federation router. -2. Automatic discovery and composition of registered subgraph schemas. -3. Runtime adoption of valid schema changes without router restart. -4. Rejection of invalid candidate schema changes without affecting the active graph. -5. HTTP GraphQL query and mutation routing. -6. GraphQL WebSocket subscriptions using `graphql-transport-ws`. -7. Direct routing of subscriptions to the subgraph that owns the subscription field. -8. Support for ephemeral, non-replayed notifications. -9. Integration with `agql-auth` JWT claims and authorization semantics. -10. Router-level authorization based on authorization metadata published by subgraphs. -11. Independent authorization enforcement within each subgraph. -12. First-class generation of router-compatible metadata from `graphql-orm`. -13. A generic protocol allowing non-`graphql-orm` GraphQL services to participate. -14. A reusable library API and standalone router binary. -15. Removal of project-specific dependencies on Cosmo, WGC, NATS, JetStream and EDFS where durable messaging is not required. - -## 3. Design Principles - -### 3.1 Project Agnostic - -`graphql-orm-router` must not depend on GEMA-specific: - -- service names; -- scope namespaces; -- entity names; -- URLs; -- authentication policies; -- deployment assumptions. - -GEMA will be a consumer of the router. - -### 3.2 graphql-orm Is Optional - -A subgraph does not have to use `graphql-orm`. - -Any compatible GraphQL service may participate if it provides the required: - -- GraphQL endpoint; -- SDL; -- federation metadata; -- router protocol metadata where applicable. - -`graphql-orm` will provide the preferred automated integration. - -### 3.3 Subgraphs Remain Authoritative - -Router authorization is defence in depth. - -A request rejected by the router must not reach a subgraph. - -A request accepted by the router must still pass the subgraph's own authentication and authorization checks. - -Direct access to a subgraph must never grant greater access than routed access. - -### 3.4 One Authorization Declaration - -Where `graphql-orm` generates an operation, the authorization requirement should be declared once and used to generate both: - -- the authoritative async-graphql resolver guard; -- router-readable authorization metadata. - -Authorization metadata must not become an independent configuration source that can silently drift from resolver enforcement. - -### 3.5 Current State Is Authoritative - -GraphQL subscriptions are intended primarily as live invalidation and state-change notifications. - -The default model is: - -> Something changed; here is the updated state or enough information to obtain the updated state. - -Subscription events are not durable records. - -If a client is disconnected while an event occurs, the normal GraphQL query remains the authoritative source of current state. - -### 3.6 Last-Known-Good Federation - -A failed subgraph health check, failed SDL fetch or failed composition must never automatically remove that subgraph from the active federated graph. - -The active graph remains unchanged until a complete candidate graph successfully validates and composes. - -## 4. In Scope - -### 4.1 Router Runtime - -A new `graphql-orm-router` crate will provide: - -- HTTP GraphQL endpoint; -- WebSocket GraphQL endpoint; -- federation query planning and execution; -- subgraph HTTP routing; -- subgraph WebSocket routing; -- JWT validation; -- router-level authorization; -- header propagation; -- health endpoints; -- telemetry; -- graph reload support. - -A standalone binary will also be supplied. - -### 4.2 Federation Engine - -The project will reuse an established Rust federation execution engine rather than implement Apollo Federation query planning from scratch. - -The initial intended engine is Hive Router or its reusable Rust components. - -The external federation engine must remain an implementation detail behind `graphql-orm-router` APIs wherever practical. - -### 4.3 Schema Composition - -The router will maintain the active federated graph from registered subgraphs. - -It will: - -- fetch subgraph SDL; -- calculate or consume schema fingerprints; -- detect schema changes; -- compose a candidate supergraph; -- validate the candidate; -- activate it atomically when valid; -- retain the current graph if candidate composition fails. - -Composition should use a native Rust implementation where possible. - -### 4.4 Subgraph Registry - -The router will maintain a registry containing: - -- stable subgraph identity; -- GraphQL HTTP endpoint; -- optional WebSocket endpoint; -- SDL endpoint or SDL retrieval method; -- schema fingerprint; -- capability information; -- health state; -- last-known-good schema; -- authorization metadata version; -- registration state. - -Subgraphs may be: - -- configured statically; -- registered dynamically; -- admitted as candidates; -- activated after successful validation; -- disabled explicitly. - -### 4.5 Subscriptions - -Subscriptions will use GraphQL WebSockets rather than NATS-backed EDFS. - -For a normal `graphql-orm` subgraph: - -```text -Database write - ↓ -graphql-orm change event - ↓ -tokio broadcast - ↓ -async-graphql Subscription - ↓ -graphql-orm-router - ↓ -client WebSocket -``` - -No persistence or replay is required by the router. - -### 4.6 Authorization - -The project will support: - -- JWT authentication; -- local role and scope claims; -- fixed scope requirements; -- any/all scope requirements; -- authentication-only requirements; -- argument-dependent scope templates; -- HTTP authentication; -- WebSocket `connection_init` authentication; -- downstream propagation of the authenticated token. - -### 4.7 graphql-orm Integration - -`graphql-orm` will be enhanced to expose router-consumable metadata for: - -- generated root fields; -- generated subscriptions; -- authentication requirements; -- scope requirements; -- parameterised scope requirements; -- operation fingerprints; -- generated schema fingerprints. - -### 4.8 agql-auth Integration - -`agql-auth` 0.14 has been enhanced to make its JWT and authorization contract directly usable by the router. - -This includes standardising scope claim interoperability. - -## 5. Out of Scope - -The initial project will not provide: - -- durable event storage; -- message replay; -- distributed queues; -- exactly-once delivery; -- workflow orchestration; -- event sourcing; -- replacement for Kafka, NATS JetStream or RabbitMQ; -- arbitrary service discovery platforms; -- database replication; -- automatic horizontal subscription fan-out between independent instances; -- frontend cache management; -- application-specific authorization policy. - -If durable asynchronous messaging is required by a project, it should be introduced separately for that workload. - -## 6. Multi-Instance Constraint - -The initial subscription implementation may use process-local Tokio broadcast channels. - -This works where one active instance of a subgraph is responsible for both: - -- the write that generates an event; -- the GraphQL subscription serving that event. - -Horizontal scaling introduces a separate cross-instance fan-out problem. - -Future deployments may add an ephemeral pub/sub provider such as: - -- NATS Core; -- Redis Pub/Sub; -- PostgreSQL LISTEN/NOTIFY; -- another project-selected transport. - -This must remain optional and must not reintroduce a mandatory durable broker. - -## 7. Required Repository Changes - -### 7.1 graphql-orm - -Implemented changes: - -- introduce router-compatible operation authorization metadata; -- expose generated subscription ownership and signatures; -- expose stable schema/operation fingerprints; -- ensure generated subscription resolvers use request-authenticated context; -- provide a single-source authorization declaration for guard and router metadata generation; -- expose fixed and templated scope requirements; -- optionally depend on `graphql-orm-router-protocol`; -- add integration tests covering router metadata drift. - -`graphql-orm` must not depend on `graphql-orm-router`. - -### 7.2 graphql-orm-macros - -Required changes: - -- generate router protocol metadata where enabled; -- generate Federation authorization directives or equivalent metadata; -- generate deterministic operation identities; -- support argument references in scope templates; -- ensure compile-time validation of malformed scope templates where possible. - -### 7.3 New graphql-orm-router-protocol Crate - -This crate will define stable, project-neutral data structures for communication between subgraphs and routers. - -It should contain data types only and avoid router implementation dependencies. - -### 7.4 New graphql-orm-router Crate - -This crate will contain: - -- router runtime; -- registry; -- composition; -- authentication; -- authorization; -- schema lifecycle; -- federation runtime integration; -- WebSocket support; -- configuration; -- telemetry; -- standalone binary. - -### 7.5 agql-auth - -Required changes: - -- support a standards-compatible JWT scope claim; -- accept legacy `scopes` tokens during migration; -- expose reusable resource-server validation suitable for router use; -- retain WebSocket `connection_init` validation; -- expose or share scope matching semantics used by subgraphs; -- preserve fail-closed defaults. - -Preferred migration: - -- new tokens emit `scope`; -- validators accept `scope` and legacy `scopes`; -- legacy support may later be deprecated. - -### 7.6 GEMA - -GEMA will become a consumer and migration target. - -Required eventual changes include: - -- replace Cosmo Router with `graphql-orm-router`; -- remove WGC composition; -- remove Cosmo execution config generation; -- remove Cosmo-specific configuration; -- remove the Go subscription authorization module; -- remove NATS-backed EDFS for GraphQL notifications; -- remove JetStream dependency where no other workload requires it; -- migrate generated events to native GraphQL subscriptions; -- update Apollo WebSocket authentication payload to the router-supported standard; -- retain subgraph-side `agql-auth` enforcement. - -## 8. Deliverables - -The project is complete when the workspace contains: - -1. `graphql-orm-router-protocol`. -2. `graphql-orm-router` library. -3. `graphql-orm-router` standalone binary. -4. Updated `graphql-orm` metadata generation. -5. Updated `graphql-orm-macros`. -6. Required `agql-auth` interoperability changes. -7. Router/subgraph integration tests. -8. Subscription integration tests. -9. Authorization equivalence tests. -10. Automatic schema refresh tests. -11. Last-known-good rollback tests. -12. Documentation and example project. -13. GEMA migration plan. - -## 9. Success Criteria - -The project will be considered successful when: - -- multiple GraphQL subgraphs appear as one public graph; -- a new compatible subgraph can be admitted without router restart; -- a valid schema update becomes available automatically; -- an invalid schema update does not change the active graph; -- queries and mutations are correctly federated; -- clients subscribe over the same public GraphQL endpoint; -- generated `graphql-orm` subscriptions work without NATS; -- subscription notifications reach connected clients in real time; -- disconnected clients recover current state using ordinary GraphQL queries; -- router scope decisions match subgraph scope decisions; -- parameterised scopes are enforced correctly; -- unauthorized subscriptions are rejected before opening an upstream subscription; -- direct subgraph access remains protected; -- GEMA can operate without Cosmo Router or NATS for GraphQL federation and notifications. - -## 10. Non-Goals - -The router is not intended to become: - -- a general message broker; -- a durable event bus; -- a distributed database; -- an application server; -- an authentication provider; -- an ORM; -- a replacement for subgraph business logic. - -Its responsibility is federation, routing, schema lifecycle and enforcement of declared GraphQL access policy. diff --git a/docs/plans/active/graphql-orm-router/technical-design.md b/docs/plans/active/graphql-orm-router/technical-design.md deleted file mode 100644 index eecb4a11..00000000 --- a/docs/plans/active/graphql-orm-router/technical-design.md +++ /dev/null @@ -1,1667 +0,0 @@ ---- -title: GraphQL ORM Router technical design -kind: reference -status: draft -owner: workspace-maintainers -last_reviewed: 2026-08-07 -review_by: 2026-11-07 -supersedes: [] ---- - -# GraphQL ORM Router — Technical Design - -## Status - -Proposed. - -## 1. Overview - -`graphql-orm-router` will be a reusable Rust GraphQL federation router contained within the `graphql-orm` monorepo. - -It will provide: - -- federation runtime; -- schema composition; -- subgraph registry; -- automatic schema refresh; -- HTTP routing; -- WebSocket subscription routing; -- JWT authentication; -- declared scope enforcement; -- integration with `graphql-orm`; -- optional integration with `agql-auth`. - -The initial production consumer will be GEMA. - -The intended GEMA migration removes: - -- Cosmo Router; -- WGC; -- Cosmo execution configuration; -- custom Cosmo authorization modules; -- NATS; -- JetStream; -- EDFS; -- NATS-backed GraphQL subscription generation. - -## 2. High-Level Architecture - -```text - GraphQL Clients - HTTP + WebSocket /graphql - │ - ▼ - ┌────────────────────────┐ - │ graphql-orm-router │ - │ │ - │ Federation runtime │ - │ Subgraph registry │ - │ Schema composition │ - │ JWT validation │ - │ Scope enforcement │ - │ Graph lifecycle │ - └───────────┬────────────┘ - │ - HTTP + WebSocket - │ - ┌─────────────────────┼─────────────────────┐ - ▼ ▼ ▼ - subgraph-a subgraph-b subgraph-c - async-graphql async-graphql async-graphql - graphql-orm graphql-orm custom GraphQL - agql-auth agql-auth compatible auth - Tokio broadcast Tokio broadcast own subscription -``` - -## 3. Workspace Architecture - -Proposed workspace: - -```text -graphql-orm/ -├── crates/ -│ ├── graphql-orm -│ ├── graphql-orm-macros -│ ├── graphql-orm-storage -│ ├── graphql-orm-backup -│ ├── graphql-orm-ai -│ ├── graphql-orm-router-protocol -│ └── graphql-orm-router -└── docs/ - └── plans/ - └── active/ - └── graphql-orm-router/ -``` - -Dependency direction: - -```text - agql-auth - ▲ - │ optional integration - │ -graphql-orm ──► graphql-orm-router-protocol ◄── graphql-orm-router - │ - ▼ - Federation runtime -``` - -Rules: - -- `graphql-orm` must not depend on `graphql-orm-router`. -- `agql-auth` must not depend on `graphql-orm-router`. -- protocol types must remain independent of the federation runtime. -- the router may optionally depend on `agql-auth`. -- `graphql-orm` may optionally emit protocol-compatible metadata. -- the router must not expose Hive JWT or object-storage configuration; -- a future deployment-owned storage integration uses `graphql-orm-storage` - outside the project-neutral router crate; and -- router authentication is public-key resource-server validation only. Private - keys, token signing/issuance, refresh sessions, and RSA decryption remain in - an external identity service. - -## 4. Crate Responsibilities - -### 4.1 graphql-orm-router-protocol - -Purpose: - -Provide a stable interoperability contract between GraphQL services and compatible routers. - -Protocol v1 data model: - -```rust -pub struct SubgraphDescriptor { - pub protocol_version: ProtocolVersion, - pub subgraph: SubgraphIdentity, - pub graphql: GraphqlEndpoints, - pub schema: SchemaAdvertisement, - pub capabilities: CapabilitySet, - pub required_semantics: Vec, - pub operations: Vec, - pub fingerprints: DescriptorFingerprints, -} - -pub struct ProtocolVersion { - pub major: u16, - pub minor: u16, -} - -pub struct OperationDescriptor { - pub root_type: RootOperationType, - pub field_name: String, - pub arguments: Vec, - pub authorization: AuthorizationRequirement, -} - -pub enum AuthorizationRequirement { - Public, - Authenticated, - AllScopes { scopes: Vec }, - AnyScopes { alternatives: Vec }, - SubgraphOnly { policy: UnrepresentablePolicy }, -} - -pub struct ScopeTemplate { - pub value: String, -} -``` - -The protocol crate must contain serializable project-neutral declarations only. -Compatible readers accept later minors in the same major and ignore additive -fields. A producer lists semantics that cannot be ignored in -`required_semantics`; an unknown required semantic or different major fails -with a stable error category. - -### 4.2 graphql-orm-router - -The router crate will contain modules conceptually similar to: - -```text -src/ -├── lib.rs -├── config.rs -├── runtime.rs -├── registry.rs -├── discovery.rs -├── composition.rs -├── graph_state.rs -├── auth/ -│ ├── mod.rs -│ ├── jwt.rs -│ ├── scopes.rs -│ └── templates.rs -├── subgraphs/ -│ ├── client.rs -│ └── health.rs -├── telemetry.rs -└── bin/ - └── graphql-orm-router.rs -``` - -The crate should expose both: - -- a reusable library API; -- a standalone executable. - -## 5. Federation Runtime - -The project will not implement a Federation query planner from scratch. - -An existing Rust Federation engine will be embedded or wrapped. - -The initial intended implementation is Hive Router or reusable Hive Router components. - -Responsibility split: - -```text -graphql-orm-router - │ - ├── registry - ├── schema lifecycle - ├── composition - ├── authorization integration - ├── operational configuration - └── runtime lifecycle - │ - ▼ - Federation engine - │ - ├── operation parsing - ├── query planning - ├── entity resolution - ├── subgraph execution - └── subscription execution -``` - -The chosen federation runtime should not leak unnecessary implementation-specific types through the public `graphql-orm-router` API. - -## 6. Public Router Interface - -Default endpoints: - -```text -POST /graphql -GET/WS /graphql - -GET /health/live -GET /health/ready -``` - -Administrative endpoints may use an internal namespace: - -```text -GET /_router/status -POST /_router/subgraphs -POST /_router/refresh -``` - -Administrative endpoints must require authentication when enabled outside loopback-only development environments. - -## 7. Active Graph Model - -Graph state shall be immutable once created. - -Indicative model: - -```rust -pub struct ActiveGraph { - pub version: GraphVersion, - pub fingerprint: String, - pub activated_at: SystemTime, - pub supergraph_sdl: Arc, - pub subgraphs: BTreeMap, -} - -pub struct ActiveSubgraph { - pub descriptor: SubgraphDescriptor, - pub schema_sdl: Arc, - pub schema_fingerprint: String, - pub authorization_fingerprint: Option, -} -``` - -Graph activation must be atomic. - -Readers must observe either: - -- the complete previous graph; -- or the complete replacement graph. - -They must never observe partially updated graph state. - -An immutable graph stored behind `ArcSwap`, `RwLock>`, or an equivalent atomic swap mechanism is appropriate. - -## 8. Subgraph Lifecycle - -State model: - -```text -REGISTERED - │ - ▼ -CANDIDATE - │ - ├── SDL fetch failure ──────► REJECTED - │ - ├── metadata invalid ───────► REJECTED - │ - ├── composition failure ────► REJECTED - │ - └── success - │ - ▼ - ACTIVE - │ - ├── health failure - │ │ - │ └── ACTIVE + UNHEALTHY - │ - ├── invalid schema update - │ │ - │ └── retain previous ACTIVE schema - │ - └── valid schema update - │ - └── activate new graph version -``` - -A health failure does not implicitly alter schema composition. - -Explicit administrative removal is required to remove an active subgraph from the graph. - -## 9. Schema Discovery - -Each participating subgraph should provide an authenticated internal schema endpoint. - -Recommended baseline: - -```text -GET /sdl -``` - -Example response: - -```text -Content-Type: application/graphql -ETag: "" -``` - -The endpoint may require an internal service credential. - -Polling process: - -```text -refresh timer - │ - ▼ -GET /sdl If-None-Match - │ - ├── 304 Not Modified - │ └── no composition - │ - └── changed - │ - ▼ - fetch SDL - │ - ▼ - build candidate graph -``` - -A push-based schema-change notification may be added later as an optimisation. - -Polling remains the baseline because it: - -- is simple; -- self-recovers after temporary network failure; -- does not require another messaging system; -- naturally detects restarted services. - -## 10. Schema Fingerprints - -Subgraphs should expose deterministic schema fingerprints. - -The fingerprint must change when the router-relevant schema changes. - -It should cover: - -- GraphQL SDL; -- federation directives; -- operation authorization declarations; -- router protocol metadata that affects execution. - -The fingerprint must not include unstable values such as: - -- timestamps; -- hostnames; -- process IDs; -- memory addresses. - -SHA-256 over a canonical schema representation is suitable. - -## 11. Composition Pipeline - -Composition shall operate entirely on immutable candidate input. - -```text -changed candidate SDL - │ - ▼ -load last-known-good SDLs - │ - ▼ -validate protocol metadata - │ - ▼ -run Federation composition - │ - ▼ -validate resulting supergraph - │ - ▼ -construct runtime graph - │ - ▼ -atomic activation -``` - -No candidate artifact may overwrite active runtime state before complete success. - -The active graph should record: - -- all input subgraph fingerprints; -- router protocol versions; -- composition library version; -- resulting supergraph fingerprint; -- activation timestamp. - -## 12. Composition Implementation - -Composition should use a native Rust Federation composition implementation where practical. - -Potential implementation: - -```text -Subgraph SDLs - │ - ▼ -graphql-composition - │ - ▼ -FederatedGraph - │ - ▼ -Supergraph SDL - │ - ▼ -Federation runtime -``` - -The router must not require: - -- Node.js; -- WGC; -- shell execution; -- intermediate Cosmo execution configuration. - -## 13. graphql-orm Integration - -### 13.1 Existing Generated Operations - -`graphql-orm` already generates: - -- query roots; -- mutation roots; -- subscription roots; -- operation metadata; -- deterministic generated-surface metadata. - -Router integration should extend this existing metadata system rather than introduce a parallel generator. - -### 13.2 Canonical Authorization Metadata - -Each generated operation should have a canonical authorization descriptor. - -Conceptually: - -```rust -AuthorizationRequirement::Scopes( - ScopeRequirement { - alternatives: vec![ - vec![ - ScopeTemplate::new("endpoint.{Id}.read") - ], - vec![ - ScopeTemplate::new("endpoints.read") - ], - vec![ - ScopeTemplate::new("global.admin") - ], - ], - } -) -``` - -The same metadata shall drive: - -1. subgraph resolver enforcement; -2. router protocol metadata; -3. Federation authorization directives where the policy can be represented by standard directives. - -The derive grammar uses repeatable, disjoint category declarations across every -generated category: `list`, `single_read`, `search`, `keyset_list`, `create`, -`upsert`, `update`, `update_many`, `delete`, `delete_many`, and `subscription`. - -```rust -#[graphql_orm( - operation_authorization( - categories = ["single_read"], - any_scopes = [["records.read"], ["records.admin"]] - ), - operation_authorization( - categories = ["list"], - all_scopes = ["records.list", "tenant.active"] - ), - operation_authorization( - categories = ["search"], - any_scopes = [["records.search"], ["records.admin"]] - ), - operation_authorization( - categories = ["keyset_list"], - all_scopes = ["records.page", "tenant.active"] - ), - operation_authorization( - categories = ["create", "upsert", "update", "update_many", "delete_many"], - all_scopes = ["records.write"] - ), - operation_authorization( - categories = ["delete"], - any_scope_templates = [["records.{id}.delete"], ["records.admin"]] - ), - operation_authorization( - categories = ["subscription"], - any_scopes = [["records.events"], ["records.admin"]] - ) -)] -``` - -Each category may appear in only one declaration. Exactly one of `all_scopes`, -`any_scopes`, `all_scope_templates`, or `any_scope_templates` is required and -empty sets are invalid. Fixed scopes reject whitespace, control characters, -and template braces. Template modes validate balanced GraphQL argument names, -statically reject unknown arguments and complex or nullable inputs, and support -only canonical String, UUID, Boolean, integer, and float substitutions. -Declarations also fail when the entity does not generate the named operation, -preventing an unused policy from silently appearing valid. - -### 13.3 Fixed Scope Policies - -Fixed scope requirements should use standard Federation directives where supported. - -The pinned async-graphql 7.2.1 exporter natively records and imports -`@requiresScopes`, but it does not expose or import Federation's standard -`@authenticated` field metadata. Generated operations therefore address the -non-imported standard directive through the existing Federation link's default -namespace as `@federation__authenticated`. A compatible directive definition is -registered with async-graphql only so its schema registry accepts and emits the -field invocation; it is not linked or composed as a project-owned directive. -Both the pinned Hive composition path and Apollo Composition recognize the -namespaced invocation as standard Federation `@authenticated` metadata. - -The pinned `graphql-composition` renderer preserves the canonical -`@authenticated` and `@requiresScopes` invocations but omits the corresponding -supergraph SECURITY feature links used by Hive to activate authorization -metadata. The private router composition adapter adds only the required -`authenticated/v0.1` and `requiresScopes/v0.1` links through the parsed -supergraph AST before constructing Hive's immutable candidate. This remains a -structural compatibility adapter, not SDL string rewriting. Regression tests -prove the namespaced subgraph form composes, both SECURITY links are present, -and Hive extracts both authorization rules. - -The same async-graphql release does not expose its dedicated `requires_scopes` -attribute on subscription fields. Generated subscriptions therefore use the -standard directive through the existing Federation link's default namespace as -`@federation__requiresScopes`; composition normalizes it to the same -`@requiresScopes` identity. This is the subscription analogue of the -`@authenticated` compatibility path, not a project-owned directive. - -Example: - -```graphql -type Query { - Records: [Record!]! - @authenticated - @requiresScopes( - scopes: [ - ["records.read"] - ["global.admin"] - ] - ) -} -``` - -### 13.4 Parameterised Scope Policies - -Standard Federation scope directives may not be sufficient for argument-dependent scopes. - -The router protocol must therefore support templates such as: - -```text -endpoint.{Id}.read -``` - -A project-neutral custom directive may optionally expose the same information in SDL: - -```graphql -directive @routerRequiresScopes( - scopes: [[String!]!]! -) on FIELD_DEFINITION -``` - -Example: - -```graphql -type Subscription { - EndpointChanged(Id: String!): EndpointChangedEvent! - @routerRequiresScopes( - scopes: [ - ["endpoint.{Id}.read"] - ["endpoints.read"] - ["global.admin"] - ] - ) -} -``` - -The protocol metadata remains authoritative for router-specific semantics if custom directives create composition compatibility problems. - -Generated argument templates use protocol metadata only. Their authoritative -subgraph guard performs one-pass substitution after GraphQL coercion, so braces -inside argument data are never reinterpreted as placeholders. Authorization -fingerprint version 2 binds each referenced argument's GraphQL type and -requiredness while the existing discovery fingerprint remains unchanged. - -## 14. Scope Template Evaluation - -Templates shall reference GraphQL root-field arguments by name. - -Example template: - -```text -endpoint.{Id}.read -``` - -Client operation: - -```graphql -subscription EndpointChanged($endpoint: String!) { - EndpointChanged(Id: $endpoint) { - Id - } -} -``` - -Variables: - -```json -{ - "endpoint": "endpoint-123" -} -``` - -Resolved requirement: - -```text -endpoint.endpoint-123.read -``` - -Rules: - -- variables must be resolved before evaluation; -- a referenced missing argument causes denial; -- an invalid or null required argument causes denial; -- unsupported complex values cause denial; -- substitutions must use deterministic canonical string conversion; -- unresolved templates fail closed; -- template values are data and must never become executable expressions. - -## 15. agql-auth Integration - -### 15.1 JWT Scope Claim - -Current applications may use: - -```json -{ - "scopes": [ - "records.read", - "records.write" - ] -} -``` - -The router ecosystem should align new tokens with the conventional claim: - -```json -{ - "scope": "records.read records.write" -} -``` - -OAuth represents `scope` as a space-delimited JSON string. The legacy -project-specific `scopes` claim remains an array during the bounded migration. - -Migration strategy: - -```text -token issuer: - emit "scope" - -validators: - accept "scope" - optionally accept legacy "scopes" -``` - -If both claims appear and differ, validation must follow a clearly defined fail-closed rule rather than silently unioning them. - -### 15.2 Shared Scope Matching - -Router authorization and subgraph authorization must use equivalent matching rules. - -Where exact matching is configured: - -```text -orders.read == orders.read -``` - -Where hierarchical matching is configured, router and subgraph must use: - -- the same matcher implementation; -- or tested compatibility vectors with identical results. - -Preferred design: - -`graphql-orm-router` optionally reuses `agql-auth` resource-server and scope-matcher primitives directly. - -At the 0.14 pin, the adapter consumes the validator's verified normalized -principal and configured legacy-scope policy directly; it does not perform a -second unverified payload decode. - -The optional adapter uses `AccessTokenValidator` with public key or JWKS -material. It must not expose or construct issuer-side `AuthService`, signing -configuration, private PEM input, refresh/session stores, or decryption APIs. -Hive's own JWT runtime remains unconfigured. - -At the pinned revision, `AuthService` already loads a private PEM and signs -tokens, so an external identity service can keep that responsibility in -`agql-auth`. `AccessTokenValidator` already provides the public-key/JWKS RS256 -resource-server seam needed by the router, including WebSocket -`connection_init` validation. `agql-auth` does not expose RSA private-key -decryption; the router has no reason to add such an operation. - -### 15.3 HTTP Authentication - -Flow: - -```text -Client - │ Authorization: Bearer - ▼ -graphql-orm-router - │ - ├── validate signature - ├── validate issuer - ├── validate audience - ├── validate expiry - ├── parse roles/scopes - └── evaluate router policy - │ - ▼ - subgraph - │ - ├── validate JWT independently - └── execute resolver guard -``` - -### 15.4 WebSocket Authentication - -Client: - -```text -connection_init -{ - "Authorization": "Bearer " -} -``` - -Router: - -```text -validate token -create authenticated WebSocket connection context -``` - -Subgraph connection: - -```text -propagate approved Authorization credential -``` - -Subgraph: - -```text -agql-auth connection-init validation - │ - ▼ -AuthUser/AuthRuntime - │ - ▼ -generated resolver guard -``` - -A long-lived WebSocket must not turn initial authentication into permanent authorization. - -Expiry, revocation and assurance-aging policies must remain enforceable. - -## 16. Router Authorization Pipeline - -For each GraphQL operation: - -```text -parse document - │ - ▼ -resolve operation - │ - ▼ -resolve variables - │ - ▼ -identify protected root field(s) - │ - ▼ -load authorization metadata - │ - ▼ -expand scope templates - │ - ▼ -evaluate authenticated principal - │ - ├── deny ─► GraphQL authorization error - │ - └── allow - │ - ▼ - Federation execution -``` - -The subgraph repeats its own authoritative authorization when execution reaches its resolver. - -## 17. Subscription Architecture - -### 17.1 graphql-orm Generated Subscriptions - -For supported write-capable backends, `graphql-orm` should use standard async-graphql subscriptions driven by local event broadcast. - -```text -generated write - │ - ▼ -commit state change - │ - ▼ -generate change event - │ - ▼ -tokio::sync::broadcast::Sender - │ - ▼ -async-graphql Subscription resolver -``` - -Existing change event semantics should be reused rather than introducing a new router-specific event system. - -### 17.2 Router Subscription Path - -```text -Apollo/client - │ - │ graphql-transport-ws - ▼ -graphql-orm-router - │ - │ upstream graphql-transport-ws - ▼ -owning subgraph - │ - ▼ -async-graphql subscription - │ - ▼ -Tokio broadcast receiver -``` - -The router does not need direct access to the subgraph's Tokio sender. - -The subgraph owns local event production. - -The router sees an ordinary GraphQL subscription stream. - -### 17.3 Event Semantics - -Events are ephemeral. - -If no client is subscribed when an update occurs: - -```text -event is discarded -``` - -This is intended behaviour. - -The authoritative state remains in the database or owning service. - -### 17.4 Buffering - -Broadcast buffers must be bounded. - -A slow consumer may miss events. - -Lagging should be: - -- observable through metrics; -- handled without unbounded memory growth; -- treated as an invalidation loss rather than application-state corruption. - -Clients should refetch authoritative state where necessary. - -## 18. Generated Subscription Example - -An entity: - -```rust -#[derive(GraphQLEntity, GraphQLOperations)] -pub struct Endpoint { - pub id: String, - pub name: String, - pub state: String, -} -``` - -could expose: - -```graphql -type Subscription { - EndpointChanged(Id: String!): EndpointChangedEvent! -} -``` - -A normal frontend subscription: - -```graphql -subscription WatchEndpoint($id: String!) { - EndpointChanged(Id: $id) { - Action - Endpoint { - Id - Name - State - } - } -} -``` - -When the generated mutation updates the row: - -```text -database update - ↓ -EndpointChangedEvent - ↓ -Tokio broadcast - ↓ -GraphQL subscription - ↓ -router - ↓ -Apollo -``` - -No message broker is involved. - -## 19. Horizontal Scaling - -Process-local broadcast does not cross service instances. - -Example: - -```text - Router - / \ - FAME instance 1 FAME instance 2 - │ │ - broadcast A broadcast B -``` - -If instance 1 processes a write while the active subscription is attached to instance 2, instance 2 will not receive that local event. - -This is acceptable for the initial single-active-instance target. - -If horizontal scaling becomes required, a pluggable ephemeral event adapter may be introduced. - -Potential providers: - -```text -Tokio local broadcast -NATS Core -Redis Pub/Sub -PostgreSQL LISTEN/NOTIFY -``` - -The adapter should expose semantics similar to: - -```rust -pub trait ChangeEventBus { - async fn publish(&self, event: ChangeEvent) -> Result<()>; - async fn subscribe(&self, topic: &str) -> Result; -} -``` - -Durable history and replay are explicitly not required by this abstraction. - -## 20. Subgraph Protocol Endpoint - -A preferred generic metadata endpoint may be introduced: - -```text -GET /.well-known/graphql-router -``` - -Example: - -```json -{ - "protocolVersion": { "major": 1, "minor": 0 }, - "subgraph": { "id": "fame-service", "name": "fame" }, - "graphql": { - "http": "http://fame:8080/graphql", - "websocket": "ws://fame:8080/graphql" - }, - "schema": { - "url": "http://fame:8080/sdl" - }, - "capabilities": { - "subscriptions": true, - "authorizationMetadata": true, - "schemaFingerprints": true - }, - "operations": [], - "fingerprints": { - "schema": "sha256:...", - "authorization": "sha256:...", - "combined": "sha256:..." - } -} -``` - -URLs returned by the service must be subject to deployment policy. - -For static configurations, the router may override self-advertised URLs. - -## 21. Dynamic Registration - -An authenticated registration operation may resemble: - -```http -POST /_router/subgraphs -``` - -Payload: - -```json -{ - "name": "fame", - "metadataUrl": "http://fame:8080/.well-known/graphql-router" -} -``` - -Flow: - -```text -service registers - │ - ▼ -verify service credential - │ - ▼ -fetch descriptor - │ - ▼ -fetch SDL - │ - ▼ -validate - │ - ▼ -compose candidate graph - │ - ├── failure - │ └── mark REJECTED - │ - └── success - └── mark ACTIVE -``` - -Dynamic registration must not permit arbitrary untrusted URLs without SSRF controls. - -## 22. SSRF and Network Trust - -The router performs outbound requests to registered subgraphs. - -Therefore: - -- allowed schemes should default to `http` and `https`; -- registration should restrict destinations to configured service networks or allowlists; -- link-local and metadata-service addresses should be rejected unless explicitly configured; -- redirects should be bounded or disabled; -- credentials must not be forwarded to arbitrary hosts; -- a subgraph identity must be bound to its permitted destination. - -## 23. Last-Known-Good Behaviour - -Assume the active graph contains: - -```text -fame schema hash A -ninja schema hash B -zorus schema hash C -``` - -FAME publishes hash D. - -Composition fails. - -The runtime remains: - -```text -fame A -ninja B -zorus C -``` - -The router records: - -```text -fame candidate D: rejected -``` - -It must not produce: - -```text -ninja B -zorus C -``` - -with FAME silently missing. - -## 24. Explicit Removal - -Schema disappearance due to service failure is not removal. - -A subgraph must be removed using an explicit administrative action or configuration change. - -Removal flow: - -```text -request removal - │ - ▼ -compose graph without subgraph - │ - ├── invalid ─► reject removal - │ - └── valid - │ - ▼ - activate new graph -``` - -## 25. Configuration - -Indicative configuration: - -```yaml -server: - listen: "0.0.0.0:4000" - graphql_path: "/graphql" - -composition: - refresh_interval: "10s" - retain_last_known_good: true - -authentication: - required: true - jwks_url: "https://auth.example.com/.well-known/jwks.json" - issuer: "example-auth" - audience: "example-clients" - -subgraphs: - - name: "service-a" - graphql_url: "http://service-a:8080/graphql" - sdl_url: "http://service-a:8080/sdl" - -security: - max_request_body_size: "1MB" - max_depth: 20 - max_fields: 500 - -telemetry: - prometheus: - enabled: true -``` - -Project-specific secret values should be supplied externally. - -## 26. Standalone Binary - -The crate shall expose: - -```text -graphql-orm-router -``` - -Example: - -```text -graphql-orm-router --config router.yaml -``` - -The binary should be sufficient for most projects. - -## 27. Embeddable Library - -Applications must also be able to construct the router programmatically. - -Conceptual API: - -```rust -let router = Router::builder() - .config(config) - .auth_provider(auth_provider) - .build() - .await?; - -router.serve().await?; -``` - -This allows project-specific wrappers without modifying the generic router. - -Example: - -```text -gema-router - │ - └── depends on graphql-orm-router -``` - -A GEMA wrapper should only be needed if GEMA requires behaviour that is genuinely outside the generic configuration model. - -## 28. Observability - -Structured tracing should include: - -- graph version; -- operation name; -- operation type; -- selected subgraphs; -- execution duration; -- composition attempt ID; -- schema fingerprint; -- authorization decision category. - -It must not include: - -- raw JWTs; -- refresh tokens; -- API tokens; -- private keys; -- arbitrary GraphQL variable bodies by default. - -Metrics should include: - -```text -router_graphql_requests_total -router_graphql_errors_total -router_subgraph_requests_total -router_subgraph_latency_seconds -router_websocket_connections -router_active_subscriptions -router_subscription_lagged_total -router_schema_refresh_total -router_composition_success_total -router_composition_failure_total -router_authorization_denied_total -router_subgraph_health -``` - -## 29. Failure Handling - -### Subgraph Unavailable - -Return an appropriate downstream GraphQL error. - -Do not mutate the graph schema. - -### SDL Unavailable - -Retain last-known-good SDL. - -Record health failure. - -### Candidate Composition Failure - -Retain active graph. - -Record the composition error. - -### Invalid JWT - -Reject before downstream execution. - -### Missing Scope - -Reject before protected downstream execution. - -### Subgraph Guard Rejects Router-Accepted Request - -Return the downstream authorization error. - -This is expected defence-in-depth behaviour and should be observable as router/subgraph authorization disagreement. - -### WebSocket Upstream Failure - -Close or error affected subscriptions without affecting unrelated graph operations. - -## 30. Testing Strategy - -### 30.1 Protocol Tests - -Test: - -- serialization; -- version compatibility; -- deterministic fingerprints; -- unknown fields; -- incompatible major versions. - -### 30.2 Composition Tests - -Test: - -- initial graph composition; -- compatible field addition; -- incompatible field change; -- subgraph addition; -- explicit removal; -- failed candidate; -- last-known-good retention. - -### 30.3 HTTP Federation Tests - -Test: - -- single-subgraph query; -- multi-subgraph query; -- entity resolution; -- mutation routing; -- propagated authentication. - -### 30.4 Authorization Tests - -Test: - -- no token; -- invalid token; -- expired token; -- correct scope; -- incorrect scope; -- any-of scopes; -- all-of scopes; -- hierarchical matcher where enabled; -- argument-templated scope; -- unresolved template; -- router/subgraph equivalence. - -### 30.5 Subscription Tests - -Test: - -- WebSocket authentication; -- generated subscription establishment; -- generated write; -- live event receipt; -- filtered entity subscription; -- multiple subscribers; -- disconnected client; -- no replay; -- lagging consumer; -- token expiry; -- scope rejection. - -### 30.6 Security Tests - -Test: - -- unauthorized registration; -- SSRF destination rejection; -- invalid SDL; -- oversized request; -- excessive query depth; -- excessive field count; -- token redaction in logs; -- administrative endpoint protection. - -## 31. Required Changes to graphql-orm - -Implementation work should include: - -1. Extend existing operation metadata with router authorization descriptors. -2. Add deterministic authorization fingerprints. -3. Ensure generated subscriptions expose standard GraphQL subscription fields independently of NATS. -4. Confirm generated change events use process-local event senders suitable for Tokio streams. -5. Ensure subscription resolvers receive normal async-graphql request context. -6. Ensure generated guards execute for subscriptions. -7. Generate fixed-scope Federation authorization directives where appropriate. -8. Generate parameterised-scope router metadata. -9. Add optional `graphql-orm-router-protocol` integration. -10. Add router compatibility integration tests. - -## 32. Required Changes to graphql-orm-macros - -Implementation work should include: - -1. Generate canonical operation authorization metadata. -2. Generate router protocol descriptors. -3. Validate scope template syntax. -4. Validate referenced root-field arguments where possible. -5. Include generated subscriptions in operation catalogues. -6. Produce deterministic metadata ordering. -7. Avoid generating router-specific runtime dependencies into applications that do not enable router integration. - -## 33. agql-auth Integration and Upstream Changes - -The exact-pinned revision already provides: - -1. `AccessTokenValidator` for public-key/JWKS RS256 validation; -2. WebSocket `connection_init` authentication; -3. exact scope matching through its existing authorization runtime; and -4. issuer-side private-PEM loading and token signing through `AuthService`, - which remains outside the router. - -The separately authorized `agql-auth` 0.14 work now provides: - -1. Emit standards-compatible space-delimited `scope` claims for new tokens. -2. Support legacy `scopes` during transition. -3. Define conflict behaviour when both claims are present. -4. Preserve fail-closed validation and exact matching by default. -5. Router interoperability tests covering HTTP and WebSocket validation. - -The workspace pins that contract at -`413fda3435f060604cd653c11e2cc18a668aace1`. Purpose tokens retain their -separate `scopes` array; the router consumes access tokens only. - -No router requirement calls for RSA private-key decryption. If a future -identity-service requirement introduces it, that is an issuer-side security -decision and not part of the router integration. - -## 34. Required Changes to GEMA - -The GEMA migration should eventually: - -1. Add `graphql-orm-router` as the federation router. -2. Route frontend `/api/graphql` or equivalent to the new router. -3. Route frontend WebSocket GraphQL to the new router. -4. Migrate Cosmo-specific `WsAuthorization` behaviour to the standard connection payload if required. -5. Remove Cosmo Router configuration. -6. Remove `execution-config.json`. -7. Remove WGC invocation. -8. Remove Cosmo configuration rendering scripts. -9. Remove the custom Go subscription authorization module. -10. Replace EDFS/NATS-generated event subscriptions with native subgraph subscriptions. -11. Remove `edfs-kit` where no other functionality requires it. -12. Remove NATS server startup where no other workload requires it. -13. Remove NATS credential and ACL generation. -14. Remove JetStream health and monitoring logic. -15. Preserve existing scope semantics. -16. Preserve direct subgraph authorization. -17. Preserve existing Apollo client query, mutation and subscription behaviour. - -## 35. Migration Strategy - -Recommended staged implementation: - -### Phase 1 — Protocol and Authorization Contract - -Build: - -```text -graphql-orm-router-protocol -graphql-orm metadata changes -agql-auth scope interoperability -``` - -No GEMA runtime changes yet. - -### Phase 2 — Native graphql-orm Subscriptions - -Ensure generated subscriptions operate entirely through local Tokio broadcast and async-graphql. - -Prove: - -```text -write → subscription event -``` - -without NATS. - -### Phase 3 — Router Prototype - -Implement: - -- static subgraphs; -- composition; -- HTTP federation; -- WebSocket federation; -- JWT validation; -- router scope enforcement. - -### Phase 4 — Schema Lifecycle - -Add: - -- automatic SDL polling; -- fingerprints; -- candidate graph composition; -- last-known-good activation; -- dynamic registration. - -### Phase 5 — GEMA Parallel Runtime - -Run: - -```text -Cosmo/NATS production path - -and - -graphql-orm-router test path -``` - -against the same subgraphs where practical. - -Compare: - -- schema; -- query output; -- authorization; -- subscription behaviour. - -### Phase 6 — Remove NATS GraphQL Notifications - -Move GEMA subscriptions to native subgraph WebSocket subscriptions. - -Remove EDFS dependency from those paths. - -### Phase 7 — Replace Cosmo - -Make `graphql-orm-router` the public GraphQL endpoint. - -Retain rollback capability during initial deployment. - -### Phase 8 — Cleanup - -Remove obsolete: - -- Cosmo runtime; -- WGC; -- NATS GraphQL infrastructure; -- JetStream GraphQL configuration; -- Go auth module; -- EDFS schema generation; -- obsolete deployment scripts. - -## 36. Future Extensions - -Potential future features include: - -- optional Redis/NATS Core/Postgres subscription fan-out; -- multiple router instances sharing graph registry state; -- schema push notifications; -- persisted operations; -- configurable operation allowlists; -- router plugin API; -- distributed graph registry; -- UI/CLI graph inspection; -- schema compatibility checks in CI; -- graph history and rollback. - -These should remain outside the minimum initial implementation. - -## 37. Final Target - -The intended reusable architecture is: - -```text - Client - │ - HTTP / WebSocket - │ - ▼ - ┌──────────────────────┐ - │ graphql-orm-router │ - │ │ - │ Federation engine │ - │ Composition │ - │ Registry │ - │ JWT validation │ - │ Scope enforcement │ - └──────────┬───────────┘ - │ - ┌───────────┼──────────────┐ - ▼ ▼ ▼ - Service A Service B Service C - GraphQL GraphQL GraphQL - │ │ │ - ├ ORM ├ ORM └ custom - ├ agql-auth ├ agql-auth - └ broadcast └ broadcast -``` - -For GEMA this becomes: - -```text -Apollo - │ - ▼ -graphql-orm-router - │ - ├── fame-service - ├── ninja-service - ├── huntress-service - ├── zorus-service - ├── cove-service - ├── ninite-service - └── other GraphQL services -``` - -with no Cosmo Router and no NATS/JetStream requirement for GraphQL subscriptions. - -The router owns federation and graph lifecycle. - -The subgraphs own their data, authentication enforcement and live change streams. - -`graphql-orm` supplies generated GraphQL surfaces and canonical policy metadata. - -`agql-auth` supplies interoperable authentication and scope semantics. diff --git a/docs/plans/backlog/router-subgraph-lifecycle-resilience/README.md b/docs/plans/backlog/router-subgraph-lifecycle-resilience/README.md index 39914cc1..a40daa5c 100644 --- a/docs/plans/backlog/router-subgraph-lifecycle-resilience/README.md +++ b/docs/plans/backlog/router-subgraph-lifecycle-resilience/README.md @@ -183,6 +183,6 @@ last-known-good retention, explicit validated removal, bounded downstream timeouts, and subscription recovery signals. The lifecycle-intent protocol, execution circuit breaker, stable transport-outage error contract, persistent cold-start recovery, and corrected multi-dimensional health reporting remain -backlog work. GEMA's initial testing migration may require every static +backlog work. An initial consumer migration may require every static subgraph to be reachable at router startup until this backlog is promoted and implemented. diff --git a/docs/plans/completed/ai-provider-sessions-and-hosted-tools/README.md b/docs/plans/completed/ai-provider-sessions-and-hosted-tools/README.md new file mode 100644 index 00000000..0d2854af --- /dev/null +++ b/docs/plans/completed/ai-provider-sessions-and-hosted-tools/README.md @@ -0,0 +1,64 @@ +--- +title: AI provider sessions, hosted tools, and visible activity completion +kind: plan +status: accepted +owner: graphql-orm-ai-maintainers +last_reviewed: 2026-08-11 +review_by: 2027-02-11 +supersedes: [] +--- + +# AI provider sessions, hosted tools, and visible activity + +## Outcome + +`graphql-orm-ai` now provides project-neutral contracts for protected +provider-retained sessions, bounded local app-server execution, +provider-hosted web search, visible reasoning summaries, and one ordered +durable activity stream without transferring application authority into a +provider process. + +## Delivered boundaries + +- Exact run-bound local-process admission, bounded reuse, interruption, + terminal cleanup, strict protocol allowlisting, and kill-on-drop. +- Protected provider-session bindings with canonical transcript watermarks, + owner/scope/profile/model/protocol/policy fencing, bounded retention, exact + cleanup, and fail-closed restore audit. +- Host-requested visible reasoning summaries clearly separated from hidden + reasoning, with protected persistence, replay, cancellation, and limits. +- Provider-retained mixed hosted-search and registered application tools, + explicit public/allowed/blocked domain policy, authoritative HTTPS citation + provenance, and cumulative per-run search ceilings. +- Default-off experimental synchronous dynamic tools that enter only through + the coordinator's existing registered-operation, current-principal, + disclosure, egress, budget, cancellation, and resolver-authorization path. +- A strict Codex app-server adapter supporting protected thread + create/resume/interrupt/delete and the exact bounded lifecycle messages + required by the reviewed protocol. It exposes no generic JSON-RPC, shell, + filesystem, browser, screenshot, remote-control, MCP, or arbitrary tool + capability. + +Warm operating-system processes and provider-retained threads remain separate +policies. Application credentials and delegated GraphQL authority never enter +the provider process. + +## Acceptance evidence + +- Package tests cover strict protocol admission, correlation, replay, + cancellation, stale fences, provider-session cleanup, mixed tools, search + ceilings, citations, visible summaries, and negative capability space. +- SQLite and disposable PostgreSQL persistence lanes, MSSQL compile lanes, + provider matrices, PascalCase GraphQL, Clippy, Rustdoc, SemVer, release + policy, and documentation checks are part of CI and the workspace release + workflow. +- Package version 0.73.0 uses AI schema module 0.55.0 for the completed + milestone. + +## Deferred work + +Cross-owner process or thread multiplexing remains unsupported until a +provider protocol and isolation assessment prove it safe. A visual-browser +broker remains a distinct future capability boundary described in the +component architecture; it is not part of hosted web search or this completed +plan. diff --git a/docs/plans/completed/graphql-orm-router/README.md b/docs/plans/completed/graphql-orm-router/README.md new file mode 100644 index 00000000..9405be3e --- /dev/null +++ b/docs/plans/completed/graphql-orm-router/README.md @@ -0,0 +1,61 @@ +--- +title: GraphQL ORM Router implementation completion +kind: plan +status: accepted +owner: graphql-orm-router-maintainers +last_reviewed: 2026-08-11 +review_by: 2027-02-11 +supersedes: [] +--- + +# GraphQL ORM Router implementation + +## Outcome + +The workspace provides independently consumable +`graphql-orm-router-protocol` and `graphql-orm-router` packages for one +project-neutral federated GraphQL HTTP and WebSocket boundary. The router +publishes only completely validated graph candidates, preserves a +last-known-good graph on failure, and treats router authorization as +defence-in-depth before authoritative subgraph enforcement. + +## Delivered boundaries + +- Versioned engine-neutral subgraph declarations and optional descriptor + extensions with deterministic canonical fingerprints. +- Exact-pinned private Federation composition/execution adapters that do not + leak engine types through public APIs. +- Atomic static and dynamic graph lifecycle, bounded conditional polling, + stale-attempt rejection, and immutable in-flight graph selection. +- Authenticated HTTP and `graphql-transport-ws`, fixed and templated scope + preflight, resource-server-only JWKS validation, and authoritative subgraph + guards. +- Identity-bound administrative operations, deny-by-default destination and + SSRF controls, strict configuration, bounded requests/subscriptions, + telemetry, metrics, and graceful shutdown. +- Explicit ephemeral subscription semantics: no durable replay or + cross-instance fan-out is implied. +- Router protocol 0.2.0 and router 0.1.3 preserve optional descriptor + extensions through registration, graph input hashing, and atomic + publication without interpreting application payloads. + +## Acceptance evidence + +- Test-owned loopback suites cover composition, HTTP, WebSocket, + authorization denial before downstream work, graph replacement, + subscription retirement, connection containment, SSRF resistance, JWKS + rotation/outage, resource bounds, and shutdown. +- CI covers default and optional authentication profiles, MSRV, warnings-denied + Clippy/Rustdoc, dependency direction, documentation, and package release + policy. +- ADR-0008 retains the exact engine boundary and requires separate + artifact-specific SBOM, notices, advisory, native-component, hash, target, + and distribution approval for every compiled delivery. + +## Follow-up + +Durable multi-instance registration, cross-instance subscription fan-out, and +subgraph lifecycle coordination remain separately bounded backlog topics. +Current mechanics and operating instructions live in the component README, +schema/reconnect guidance, threat model, troubleshooting, and operations +runbook rather than this completed plan. diff --git a/docs/reference/graphql-orm/backends.md b/docs/reference/graphql-orm/backends.md index 82bff928..1b559979 100644 --- a/docs/reference/graphql-orm/backends.md +++ b/docs/reference/graphql-orm/backends.md @@ -164,7 +164,7 @@ Cargo feature unification can enable more than one backend on the same `graphql- example, one workspace can have: - `auth-service` using SQLite -- `jim-service` using SQL Server +- `legacy-reporting-service` using SQL Server In that mode, backend selection must be explicit: diff --git a/docs/reference/graphql-orm/entities-and-relations.md b/docs/reference/graphql-orm/entities-and-relations.md index adfba1ea..cb135b17 100644 --- a/docs/reference/graphql-orm/entities-and-relations.md +++ b/docs/reference/graphql-orm/entities-and-relations.md @@ -77,12 +77,12 @@ Composite primary keys are declared by marking more than one field: ```rust #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] -#[graphql_entity(table = "JimLabour", plural = "JimLabourEntries")] -pub struct JimLabourEntry { +#[graphql_entity(table = "LegacyLabour", plural = "LegacyLabourEntries")] +pub struct LegacyLabourEntry { #[primary_key] - #[graphql(name = "JimObjectType")] - #[graphql_orm(db_column = "JimObjectType", write = false)] - pub jim_object_type: i32, + #[graphql(name = "LegacyObjectType")] + #[graphql_orm(db_column = "LegacyObjectType", write = false)] + pub legacy_object_type: i32, #[primary_key] #[graphql(name = "RefNo")] @@ -100,8 +100,8 @@ Composite-key lookups use one argument per key field: ```graphql query { - jimLabourEntry(jimObjectType: 1, refNo: 12345, lineNum: 2) { - jimObjectType + legacyLabourEntry(legacyObjectType: 1, refNo: 12345, lineNum: 2) { + legacyObjectType refNo lineNum } @@ -282,13 +282,13 @@ the `to` entries are target database columns: ```rust #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] -pub details: Vec, +pub details: Vec, ``` The macro validates: @@ -303,7 +303,7 @@ database when the generated query runs. ## Nested Relation Batching Selected relation fields are loaded in batches by relation layer. A query shaped like -`JimCardFiles -> Contacts -> Details` performs: +`LegacyCardFiles -> Contacts -> Details` performs: 1. one parent query for card files 2. one relation query for all selected contacts diff --git a/docs/reference/graphql-orm/mssql.md b/docs/reference/graphql-orm/mssql.md index fe1ec059..df767475 100644 --- a/docs/reference/graphql-orm/mssql.md +++ b/docs/reference/graphql-orm/mssql.md @@ -162,16 +162,16 @@ Composite primary keys are supported for read paths by marking each key field wi #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] #[graphql_entity( backend = "mssql", - table = "dbo.JimLabour", - plural = "JimLabourEntries", + table = "dbo.LegacyLabour", + plural = "LegacyLabourEntries", schema_policy = "external_read_only", - default_sort = "[JimObjectType] ASC, [RefNo] ASC, [LineNum] ASC" + default_sort = "[LegacyObjectType] ASC, [RefNo] ASC, [LineNum] ASC" )] -pub struct JimLabourEntry { +pub struct LegacyLabourEntry { #[primary_key] - #[graphql(name = "JimObjectType")] - #[graphql_orm(db_column = "JimObjectType", write = false)] - pub jim_object_type: i32, + #[graphql(name = "LegacyObjectType")] + #[graphql_orm(db_column = "LegacyObjectType", write = false)] + pub legacy_object_type: i32, #[primary_key] #[graphql(name = "RefNo")] @@ -193,8 +193,8 @@ The generated single lookup uses one argument per key field and binds them in de ```graphql query { - jimLabourEntry(jimObjectType: 1, refNo: 12345, lineNum: 2) { - jimObjectType + legacyLabourEntry(legacyObjectType: 1, refNo: 12345, lineNum: 2) { + legacyObjectType refNo lineNum labourDate @@ -203,9 +203,9 @@ query { ``` With Pascal-case resolver, argument, and field features, the same lookup is exposed as -`JimLabourEntry(JimObjectType: ..., RefNo: ..., LineNum: ...)`. +`LegacyLabourEntry(LegacyObjectType: ..., RefNo: ..., LineNum: ...)`. -The generated repository key type is `JimLabourEntryKey`, and read helpers include `find_by_key` and +The generated repository key type is `LegacyLabourEntryKey`, and read helpers include `find_by_key` and `get_by_key`. `PRIMARY_KEY` remains the first key for compatibility; use `PRIMARY_KEYS` or `Entity::metadata().primary_keys` when code needs the full key. Pagination cursors are offset-based today, so composite keys do not change cursor encoding. @@ -252,12 +252,12 @@ Composite relations use array syntax. `from` lists Rust source fields on the cur #[graphql(complex)] #[graphql_entity( backend = "mssql", - table = "dbo.JimCardFileContacts", - plural = "JimCardFileContacts", + table = "dbo.LegacyCardFileContacts", + plural = "LegacyCardFileContacts", schema_policy = "external_read_only", default_sort = "[CardNo] ASC, [ContNo] ASC" )] -pub struct JimCardFileContact { +pub struct LegacyCardFileContact { #[primary_key] #[graphql(name = "CardNo")] #[graphql_orm(db_column = "CardNo", write = false)] @@ -270,45 +270,45 @@ pub struct JimCardFileContact { #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] - pub details: Vec, + pub details: Vec, } ``` -The Jim card-file shape can be mapped as: +The Legacy card-file shape can be mapped as: ```rust #[graphql(skip, name = "Contacts")] #[relation( - target = "JimCardFileContact", + target = "LegacyCardFileContact", from = "card_no", to = "CardNo", multiple, emit_fk = false )] -pub contacts: Vec, +pub contacts: Vec, #[graphql(skip, name = "Details")] #[relation( - target = "JimCardFileDetail", + target = "LegacyCardFileDetail", from = ["card_no", "cont_no"], to = ["CardNo", "ContNo"], multiple, emit_fk = false )] -pub details: Vec, +pub details: Vec, ``` With Pascal-case feature flags, nested reads keep the expected legacy GraphQL shape: ```graphql query { - JimCardFiles { + LegacyCardFiles { Edges { Node { CardNo @@ -414,7 +414,8 @@ docker run --rm -e ACCEPT_EULA=Y \ mcr.microsoft.com/mssql/server:2022-latest ``` -Do not run migrations or generated writes against Jim or other legacy SQL Server databases in this +Do not run migrations or generated writes against managed production or other +legacy SQL Server databases in this phase. Use `SchemaPolicy::ExternalReadOnly` at runtime and `schema_policy = "external_read_only"` in the entity/root macros. The migration path is to port one simple read-only entity first, then a relation-heavy entity, and only then replace the old local SQL Server-specific GraphQL read path diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index c402c78d..4c703c8d 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.21.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog` | -| `graphql-orm-ai` | `0.73.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.75.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.3.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.21.0` | `crates/graphql-orm-macros` | `sqlite` | none | diff --git a/release.toml b/release.toml new file mode 100644 index 00000000..9449f659 --- /dev/null +++ b/release.toml @@ -0,0 +1,11 @@ +format_version = 1 +repository = "Dastari/graphql-orm" +distribution = "git" +workspace_tag_prefix = "workspace-" +package_tag_template = "{package}-v{version}" + +[router_artifact] +package = "graphql-orm-router" +binary = "graphql-orm-router" +features = ["auth-agql"] +target = "x86_64-unknown-linux-gnu" diff --git a/scripts/check-documentation.py b/scripts/check-documentation.py index 96d36a96..3cbe3cd6 100644 --- a/scripts/check-documentation.py +++ b/scripts/check-documentation.py @@ -31,7 +31,6 @@ HEADING_RE = re.compile(r"^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$", re.MULTILINE) STALE_PATTERNS = { "retired frontend path": re.compile(r"(? list[Path]: @@ -190,6 +193,21 @@ def validate_stale_paths(path: Path, text: str, metadata: dict[str, str], errors errors.append(f"{path}:{line}: {label}: {match.group(0)!r}") +def validate_project_neutrality( + path: Path, text: str, metadata: dict[str, str], errors: list[str] +) -> None: + if metadata.get("status") in {"archived", "superseded"}: + return + if path in IMMUTABLE_HISTORICAL_EXCEPTIONS: + return + match = CONSUMER_SPECIFIC_RE.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + errors.append( + f"{path}:{line}: maintained documentation must use project-neutral examples" + ) + + def validate_adrs(files: list[Path], metadata_by_path: dict[Path, dict[str, str]], base: str | None, errors: list[str]) -> None: numbers: dict[str, Path] = {} for path in files: @@ -245,6 +263,7 @@ def main() -> int: metadata_by_path[path] = metadata validate_links(path, text, errors) validate_stale_paths(path, text, metadata, errors) + validate_project_neutrality(path, text, metadata, errors) validate_adrs(files, metadata_by_path, args.base, errors) if errors: diff --git a/scripts/check-release-state.py b/scripts/check-release-state.py new file mode 100644 index 00000000..459bca37 --- /dev/null +++ b/scripts/check-release-state.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Check source-release invariants that are independent of a release ID.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +def main() -> int: + metadata = json.loads( + subprocess.run( + ["cargo", "metadata", "--format-version", "1", "--no-deps", "--locked"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout + ) + members = set(metadata["workspace_members"]) + errors: list[str] = [] + for package in sorted( + (package for package in metadata["packages"] if package["id"] in members), + key=lambda package: package["name"], + ): + if not VERSION_RE.fullmatch(package["version"]): + errors.append(f"{package['name']}: invalid package version {package['version']}") + if package.get("publish") != []: + errors.append( + f"{package['name']}: Git-only workspace packages must set publish = false" + ) + if package.get("repository") != "https://github.com/Dastari/graphql-orm": + errors.append(f"{package['name']}: repository metadata is missing or inconsistent") + if not package.get("description"): + errors.append(f"{package['name']}: package description is required") + if package.get("license") != "MIT": + errors.append(f"{package['name']}: expected MIT package metadata") + orm = next(package for package in metadata["packages"] if package["name"] == "graphql-orm") + macros = next( + package for package in metadata["packages"] if package["name"] == "graphql-orm-macros" + ) + if orm["version"] != macros["version"]: + errors.append("graphql-orm and graphql-orm-macros versions must remain aligned") + if errors: + print("Release-state validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print(f"Release-state validation passed for {len(members)} workspace packages.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-release-manifest.py b/scripts/generate-release-manifest.py new file mode 100644 index 00000000..01c43db7 --- /dev/null +++ b/scripts/generate-release-manifest.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Generate and validate an immutable workspace release bill of materials.""" + +from __future__ import annotations + +import argparse +from datetime import date +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys +import tomllib +from typing import Any +from urllib.parse import parse_qs, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / "release.toml" +RELEASE_ID_RE = re.compile(r"^workspace-\d{4}\.\d{2}\.\d{2}\.\d+$") +FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def run(*args: str, check: bool = True) -> str: + result = subprocess.run( + list(args), + cwd=ROOT, + check=check, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def load_config() -> dict[str, Any]: + with CONFIG.open("rb") as handle: + config = tomllib.load(handle) + if config.get("format_version") != 1: + raise SystemExit("release.toml: unsupported format_version") + if config.get("distribution") != "git": + raise SystemExit("release.toml: only the reviewed Git distribution is supported") + if config.get("workspace_tag_prefix") != "workspace-": + raise SystemExit("release.toml: workspace_tag_prefix must remain workspace-") + if config.get("package_tag_template") != "{package}-v{version}": + raise SystemExit("release.toml: unsupported package_tag_template") + return config + + +def cargo_metadata() -> dict[str, Any]: + return json.loads( + run("cargo", "metadata", "--format-version", "1", "--no-deps", "--locked") + ) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def source_at(commit: str, relative_path: str) -> str: + return run("git", "rev-parse", f"{commit}:{relative_path}") + + +def exists_at(commit: str, relative_path: str) -> bool: + result = subprocess.run( + ["git", "cat-file", "-e", f"{commit}:{relative_path}"], + cwd=ROOT, + capture_output=True, + ) + return result.returncode == 0 + + +def exact_commit(ref: str) -> str: + commit = run("git", "rev-parse", f"{ref}^{{commit}}") + if not FULL_SHA_RE.fullmatch(commit): + raise SystemExit(f"release ref did not resolve to a full commit: {ref}") + return commit + + +def require_clean_worktree() -> None: + if run("git", "status", "--porcelain"): + raise SystemExit("release manifests must be generated from a clean worktree") + + +def parse_contracts(commit: str) -> list[dict[str, str]]: + contracts = ( + ( + "graphql-orm-ai-schema-module", + "crates/graphql-orm-ai/src/persistence.rs", + r'AI_SCHEMA_MODULE_VERSION:\s*&str\s*=\s*"([^"]+)"', + ), + ( + "graphql-orm-router-protocol", + "crates/graphql-orm-router-protocol/src/version.rs", + r"SUPPORTED_PROTOCOL_VERSION:.*?major:\s*(\d+),\s*minor:\s*(\d+)", + ), + ( + "graphql-orm-ai-tool-manifest", + "crates/graphql-orm-ai-tool-profiles/src/profiles.rs", + r"AI_GRAPHQL_TOOL_MANIFEST_VERSION:\s*u16\s*=\s*(\d+)", + ), + ( + "graphql-orm-operation-assurance-manifest", + "crates/graphql-orm/src/graphql/assurance.rs", + r"OPERATION_ASSURANCE_MANIFEST_VERSION:\s*u32\s*=\s*(\d+)", + ), + ) + values: list[dict[str, str]] = [] + for name, path, pattern in contracts: + text = run("git", "show", f"{commit}:{path}") + match = re.search(pattern, text, re.DOTALL) + if match is None: + raise SystemExit(f"could not read {name} from {path}") + version = ".".join(match.groups()) + values.append({"name": name, "version": version}) + return values + + +def package_kind(package: dict[str, Any]) -> list[str]: + kinds = { + kind + for target in package["targets"] + for kind in target["kind"] + if kind not in {"example", "test", "bench", "custom-build"} + } + return sorted(kinds) + + +def manifest_packages( + metadata: dict[str, Any], config: dict[str, Any], commit: str +) -> list[dict[str, Any]]: + members = set(metadata["workspace_members"]) + packages = [package for package in metadata["packages"] if package["id"] in members] + packages.sort(key=lambda package: package["name"]) + package_tag_template = config["package_tag_template"] + result: list[dict[str, Any]] = [] + for package in packages: + if package.get("publish") != []: + raise SystemExit( + f"{package['name']}: every Git-only workspace package must set publish = false" + ) + manifest_path = Path(package["manifest_path"]) + relative_manifest = manifest_path.relative_to(ROOT).as_posix() + package_path = manifest_path.parent.relative_to(ROOT).as_posix() + package_changelog = f"{package_path}/CHANGELOG.md" + changelog_path = ( + package_changelog if exists_at(commit, package_changelog) else "CHANGELOG.md" + ) + result.append( + { + "changelogPath": changelog_path, + "manifestPath": relative_manifest, + "name": package["name"], + "packageSourceTree": source_at(commit, package_path), + "tag": package_tag_template.format( + package=package["name"], version=package["version"] + ), + "targets": package_kind(package), + "version": package["version"], + } + ) + return result + + +def external_git_dependencies(metadata: dict[str, Any]) -> list[dict[str, Any]]: + workspace_members = set(metadata["workspace_members"]) + aggregated: dict[tuple[str, str, str], set[str]] = {} + for package in metadata["packages"]: + if package["id"] not in workspace_members: + continue + for dependency in package["dependencies"]: + source = dependency.get("source") or "" + if not source.startswith("git+"): + continue + parsed = urlsplit(source.removeprefix("git+")) + revisions = parse_qs(parsed.query).get("rev", []) + revision = revisions[0] if len(revisions) == 1 else "" + if not FULL_SHA_RE.fullmatch(revision): + raise SystemExit( + f"{package['name']}: Git dependency {dependency['name']} is not exact-revision resolved" + ) + url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + key = (dependency["name"], url, revision) + aggregated.setdefault(key, set()).add(package["name"]) + return [ + { + "consumers": sorted(consumers), + "name": name, + "revision": revision, + "url": url, + } + for (name, url, revision), consumers in sorted(aggregated.items()) + ] + + +def verify_package_tags(packages: list[dict[str, Any]], commit: str) -> None: + local_tags = set(run("git", "tag", "--list").splitlines()) + for package in packages: + tag = package["tag"] + if tag not in local_tags: + continue + tagged_commit = exact_commit(tag) + package_path = str(Path(package["manifestPath"]).parent) + tagged_tree = source_at(tagged_commit, package_path) + current_tree = source_at(commit, package_path) + if tagged_tree != current_tree: + raise SystemExit( + f"{tag}: package source changed without a new package version" + ) + + +def build_manifest(release_id: str, ref: str, verify_tags: bool) -> dict[str, Any]: + match = RELEASE_ID_RE.fullmatch(release_id) + if match is None: + raise SystemExit( + "release ID must match workspace-YYYY.MM.DD.N (for example workspace-2026.08.11.1)" + ) + date_text, sequence_text = release_id.removeprefix("workspace-").rsplit(".", 1) + try: + date.fromisoformat(date_text.replace(".", "-")) + except ValueError as error: + raise SystemExit(f"release ID contains an invalid calendar date: {date_text}") from error + if int(sequence_text) < 1: + raise SystemExit("release ID sequence must be at least 1") + config = load_config() + commit = exact_commit(ref) + if commit != exact_commit("HEAD"): + raise SystemExit("check out the selected release commit before generating its manifest") + metadata = cargo_metadata() + packages = manifest_packages(metadata, config, commit) + if verify_tags: + verify_package_tags(packages, commit) + return { + "artifactProfiles": {"router": config["router_artifact"]}, + "contracts": parse_contracts(commit), + "distribution": { + "crateRegistryPublishing": False, + "kind": config["distribution"], + }, + "externalGitDependencies": external_git_dependencies(metadata), + "formatVersion": config["format_version"], + "packages": packages, + "releaseId": release_id, + "repository": config["repository"], + "source": { + "cargoLockSha256": file_sha256(ROOT / "Cargo.lock"), + "commit": commit, + }, + } + + +def render_notes(manifest: dict[str, Any]) -> str: + source_url = ( + f"https://github.com/{manifest['repository']}/blob/" + f"{manifest['source']['commit']}" + ) + lines = [ + f"# {manifest['releaseId']}", + "", + "This is an immutable, Git-only release of the tested workspace package set.", + f"Consumers must pin the full commit `{manifest['source']['commit']}`.", + "Package tags are identity aids and do not replace the commit pin.", + "", + "## Packages", + "", + "| Package | Version | Tag | Changes |", + "| --- | --- | --- | --- |", + ] + for package in manifest["packages"]: + lines.append( + f"| `{package['name']}` | `{package['version']}` | `{package['tag']}` | " + f"[changelog]({source_url}/{package['changelogPath']}) |" + ) + lines.extend( + [ + "", + "## Wire and persistence contracts", + "", + "| Contract | Version |", + "| --- | --- |", + ] + ) + for contract in manifest["contracts"]: + lines.append(f"| `{contract['name']}` | `{contract['version']}` |") + lines.extend( + [ + "", + "The attached JSON manifest is the canonical release bill of materials.", + ] + ) + return "\n".join(lines) + "\n" + + +def write_or_print(content: str, output: Path | None) -> None: + if output is None: + sys.stdout.write(content) + return + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(content, encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--release-id", required=True) + parser.add_argument("--ref", default="HEAD") + parser.add_argument("--output", type=Path) + parser.add_argument("--notes-output", type=Path) + parser.add_argument("--check-clean", action="store_true") + parser.add_argument("--verify-tags", action="store_true") + args = parser.parse_args() + if args.check_clean: + require_clean_worktree() + manifest = build_manifest(args.release_id, args.ref, args.verify_tags) + serialized = json.dumps(manifest, indent=2, sort_keys=True) + "\n" + write_or_print(serialized, args.output) + if args.notes_output is not None: + write_or_print(render_notes(manifest), args.notes_output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())