Skip to content

THOTH-GQL-BATCH-01: request-scoped GraphQL batching foundation and mutation request guard - #791

Merged
ja573 merged 7 commits into
developfrom
feature/shared-architecture/graphql-batching
Aug 10, 2026
Merged

THOTH-GQL-BATCH-01: request-scoped GraphQL batching foundation and mutation request guard#791
ja573 merged 7 commits into
developfrom
feature/shared-architecture/graphql-batching

Conversation

@ja573

@ja573 ja573 commented Aug 8, 2026

Copy link
Copy Markdown
Member

Authority

Programme Shared Thoth GraphQL / Backend Architecture
Task THOTH-GQL-BATCH-01 - Request-scoped GraphQL batching foundation
Risk HIGH
Governing decision ADR-0006 (APPROVED, repository-authoritative)
Specification CTO-approved, recorded on PR #789 as comment 5225973903
Implementation authorization Explicit CTO authorization granted 2026-08-08, recorded on merged PR #789 as comment 5226006860
Authorized exact base df2e2efef176716e8c8d523457b30e3deebab770
Target develop

Implementation authorization is not merge authorization and not production activation authorization.

The merged state is inert

guard mode                = OFF
loader store              = unavailable (derived from the mode)
added request-path work   = none
production request accept = unchanged

Store availability is derived from the single MutationGuardMode value, so OFF + store enabled and OBSERVE + store enabled are structurally unrepresentable — there is no second enable flag anywhere in the codebase.

What this adds

  • Store (batching.rs) — request-scoped, keyed by (top-level response key, loader identity, normalized load shape, parent key) uniformly for queries and mutation payloads. Three unambiguous states NotLoaded / Loaded (including Loaded([])) / LoadFailed; only NotLoaded may direct-fallback. Non-destructive reads, set-based dispatch over a de-duplicated key set, retained non-retried failures, whole-store invalidation.
  • Mutation guard (mutation_guard.rs) — OFF / OBSERVE / ENFORCE, wired at the HTTP boundary before ordinary Juniper execution. OFF returns before parsing. A baseline eligibility gate reproduces pinned juniper 0.16.2's own pipeline using juniper's own helpers, so a baseline-invalid request yields no decision, no event and no guard error, and juniper remains the sole authority for its error.
  • Scope shim (scope.rs) — the only new_error(..) -> path() response-scope extraction site; side-effect-free, fail-closed, with the pinned-Juniper coupling and revalidation obligation documented and tested.
  • Prefetch (prefetch.rs) — look-ahead-driven set-based prefetch covering direct and descendant paths, alias-safe on field_original_name() at every segment, collecting every matching terminal selection, writing into the ordinary terminal namespace.

Boundaries held

Evidence (local, exact head)

SQL statement counts observed at the driver via Diesel instrumentation, through a fresh pool constructed after the hook was installed (never the OnceLock test pool):

Path n terminal/prefetch stmts direct baseline legacy intermediate
direct 3 1 3
direct 7 1 7
descendant 3 2 (list + 1 dispatch) 6
descendant 6 2 12
mutation fan-out 3 / 6 1 dispatch, 0 fallbacks
two top-level query fields 5 2 dispatches, not N + N

Bounding the terminal loader does not make the descendant operation globally N+1-free — the legacy intermediate resolver still scales, and is reported separately.

Local checks: cargo fmt --all -- --check, cargo check --workspace, cargo test --workspace (965 + 171 tests, 0 failures; 96 new), cargo clippy --all --all-targets --all-features -- -D warnings, git diff --check — all pass. Full detail in the implementation report.

Activation boundary — read before merging

Merge authorizes neither activation.

merge  !=  OBSERVE activation  !=  ENFORCE activation

Production OBSERVE and ENFORCE each require separate explicit CTO production activation approval and both remain NOT AUTHORIZED. Production activation is additionally BLOCKED — runtime-operations / CG-13 and monitoring-threshold evidence unverified. Rollback timing and mechanism are unverified; rollback is not claimed to be certain or deploy-free. BE-02 runtime remains NOT AUTHORIZED.

Review gate

This exact head requires fresh independent cross-model review. The implementing agent has not reviewed or approved its own work. HIGH risk additionally requires separate explicit CTO merge authorization bound to the independently approved exact head; any head movement invalidates it.

ja573 added 5 commits August 8, 2026 13:03
Implements ADR-0006 / THOTH-GQL-BATCH-01. The merged state is inert:
guard mode OFF, store derived-unavailable, no request-path overhead and
unchanged production request acceptance.

Store (thoth-api/src/graphql/batching.rs)
  Request-scoped, owned by one GraphQL request, keyed by the full
  (top-level response key, loader identity, normalized load shape,
  parent key) identity, applied uniformly to queries and mutation
  payloads. Three unambiguous states - NotLoaded, Loaded (including
  Loaded([])) and LoadFailed - of which only NotLoaded may direct-
  fallback. Reads are non-destructive, dispatch is set-based over a
  de-duplicated key set, failures are retained and never retried, and
  whole-store invalidation is provided. Availability is derived from
  MutationGuardMode alone, so OFF/OBSERVE + store enabled are
  structurally unrepresentable. The WIP dispatch de-duplication was
  replaced with an explicit unique-key loop.

Mutation guard (thoth-api/src/graphql/mutation_guard.rs)
  Central request guard with modes OFF (default), OBSERVE and ENFORCE,
  wired at the HTTP boundary before ordinary Juniper execution. OFF
  returns before parsing. Otherwise a baseline eligibility gate
  reproduces pinned juniper 0.16.2's own pipeline using juniper's own
  helpers, so a baseline-invalid request yields no decision, no event
  and no guard error and juniper stays the sole authority for its
  error. Effective variables mirror the executor's or_insert; @Skip and
  @include are evaluated against them; fragment expansion is
  cycle-safe without suppressing distinct occurrences.

Scope shim (thoth-api/src/graphql/scope.rs)
  The single permitted new_error(..) -> path() response-scope
  extraction site, side-effect-free and fail-closed, with the pinned-
  Juniper coupling and revalidation obligation documented and tested.

Prefetch (thoth-api/src/graphql/prefetch.rs)
  Look-ahead-driven set-based prefetch covering direct and descendant
  paths, alias-safe on field_original_name() at every segment,
  collecting every matching terminal selection and writing into the
  ordinary terminal namespace.

Proven by a test-only loader, schema and mutations; no production field
adopts the foundation, no production child resolver and none of the 88
MutationRoot resolvers are modified, and the generated SDL is
byte-identical. No migration, schema.rs, policy.rs or dependency change.

Merge does not authorize activation: production OBSERVE and ENFORCE
each require separate explicit CTO authorization and remain NOT
AUTHORIZED.
…changelog

Records the implementation evidence required by specification section 14:
the per-scope SQL statement-count tables for the direct, descendant and
mutation-payload paths at two list sizes; the two-top-level-field result
showing 2 dispatches rather than N + N; terminal-loader and intermediate-
resolver counts as separate figures; the descendant prefetch
representation; the compatibility shim's signature, side-effect freedom
and fail-closed behaviour; the baseline eligibility gate and its
correspondence to pinned juniper's own pipeline; the baseline-invalid and
directive/effective-variable matrices asserted against Juniper's observed
execution; the measured zero-resolver, zero-write evidence; observability
and redaction evidence; and the generated-SDL and protected-path identity
results.

Separates implementation/merge readiness from production activation
readiness. Production activation remains BLOCKED - runtime-operations /
CG-13 and monitoring-threshold evidence unverified. Records the accurate
request-path cost (parse and operation selection on every request in
OBSERVE/ENFORCE, plus validation on mutations only), characterises the
doc(hidden) juniper surfaces without calling them stable public API, and
does not claim rollback is certain or deploy-free.

Also records one inconvenient finding: the repository's
.map_err(Into::into) idiom hits juniper's blanket From<T: Display> impl
and drops the extensions.type discriminant, so existing production child
resolvers emit none.
…itly

Specification section 10 requires query-path behaviour to be proven
directly, not only inferred from the mutation cases, because the
eligibility gate touches every request in OBSERVE and ENFORCE.

Adds four cases: a valid query is never restricted and emits no event in
any mode; its response is byte-identical to the no-guard baseline in
every mode; an invalid query keeps juniper's canonical error and produces
no guard event in any mode; and a query carrying a duplicate top-level
response key is accepted, shares one scope across both occurrences, and
issues no additional terminal statement for the second.

Updates the implementation report's test counts and adds the
corresponding evidence section.
…tion report

Completes section 3 with every commit from the authorized base, notes that
the inherited WIP commit was not rewritten, rebased or force-pushed, and
states that the authoritative exact head is the PR head — the head that
requires fresh independent cross-model review and separate explicit CTO
merge authorization, and which any further movement invalidates.
@ja573

ja573 commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Exact-head CI verification

Head: acf4ca2881f40662b57dd6cfa15a261fea659506

Every run below is bound to that exact SHA (verified via gh run list --json headSha), not to an earlier push.

Check Result Duration Run
classify (build-test-and-check) PASS 5s 31262042550
format_check PASS 11s 31262042550
lint PASS 2m5s 31262042550
build PASS 6m5s 31262042550
test PASS 8m9s 31262042550
check-changelog PASS 6s 31262042549
classify (run-migrations) PASS 6s 31262042559
run_migrations PASS 6m48s 31262042559
classify (publish-to-dockerhub) PASS 8s 31262042564
build_and_push_staging_docker_image PASS 11m5s 31262042564

PASS vs SKIPPED. No check was skipped. classify gates build/test/lint on run_build == 'true'; all three ran with substantial durations, so none was short-circuited. The only ignored tests are the 8 pre-existing thoth_api doc-tests, ignored on the base as well.

test job ran the database-backed suite

The CI test job runs cargo test --workspace against a real postgres:17 service plus redis:alpine, so the 100 added tests — including every database-backed prefetch, scope, mutation and SQL statement-count case — executed in CI, not only locally. Counts from the CI log match the local run exactly:

thoth-api            969 passed; 0 failed   (869 pre-existing + 100 added)
graphql_permissions   13 passed; 0 failed   (unmodified)
thoth-export-server  143 passed; 0 failed
thoth-errors          11 passed; 0 failed
thoth-client           4 passed; 0 failed
thoth (bin)            1 passed; 0 failed
doc-tests              0 passed; 8 ignored  (pre-existing)
TOTAL: 0 failures

Local checks at the same head

cargo fmt --all -- --check                                   exit 0
cargo check --workspace                                      exit 0
cargo test --workspace                                       0 failures
cargo clippy --all --all-targets --all-features -- -D warnings  exit 0
git diff --check                                             exit 0

Generated-SDL comparison (built at both revisions and diffed, since the file is build-generated and gitignored):

base df2e2efe sha256 = 1e08b46b565ef719c404bbe6b3131e6a733df09c7abdc4538b66c2b24d2d899c
head acf4ca28 sha256 = 1e08b46b565ef719c404bbe6b3131e6a733df09c7abdc4538b66c2b24d2d899c
diff = no output -> BYTE-IDENTICAL

This is CI evidence only. It is not a review, not an approval, and not merge authorization. The exact head above still requires fresh independent cross-model review and, separately, explicit CTO merge authorization; any head movement invalidates both.

ja573 added 2 commits August 8, 2026 16:20


Independent review of acf4ca2 returned CHANGES REQUIRED. No architecture,
production adoption or activation control was changed.

1. Guard rejection no longer has its own HTTP branch.
   The handler carried `return Ok(HttpResponse::BadRequest().json(rejection))`,
   which is the extra branch and bespoke status mapping the specification
   forbids even though the status happened to match. Both paths now produce
   one GraphQLResponse and fall through the single pre-existing
   `match result.is_ok()` branch, which is now the only status-producing
   construction in the handler. Three tests drive the real handler through
   actix_web::test and show a guard rejection and an ordinary juniper
   validation failure sharing the status and body convention, plus a success
   case exercising the 200 arm so the branch is provably common rather than
   failure-only. No new dependency: the assertions are made against the raw
   body because this crate has no serde_json, and the precise structural
   body comparison stays in thoth-api where it does.

2. Two-loader isolation is now proven with two loaders.
   The previous test instantiated a single loader and asserted a structural
   property, so it did not satisfy its own name. TestImprintDescendingLoader
   adds a second closed LoaderIdentity sharing the same parent-key type,
   value type and shape identity, differing only in the discriminant and in
   returning rows descending, so a namespace collision returns observably
   wrong data. Both loaders are dispatched: A's dispatch leaves B NotLoaded,
   B still needs its own dispatch, each lookup returns its own value, B does
   not overwrite A, and one (scope, key, shape) holds two entries. A failure
   under A also leaves B loadable and survives B's dispatch. Verified
   non-vacuous by temporarily giving B A's identity, which fails the test at
   the expected assertion.

3. Same-scope multi-site reuse is now proven with two sites.
   The previous test invoked one site once. TestTwoSiteContainer exposes two
   sibling fields that each resolve their own parent list and install their
   own prefetch site, both below one top-level field so both derive the same
   scope, loader, shape and key set. Per-site outcomes are recorded: the
   first site is Loaded, the second AlreadyLoaded, on both execute_sync and
   async execute. Measured terminal-loader SQL for the pair is 1 at n=3 and
   n=6 with zero fallbacks. Async needed no coordination primitive and none
   was added; this was measured rather than assumed, and a separate test
   keeps the same-scope case distinct from correct cross-scope isolation.

The implementation report is corrected rather than left standing: the
previous "no new handler branch", two-loader and multi-site claims are
recorded as unsupported at the reviewed head, with the new tests and actual
measured results in their place.
The OFF-mode case posts a real mutation, so it must not be able to write
to the shared test database. Anonymous requests stop at
PublisherPolicy::can_create before Publisher::create runs; asserting that
explicitly proves both that the request reached ordinary execution rather
than being turned away by the guard, and that the test performs no write.
Verified by row count before and after: unchanged.
@ja573

ja573 commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Independent-review remediation — CHANGES REQUIRED addressed

Reviewed head: acf4ca2881f40662b57dd6cfa15a261fea659506
New exact head: f6506212ca24379f01b7f6eb94a54a28d5662fdd
Commits added: b983e187, f6506212 — appended to the existing branch; nothing rebased, reset or force-pushed. No new branch, no new PR.

1. Guard-specific HTTP branch removed

The finding was correct and the previous claim was false as written. The handler carried return Ok(HttpResponse::BadRequest().json(rejection)) — an extra branch and a bespoke status mapping — even though the resulting status happened to match. Both paths now produce one GraphQLResponse and fall through the single pre-existing branch:

let result = match run_mutation_guard(mode, &data, &st) {
    Some(rejection) => rejection,
    None => {
        let ctx = Context::with_guard_mode(/* … */, mode);
        data.execute(&st, &ctx).await
    }
};

match result.is_ok() {
    true => Ok(HttpResponse::Ok().json(result)),
    false => Ok(HttpResponse::BadRequest().json(result)),
}

Structurally, the handler now contains exactly two HttpResponse::…() constructions — the two arms of that one branch — and no other status-producing return.

Three new tests drive the real handler through actix_web::test (no new dependency):

Case Mode Status Body
duplicate top-level response key (guard rejection) ENFORCE 400 {"errors":[…]} with message + locations, no data key
unknown field (ordinary juniper validation failure) OFF 400 same shape
{ __typename } ENFORCE 200 carries data — proves the branch is common, not failure-only
same duplicate document OFF no guard rejection; stops at the authorization check, so no write

Preserved: rejection before any resolver executes, resolver count 0, database writes 0, ordinary validation-style body, HTTP 400 via the common mapping.

2. Two-loader collision isolation — now two actual loaders

The previous test did not satisfy its name. It instantiated one loader and asserted a structural property. TestImprintDescendingLoader adds a second closed LoaderIdentity sharing the same parent-key type (Uuid), value type (Imprint) and shape identity (asserted equal in the test), differing only in the discriminant and in returning rows descending — so a collision returns observably wrong data.

Step Observed
before any dispatch A NotLoaded, B NotLoaded
after A dispatches A Loaded; B still NotLoaded
B then dispatches Loaded — B still needs its own dispatch
A lookup ["AAA-first","BBB-middle","CCC-last"]
B lookup ["CCC-last","BBB-middle","AAA-first"] — A not overwritten
entries under one (scope, key, shape) 2

A failure under loader A also leaves B loadable and survives B's dispatch.

Verified non-vacuous: giving B loader A's identity makes the test fail at exactly loader A's dispatch must not satisfy loader B. The identity was restored immediately.

3. Same-scope multi-site reuse — now two actual sites

The previous test invoked one site once. TestTwoSiteContainer exposes two sibling fields, each resolving its own parent list and installing its own prefetch site, both below one top-level field so both derive the same scope, loader, shape and key set.

Execution path site left site right entries fallbacks
juniper::execute_sync Loaded (one dispatch) AlreadyLoaded one per parent 0
async juniper::execute Loaded AlreadyLoaded one per parent 0

Measured terminal-loader SQL through the instrumented pool:

SAME-SCOPE TWO SITES | scope=testTwoSites | rows (n, terminal stmts, fallbacks)
                     = [(3, 1, 0), (6, 1, 0)]

Total terminal-loader SQL for the pair = 1, both list sizes, both execution paths. All parent results correct and identical between sites.

No coordination primitive was needed, and none was added — this was measured, not assumed. The async path shows one dispatch because the fixture's resolvers are synchronous bodies and the pinned executor drives a selection set through FuturesOrdered polled from a single task, so a site's read-load-write completes before its sibling runs. Had it shown two, request-local coordination would have been added rather than the criterion relaxed. No global/static cache, background task, external lock manager, dependency or production state was introduced.

A separate test keeps this distinct from cross-scope isolation, where two top-level response keys correctly require two dispatches.

Exact-head CI — f6506212

Check Result Duration
classify ×3 PASS 6–9s
format_check PASS 9s
lint PASS 1m48s
build PASS 6m9s
test PASS 7m56s
check-changelog PASS 8s
run_migrations PASS 7m18s
build_and_push_staging_docker_image PASS 11m25s

SKIPPED: none. Only the 8 pre-existing thoth_api doc-tests are ignored, as on the base. CI test counts (real postgres:17 + redis:alpine), matching local exactly:

thoth-api            973 passed; 0 failed   (869 pre-existing + 104 added)
graphql_permissions   13 passed; 0 failed   (unmodified)
thoth-api-server       3 passed; 0 failed   (new handler tests)
thoth-export-server  143 passed; 0 failed
thoth-errors          11 passed; 0 failed
thoth-client           4 passed; 0 failed
thoth (bin)            1 passed; 0 failed
TOTAL: 0 failures

Local gate at the same head: cargo fmt --all -- --check, cargo check --workspace, cargo test --workspace, cargo clippy --all --all-targets --all-features -- -D warnings, git diff --check — all exit 0.

Boundaries preserved

Unchanged versus the authorized base df2e2efe: schema.rs, migrations/**, Cargo.toml, Cargo.lock (all three crate manifests), policy.rs, CI workflows, mutation.rs (all 88 MutationRoot resolvers), query.rs, the pre-existing GraphQL test suite, PR #788 and issue #765. Generated SDL still byte-identical (sha256 1e08b46b…), with no test-only type leaked into it. new_error(..) remains a single call site in scope.rs. No BE-02 symbol anywhere. No new dependency, no migration.

Merged default remains MutationGuardMode::Off, store unavailable, no added OFF-mode parsing or validation, request acceptance unchanged. OBSERVE and ENFORCE remain NOT AUTHORIZED; BE-02 runtime remains NOT AUTHORIZED; CG-13 and the monitoring-threshold blockers are untouched and still open.


This is remediation and CI evidence only. It is not a review, not an approval, and not merge authorization. The reviewed SHA has changed, so this head requires a fresh independent review, and merge additionally requires separate explicit CTO authorization bound to the approved exact head.

@ja573
ja573 marked this pull request as ready for review August 10, 2026 08:55
@ja573
ja573 merged commit 75f44aa into develop Aug 10, 2026
10 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

The authoritative final exact head is the head shown on PR
[#791](https://github.com/thoth-pub/thoth/pull/791), and it is that head — not
any commit listed here — which requires **fresh** independent cross-model review
(the reviewed SHA has changed) and, separately, explicit CTO merge
authorization. **Any head movement invalidates both.**

P1 Badge Remove transient review state from the durable report

Once PR #791 is independently reviewed or merged, this committed paragraph becomes false because it says the current head still requires fresh review and CTO authorization. Keep live review and authorization status in GitHub and make the implementation report state only the durable decision and authority condition, so merging does not immediately leave stale control evidence on the integration branch.

AGENTS.md reference: docs/engineering/AGENTS.md:L56-L59

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant