Skip to content

refactor: query planner service no longer returns graphql response - #9911

Merged
BrynCooke merged 40 commits into
dev-v3.xfrom
agent/ROUTER-1973
Aug 19, 2026
Merged

refactor: query planner service no longer returns graphql response#9911
BrynCooke merged 40 commits into
dev-v3.xfrom
agent/ROUTER-1973

Conversation

@BrynCooke

@BrynCooke BrynCooke commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Authorization no longer decides responses inside the query planner. The planner returns a query plan or an error. Authorization filters the operation as before, and a layer on the execution service applies reject_unauthorized and answers refused operations.

BREAKING: when every field in an operation fails authorization, data is an object with each requested root field set to null, plus one error per field.

before  {"data":null,          "errors":[{"path":["orga","id"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]}
after   {"data":{"orga":null}, "errors":[{"path":["orga","id"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]}

BREAKING: the router validates the request before authorization runs. An operation with a missing required variable gets 400 VALIDATION_INVALID_TYPE_VARIABLE, and a subscription sent without Accept: multipart/mixed gets 406 SUBSCRIPTION_BAD_HEADER. Both previously returned 200 with the authorization errors.

Operations that fail authorization now reach Apollo Studio with their operation signature. The Authorization error log event moves from the query_planning span to under execution.

Tests came first: the response shape, the errors.response modes, the HTTP status, billing, and the two response-formatting passes were all pinned before the code moved. dev-docs/authorization-query-planning.md has the before and after, plus the questions we left open, including whether a refused operation should be a GraphQL request error (ROUTER-2063).

@apollo-librarian

apollo-librarian Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

✅ Docs preview ready

The preview is ready to be viewed. View the preview

File Changes

1 new, 20 changed, 0 removed
+ graphos/routing/(latest)/upgrade/from-router-v2.mdx
* graphos/routing/(latest)/configuration/yaml.mdx
* graphos/routing/(latest)/customization/native-plugins.mdx
* graphos/routing/(latest)/customization/coprocessor/index.mdx
* graphos/routing/(latest)/customization/rhai/index.mdx
* graphos/routing/(latest)/observability/graphos/graphos-reporting.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/index.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/apm-guides/datadog/router-instrumentation.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/apm-guides/datadog/connecting-to-datadog/datadog-agent/datadog-agent-traces.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/apm-guides/jaeger/jaeger-traces.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/apm-guides/zipkin/zipkin-traces.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/enabling-telemetry/conditions.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/enabling-telemetry/selectors.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/enabling-telemetry/spans.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/enabling-telemetry/standard-instruments.mdx
* graphos/routing/(latest)/observability/router-telemetry-otel/telemetry-pipelines/trace-exporters/overview.mdx
* graphos/routing/(latest)/performance/caching/response-caching/faq.mdx
* graphos/routing/(latest)/security/demand-control.mdx
* graphos/routing/(latest)/self-hosted/containerization/proxy-certificates.mdx
* graphos/routing/(latest)/upgrade/from-router-v1.mdx
* graphos/routing/(latest)/_sidebar.yaml

Build ID: 8fb8311ea5c70b691bc4490e
Build Logs: View logs

URL: https://www.apollographql.com/docs/deploy-preview/8fb8311ea5c70b691bc4490e


✅ AI Style Review — No Changes Detected

No MDX files were changed in this pull request.

Review Log: View detailed log

This review is AI-generated. Please use common sense when accepting these suggestions, as they may not always be accurate or appropriate for your specific context.

@BrynCooke
BrynCooke force-pushed the agent/ROUTER-1973 branch 2 times, most recently from c33beea to 3ec0596 Compare July 31, 2026 14:40
@BrynCooke BrynCooke changed the title test: characterize authorization behaviour before separating it from query planning test: authorization behaviour before separating it from query planning Jul 31, 2026
bryn added 5 commits July 31, 2026 15:54
When `filter_query` empties the document, or `reject_unauthorized` is set and
any path was removed, the query planner returns `QueryPlannerContent::Response`
and the supergraph service forwards it verbatim. We are about to move that
rejection out of the query planner into a supergraph layer, and three properties
of the current behaviour had no test at all:

- Execution is never reached. The existing reject tests register subgraph mocks
  but never assert they went unused, so a regression that let execution run
  would pass them as long as the response body matched.
- The response carries HTTP 200. Directive-based authorization strips selections
  from the document, so a rejection has field-error semantics: value completion
  nulls the stripped selections and, once everything is stripped, propagates to
  `data: null`. 200 with errors is the correct response for that. Moving the
  rejection ahead of the planner makes it look like a request-level rejection,
  where 400 becomes the instinctive choice, so this needs pinning.
- Usage reporting is not recorded, because `CachingQueryPlanner` only inserts it
  for the `Plan` variant. The operation is still metered — telemetry falls back
  to counting it as one licensed operation — but it arrives with no operation
  signature, referenced fields, or per-type stats, so Studio cannot attribute
  the rejection to an operation.

Every subgraph is replaced by a `tower_test` mock with no canned responses, so
reaching one fails the test. `assert_no_subgraph_calls` additionally fails when
no subgraph service was built at all, which would otherwise make the assertion
vacuous.

Verified all three tests discriminate: with `reject_unauthorized` flipped to
false the operation becomes a partial filter that does reach execution, and all
three fail.
`CacheKeyMetadata` is part of `CachingQueryKey`'s `Hash`/`Eq`, which is what stops
an unauthenticated request from being served a plan built for an authenticated
one. Until now that invariant was only covered incidentally, by two supergraph
snapshot tests (`authorization::tests::{authenticated_directive,
scopes_directive}`) that send the same query twice against a shared cache and
compare bodies. Nothing named the invariant, so it was easy to weaken by
accident while editing either test for unrelated reasons.

`plan_cache_is_segmented_by_authorization_metadata` asserts it directly at the
caching planner: the same query under two different `CacheKeyMetadata` values
reaches the inner planner twice, and repeating the first value is served from
cache. Counting inner invocations rather than comparing response bodies means the
test states the cache behaviour instead of inferring it.

`rejection_response_is_cached` pins the fact that a `QueryPlannerContent::Response`
is cached like any other output, since `entry.insert` does not discriminate on
variant. Characterization only — moving the rejection ahead of the cache would
change this, and it should be a deliberate decision.

Verified the segmentation assertion discriminates: making both metadata values
identical drops the inner planner to a single invocation and fails the test, so
the extra plan is genuinely caused by the metadata difference and the third call
genuinely hits the cache.
Query plan warm-up bypasses the router and supergraph pipelines entirely —
`WarmupParseQueryLayer` wraps the `CachingQueryPlanner` directly — and plans with
`CacheKeyMetadata::default()`, which is byte-for-byte the metadata a genuinely
unauthenticated request produces. Warm-up is therefore only safe because
authorization filtering sits inside the query planner, below the plan cache,
where every caller hits it regardless of how it got there.

Nothing tested that. No warm-up test involves authorization directives at all,
and the caching planner tests all use default metadata, so the property held by
construction rather than by assertion.

This asserts it at the planner: with directives enabled and unauthenticated
metadata, planning an operation whose root field requires a scope returns a
rejection, not a plan. Moving authorization out of the planner has to keep that
true by some other means — otherwise warm-up populates the default-metadata cache
key with an unfiltered plan and any unauthenticated request is served it.

The failure message spells out that consequence, because a bare "expected
Response, got Plan" would not tell whoever trips it why it matters.

Verified the assertion discriminates: disabling the directives makes the planner
return a plan and the test fails with the leak message.
When authorization filters an operation, response formatting runs twice: once for
the filtered query, then once for the original. The filtered pass has to copy
`__typename` through, because the original pass needs it to decide whether a type
condition applies. Without it, every field behind an inline fragment or fragment
spread is dropped from the response.

Both branches implementing this (`is_original == false` in `apply_selection_set`)
were unreachable from the test suite. `is_original: false` appears exactly once,
in `filtered_defer_fragment`, whose filtered query is `{ a { b } }` — no
fragments, so neither branch can fire.

The new test filters a field out of `... on Foo` and asserts `__typename` survives
the filtered pass, then that `foo` survives the original pass, which it can only
do if `__typename` did. Asserting the intermediate state as well as the final one
means a failure says which of the two passes broke.

`query_for_test` extracts the `Query` construction so the new test does not
repeat the twenty lines `filtered_defer_fragment` uses to build one by hand.

Verified the test reaches the branch: setting `is_original: true` on the filtered
query drops `__typename` and fails the first assertion.
These three snapshots have no test function anywhere in the repo. They were added
with the experimental response cache plugin and the test that produced them was
removed later without them, so `cargo insta` has been carrying them as unreferenced
files ever since.

The path they used to cover — `CacheKeyMetadata` folded into the entity cache key
by `response_cache::cache_key::hash_additional_data` — has no test now. Deleting
the snapshots does not lose coverage, it stops the files from implying coverage
that does not exist.
@BrynCooke
BrynCooke force-pushed the agent/ROUTER-1973 branch from 3ec0596 to 15bf899 Compare July 31, 2026 14:57
@BrynCooke
BrynCooke marked this pull request as ready for review July 31, 2026 15:00
@BrynCooke
BrynCooke requested a review from a team as a code owner July 31, 2026 15:00
@rohan-b99 rohan-b99 self-assigned this Aug 10, 2026
rohan-b99 and others added 4 commits August 10, 2026 17:04
…data path

`plan_cache_is_segmented_by_authorization_metadata` inserted `CacheKeyMetadata`
into the request context directly. Nothing in the router does that:
`CachingQueryPlanner::plan` calls `AuthorizationPlugin::update_cache_key` first,
which unconditionally overwrites the context's metadata with one derived from the
request's JWT claims. The test only survived that overwrite because
`Configuration::default()` plus `starstuff@current.graphql` (no authorization spec
linked) left `enable_authorization_directives` false, so `update_cache_key` never
ran — meaning the test pinned the derived `Hash`/`Eq` on `CachingQueryKey` and not
the segmentation it claimed. Both cache tests now use a schema and configuration
for which directives are enabled, and carry authorization state as JWT claims so
`update_cache_key` produces the metadata. Removing that call now fails the test.

`rejection_response_is_cached` fed a `QueryPlannerContent::Response` with no
errors and used default metadata on both calls, so it observed neither a rejection
nor authorization-dependent keying. It now uses the content shape
`QueryPlannerService::get` returns for a whole-query rejection and adds a call in a
different authorization state, pinning that a cached rejection is not served across
authorization states.

Two comments claimed more than their tests checked.
`planning_unauthenticated_rejects_rather_than_returning_unfiltered_plan` stated an
invariant about the plan cache while asserting on the planner service one layer
below it, and passed `ComputeJobType::QueryPlanningWarmup` as though that selected
a warm-up path — it only sets the compute-pool priority and the metric label, and
`get` filters before reading it. `returns_http_200` credited the status to value
completion nulling stripped selections, but the rejection short-circuits in the
planner and `new_from_graphql_response` wraps it with `http::Response::new`, which
is 200 regardless. Both now describe the code that actually runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`filtered_query_keeps_typename_for_type_conditions` hand-wrote both the original
and the filtered query and hand-built the `Query` pair around them, so it pinned a
shape that nothing guaranteed filtering produces. It now plans an unauthenticated
request through `QueryPlannerService` and takes the pair from `plan.query`, which is
where `filter_query` produces the filtered document and the planner sets
`is_original = false`. The schema gains the `authenticated` spec and `Foo.secret`
gains `@authenticated` so filtering has something to remove.

Split into one test per fragment form. The single test covered an inline fragment and
a fragment spread in one query, which pinned neither branch: `apply_selection_set`
copies `__typename` in both the `InlineFragment` and `FragmentSpread` arms, so either
copy alone kept both fields alive. Disabling one arm left the test passing. With one
form per test, disabling either arm fails exactly the test that covers it, verified
in both directions.

`Thing` stays an interface because the copy only matters for abstract types:
`apply_selection_set` derives `current_type` from the response `__typename` for
interfaces and unions, and from the schema otherwise, so a concrete field type would
resolve type conditions with or without the copy.
`ExecutionService` formats a filtered operation twice: the filtered query projects the
subgraph data onto the authorized shape, then the original query expands it to the
shape the client requested. Both properties matter and neither was named by a test.

Disabling the filtered pass entirely left 92 of the 93 authorization tests passing.
The only failure was a snapshot in `cache_key_metadata`, whose name points at cache
keys rather than at response filtering, and whose diff for the leak is a single line.
A refactor of the response path could drop that pass and return protected data with
the suite still green.

This test has a subgraph return `phone` even though filtering removed it from the
operation, so the data reaching formatting is wider than the query. Asserting
`Some(Value::Null)` covers both halves in one key: present means the original query
restored the requested shape, null rather than "1234" means the filtered query stripped
the value.

Verified against both mutations. Removing the filtered pass yields
`Some(String("1234"))`, a leak of an `@authenticated` field. Running the passes in the
other order yields `None`, dropping a requested field from the response and orphaning
the authorization error already recorded for its path.

@goto-bus-stop goto-bus-stop left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

just dropping my original comments here, we'll see which are still relevant after the changes we already discussed

Comment thread apollo-router/src/plugins/authorization/tests.rs Outdated
Comment on lines +557 to +564
/// The status comes from the short-circuit in the query planner, not from response
/// formatting: `filter_query` returns `Err(Unauthorized)`, `QueryPlannerService::get`
/// builds a `graphql::Response` with `data: null` directly, and
/// `SupergraphResponse::new_from_graphql_response` wraps it with `http::Response::new`,
/// which is 200 regardless of errors or data shape. Execution and value completion never
/// run — see `does_not_reach_execution`. 200 with errors is the right answer for a
/// rejection with field-error semantics, so this pins that the short-circuit does not
/// pick up an error status along the way.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this is the correct behaviour when reject_unauthorized is enabled, since we don't produce partial data in that case. I think we are required by GraphQL spec to have a request error instead of a field error, which we don't do today. Then the HTTP status code should be 4xx, not 200. Additionally, data should be omitted entirely, instead of being null.

Suggested change
/// The status comes from the short-circuit in the query planner, not from response
/// formatting: `filter_query` returns `Err(Unauthorized)`, `QueryPlannerService::get`
/// builds a `graphql::Response` with `data: null` directly, and
/// `SupergraphResponse::new_from_graphql_response` wraps it with `http::Response::new`,
/// which is 200 regardless of errors or data shape. Execution and value completion never
/// run — see `does_not_reach_execution`. 200 with errors is the right answer for a
/// rejection with field-error semantics, so this pins that the short-circuit does not
/// pick up an error status along the way.
/// We historically treat authorization errors as field errors, even if the whole request is rejected.
/// Hence we expect `null` data, auth errors, and status code 200.

I think for the sake of encoding the current behaviour, we should also assert in this test that data is present but null (...or at least that it is null, I'm not sure we differentiate between absence and null yet).

I'll file a follow up ticket for spec compliance here I think.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This went further than the comment after a team discussion. rejection_sends_null_data confirms we do differentiate null from absent — at wire level; deserializing into graphql::Response collapses the distinction, which is why the module asserts on bytes.

Since f40c739, an emptied operation without reject_unauthorized returns shaped data ({"data":{"orga":null},...}) like a partial filter — breaking changeset included. With reject_unauthorized the response stays 200/data: null, pinned by returns_http_200 and rejection_sends_null_data.

For your follow-up ticket: dev-docs/authorization-query-planning.md has the verified spec citations (request error ⇒ data absent; over-HTTP ⇒ 4xx MUST for application/graphql-response+json, 200 for legacy application/json) and the three coupled decisions — status by negotiated content type, path on errors with no result, and errors.response: disabled producing a spec-invalid response when data is absent.

Comment thread apollo-router/src/query_planner/caching_query_planner.rs Outdated
Comment thread apollo-router/src/query_planner/caching_query_planner.rs Outdated
Comment thread apollo-router/src/query_planner/caching_query_planner.rs Outdated
bryn added 10 commits August 17, 2026 11:54
… planner

The refactor moves whole-query rejection out of the query planner into a layer on
the execution service, and the response it produces has to stay byte-identical.
Four properties of that response had no test.

`data: null` is sent, rather than `data` being left out. GraphQL distinguishes the
two: an absent `data` marks a request error, a null one marks a field error that
propagated to the root. The existing snapshots cannot see this, because
`into_graphql_response_stream` deserializes into `graphql::Response` first, where
`data: Option<Value>` turns JSON `null` into `None` and then skips it on
re-serialization. These tests read the wire bytes instead. Building the rejection
without `data` leaves all three pre-existing rejection tests passing.

`errors.response: disabled` and `errors.response: extensions` were only covered for
partial filtering. `errors_in_extensions` sets no `reject_unauthorized`, and the
`config_parsing` cases check that the options deserialize, not what they do. Both are
now covered for a whole rejection: `disabled` leaves `data: null` as the client's only
signal, `extensions` moves the errors to `extensions.authorizationErrors` and leaves
`errors` out.

`dry_run` combined with `reject_unauthorized` still rejects. `dry_run` reports the
paths without modifying the operation, so a rejection there can only come from the
config, never from an emptied document. Any change that keys rejection off the
document being empty has to keep the config check independent, or `dry_run` silently
stops enforcing.

Each test was verified against a mutation that isolates it: omitting `data` fails all
four, ignoring `ErrorLocation::Disabled` fails only the disabled test, and skipping the
`reject_unauthorized` check under `dry_run` fails only the dry-run test.
Moving whole-query rejection out of the query planner turns the planner's output for
a rejected operation from `QueryPlannerContent::Response` into a plan, and
`QueryPlan::usage_reporting` is not optional, so that plan has to carry a
`UsageReporting`. Which variant it carries decides what the operation costs:

    fn licensed_operation_count(usage_reporting: &UsageReporting) -> u64 {
        match usage_reporting {
            UsageReporting::Error(_) => 0,
            _ => 1,
        }
    }

`UsageReporting::Error` is the natural-looking choice for an operation that produced
no plan, and it bills nothing. A rejected operation is billed as one licensed
operation today, because telemetry falls back to a count of 1 when the context holds
no `UsageReporting` at all, so choosing `Error` would drop rejected operations off
the bill. No test covered any of this: every snapshot asserting
`licensed_operation_count: 1` covers a successful operation.

These drive `update_apollo_metrics` over a context directly and assert the count for
all three cases: absent reporting bills 1, `Error` bills 0, and `Operation` bills 1.
The middle one exists to make the trap visible rather than to protect current
behaviour.

`apollo_reports.rs` was the obvious home, but its fixture schema has no authorization
directives, and the file documents itself as flaky with process-wide collectors. The
unit-level metrics tests could not host these either, since `get_metrics_for_request`
fixes the schema and configuration. Driving the reporting function directly needs
neither.

Each test body runs under `FutureMetricsExt::with_metrics`, which gives it a
task-local meter provider. Without it these emit through the global meter and
intermittently break `plugins::authorization::authenticated::tests` when both modules
run together; with it that combination passed five consecutive runs. Verified each
test still discriminates afterwards: changing the fallback to 0, `Error` to 1, or the
catch-all to 0 fails exactly one test each.
…udio

Describes the one user-visible effect of moving authorization out of the query
planner: an operation refused outright now reaches Studio with a signature,
referenced fields, and per-type stats, where before it contributed only to the
operation count.

States the licensed operation count explicitly, since a change to what Studio
receives invites the question of what it costs.
`filter_query` signalled a refused operation with `Err(QueryPlannerError::Unauthorized)`,
so the query planner had to catch an error variant that meant success in order to build a
200 response from it. An authorization refusal is an outcome of filtering, not a failure
of it.

`filter_query` now returns `FilterResult`, which names its three outcomes: the operation
is `Unchanged`, it is `Filtered` and carries the removed paths alongside the new document,
or it is `Refused`. `Err` is left for the spec errors the filtering visitors raise. The
query planner matches the three arms, which also drops the tuple that
`clippy::type_complexity` needed a type alias to quieten.

`QueryPlannerError::Unauthorized` had no handler beyond that one arm, so it goes with it.

The response is untouched: `Refused` runs the same `UnauthorizedPaths` calls in the same
order and returns the same `QueryPlannerContent::Response`.
Filtering removes `currentUser.phone`, emptying that selection, and `@skip(if: true)`
removes the only other root field. The operation still has a definition, so filtering
reports it as filtered rather than refused, and the plan comes back with no root node.

That plan shape is indistinguishable from a refused operation: no root node, and
unauthorized paths present. The responses differ though. This one carries a shaped
`data` of `{"currentUser": null}` alongside the authorization error, where a refused
operation carries `data: null`. Deciding refusal from the plan shape would answer this
operation as a refusal and change its `data`.

The test asserts no subgraph was called, which is what establishes that the plan held
no executable work; without it the snapshot alone would not show that this is the
colliding case.
The `Authorization error` event for a refused operation fires exactly once, inside
the `query_planning` span, carrying the unauthorized paths. The unit tests cannot
cover the span: it takes the telemetry plugin, which only joins the pipeline when
OpenTelemetry is initialised for the process, so this spawns a real router and parses
the JSON log's span list.

Exactly-once is the assertion with teeth. The refusal is decided at a single place,
so a second event for the same request means two code paths both believe they own
the log.
The query planner answered a refused operation itself, returning
`QueryPlannerContent::Response` instead of a plan. The planner now plans or errors,
and the authorization plugin's execution-service layer answers refusals.

`filter_query` loses the `reject_unauthorized` check entirely: that decision belongs
to the layer, which receives the flag at plugin construction. `FilterResult::Refused`
becomes `Emptied`, meaning only that filtering removed every selection. For an emptied
operation the planner returns a plan with no root node, usage reporting generated from
the original query with empty references (nothing resolves, so nothing is referenced),
and `operation_emptied` set on `UnauthorizedPaths`, which rides the `Query` through
the plan cache.

The flag is not derivable downstream: a plan with no root node and non-empty paths
also describes a partially filtered operation whose surviving selections are all
statically skipped, pinned by `partial_filter_leaving_no_executable_work` as
returning shaped data rather than a refusal.

The layer is a `checkpoint_async` ahead of the existing authorization counter, so a
refusal breaks before the counter fires and rejected operations stay uncounted, as
they were when they never reached execution at all. It answers when the operation was
emptied or when it holds `reject_unauthorized` and paths are present, with the same
`data: null` response and `ErrorLocation` handling as before, byte-identical on the
wire.

Two observable changes, both in the changeset:

- A refused operation reaches `CachingQueryPlanner` as a plan, so its usage reporting
  now lands in the context and Studio receives the operation signature. The licensed
  operation count stays 1: the reporting is `UsageReporting::Operation`, and telemetry
  previously billed the missing-reporting fallback at 1.
- The `Authorization error` event moves from the `query_planning` span to `execution`,
  visible here as the assertion flip in the integration test. The unit harness cannot
  see the span (without process-wide OpenTelemetry init the telemetry plugin is absent
  and no `execution` span exists), so the unit helper now asserts the event fires
  exactly once and leaves the span to the integration test.

The exactly-once assertion is load-bearing: the first version of this change logged
in both the planner and the layer for an emptied operation, and nothing failed until
that assertion existed. Its negative control (reintroducing the planner's log call)
fails showing one event in `query_planning` and one in `execution`.
The authorization layer on the execution service answers refused operations, so no
producer of `QueryPlannerContent::Response` remains. The supergraph service's
forwarding arm, the cache's size estimation arm, and the variant itself go together.

`QueryPlannerContent` keeps its single `Plan` variant rather than collapsing into
`QueryPlan`: flattening the type ripples through every construction and match in the
crate, and is mechanical enough to review separately.

`rejection_response_is_cached` becomes `refused_operation_plan_is_cached`, feeding the
shape `QueryPlannerService::get` now returns for a refusal: a plan with no root node
and the query marked emptied. What it pins is unchanged — a refusal is cached, and
stays keyed by authorization state.

The remaining matches on the enum simplify to irrefutable bindings.
Assert the rejection by its error code rather than its message: the code is the
contract, the wording is not.

Reword the HTTP 200 doc along the lines review suggested: field-error treatment of a
whole-operation refusal is historical, the spec arguably calls for a request error
with a 4xx status and no `data` key, and `rejection_sends_null_data` pins the
null-versus-absent distinction on the wire until spec compliance changes deliberately.
The previous wording also described the pre-refactor mechanism, with the planner
building the response.

In the caching tests, derive the parsed document inside the request helper instead of
taking it as a second argument that must agree with the query, name the helper for
the authorization tests it serves, inline the one-argument planner wrapper, and cut
the config helper's doc to one line.
…ield to match

`filter_query` reports `Emptied` from a document-wide `definitions.is_empty()` check,
so fully filtering the executed operation while a sibling operation shares the
document reports `Filtered` instead. Planning then fails to find the executed
operation, and the client receives 400 `GRAPHQL_UNKNOWN_OPERATION_NAME` with no
mention of authorization, where the single-operation case receives the refusal
response. The `FIXME`s in `filter_query` point at exactly this check; the new test
pins the outcome so resolving them changes it deliberately.

`operation_emptied` becomes `document_emptied`: the field records what the check
measures, and the old name claimed the stronger per-operation meaning. The field doc
gains the two counterexamples that make it non-derivable from the plan and the
multi-operation case that bounds what it means.
@@ -0,0 +1,9 @@
### Report operations rejected by authorization to Apollo Studio ([PR #9911](https://github.com/apollographql/router/pull/9911))

When authorization refuses an operation outright, Apollo Studio now receives it as an operation, identified by the signature of the query the client sent and carrying the client name, version, and request count. Studio previously received the operation count alone and had nothing to attribute it to.

@goto-bus-stop goto-bus-stop Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When authorization refuses an operation outright,

Does this only apply when reject_unauthorized: true?

I think when authorization removes all fields, we should NOT describe this as a "refused" operation to users. It's an operation that was executed, but all field executions errored because the client did not have the required authorization.

Only when reject_unauthorized: true should we describe a request as "refused" outright. And I think we should mention that option whenever we talk about refusal, to clarify to users that the changeset is only relevant to them if they use that option.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It applies to both cases, so reworded with your taxonomy in 6eb7282: "authorization raises errors for every field" for the directive case, "refuses" reserved for reject_unauthorized, which is now named wherever refusal comes up.

@@ -0,0 +1,11 @@
### Fully unauthorized operations return the same response shape as partially unauthorized ones ([PR #9911](https://github.com/apollographql/router/pull/9911))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should frame this as a spec-compliance upgrade. Previously, we incorrectly returned data: null in some cases where we should return an object. I'll propose a new changeset after I'm done with the rest of the PR.

In particular, I think we should never mention "removed" fields to users, as this is an implementation detail of how we work around the query planner. To a user, such a field was not "removed", instead the field is executed but raised an error due to not being authenticated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Rewrote it along those lines in 6eb7282: spec-fix framing, "fields fail authorization" instead of "removed", and the reject_unauthorized carve-out named. Overwrite freely if you had different wording in mind.

Comment thread apollo-router/src/batching/query_plan_analysis_layer.rs Outdated
`QueryPlannerContent` held one variant wrapping `Arc<QueryPlan>`, so every consumer
paid a destructure for no information. It is now a type alias for `Arc<QueryPlan>`:
signatures keep naming the planner's output as a single seam, so changing the payload
touches one definition, while constructions and uses handle the plan directly. 41
lines of pattern matching collapse into direct use.

The distributed plan cache serializes the cache value, so its wire shape loses the
enum tag. Cache keys carry the crate version, and 2.17.0 is unreleased, so no
released router shares the namespace this shape lands in.

@goto-bus-stop goto-bus-stop left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

About halfway done with review, need to get off the train now though!!!

let unauthorized = request.query_plan.query.unauthorized.clone();
unauthorized.log_unauthorized_paths();

let mut response = graphql::Response::builder().data(Value::Null).build();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's clarify that this is technically not up to spec. Not a fan of including ticket references in general, but it might be helpful in this case, as it explains future work rather than describing work that's already done (which is what bothers me most when Claude sticks references in comments)

Suggested change
let mut response = graphql::Response::builder().data(Value::Null).build();
// We knowingly build an invalid response here. Execution was prevented,
// so we should respond with a request error and no data. Instead, we're
// responding with execution errors and a fake/incorrect `data: null`. We
// maintain backwards compatibility for the time being.
// Tracked in ROUTER-2063.
let mut response = graphql::Response::builder().data(Value::Null).build();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken verbatim in 70cbf5e.

/// The span the event belongs to is asserted by
/// `integration::telemetry::logging::test_authorization_error_event_in_execution_span`.
/// This harness runs without the telemetry plugin, so the `execution` span does not
/// exist here and the event lands directly in the `router` span.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't see anything in this test that relies on the span?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right — it asserts cardinality only. Dropped the span paragraph from the doc in 6e975af; the span assertions live in the integration tests.

Comment on lines +480 to +493
/// A subgraph can return more than the operation selected, so the data reaching response
/// formatting is not bounded by what authorization left in the query. `ExecutionService`
/// handles that by formatting twice: the filtered query projects the data onto the
/// authorized shape, then the original query expands it to the shape the client asked
/// for.
///
/// `User.phone` is `@authenticated`, so filtering removes it from an unauthenticated
/// operation while this subgraph returns it anyway. `Some(Value::Null)` pins both halves
/// of that arrangement in one assertion: `phone` is present, so the original query
/// restored the requested shape, and it is null rather than `"1234"`, so the filtered
/// query stripped the value the client may not see.
///
/// Removing either property changes this key: drop the filtered pass and it holds
/// `"1234"`, run the passes in the other order and it disappears from the response.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A subgraph can return more than the operation selected,

While technically true, this would be a misbehaviour from the subgraph and I don't think this is the most likely case. The name field could have an @requires(keys: "phone") and then we must ask for that field from the subgraph in order to resolve name, but we must not send the phone field to the client.

The rest of the comment is explaining so much about the behaviour that it's hard to understand what the actual point of the test is. The comments about filtering ordering would be useful to have at the point where we do the filtering though.

Suggested change
/// A subgraph can return more than the operation selected, so the data reaching response
/// formatting is not bounded by what authorization left in the query. `ExecutionService`
/// handles that by formatting twice: the filtered query projects the data onto the
/// authorized shape, then the original query expands it to the shape the client asked
/// for.
///
/// `User.phone` is `@authenticated`, so filtering removes it from an unauthenticated
/// operation while this subgraph returns it anyway. `Some(Value::Null)` pins both halves
/// of that arrangement in one assertion: `phone` is present, so the original query
/// restored the requested shape, and it is null rather than `"1234"`, so the filtered
/// query stripped the value the client may not see.
///
/// Removing either property changes this key: drop the filtered pass and it holds
/// `"1234"`, run the passes in the other order and it disappears from the response.
/// When a subgraph response includes authenticated fields, because of @requires or because
/// the subgraph is misbehaving, the authenticated field should not be propagated to the client.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Took your one-liner in 5f9f5c9, and the pass-ordering explanation moved to the two-pass site in services/execution/service.rs as suggested.

Comment on lines +544 to +549
assert_eq!(
current_user.get("phone"),
Some(&serde_json_bytes::Value::Null),
"the subgraph returned `phone` outside the filtered operation, so it must reach \
the client as null rather than as its value"
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggest additionally asserting that we have an execution error for currentUser.phone. Since the behaviour that we want is that phone was nullified because of an error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 5f9f5c9 — the error is already emitted today, path /currentUser/phone with UNAUTHORIZED_FIELD_OR_TYPE, so the assertion pins existing behaviour.

Comment on lines +641 to +642
/// Deserializing into [`graphql::Response`] collapses a JSON `null` under `data`
/// into an absent key, so assertions on whether `data` is present read the bytes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe something we should follow up on ... this could cause us to misparse responses from subgraphs today.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — graphql::Response cannot represent data: null, so a subgraph's data: null deserializes the same as no data at all. Recorded in the dev-doc's outstanding-issues section (c867886) as follow-up material; happy to file the ticket.


/// `data` is `null` and present. An absent `data` marks a request error; a null one
/// marks a field error that propagated to the root. The key's presence is part of
/// the response contract.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// the response contract.
/// the response contract.
/// This is not a spec-compliant behaviour, but is asserted for backwards compatibility.

I'm tempted to suggest merging this into the returns_http_200 test?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Merged into returns_http_200_with_null_data in 6e975af, spec-compliance note included.

}

/// `errors.response: disabled` suppresses the authorization errors, so `data: null`
/// is the only thing telling the client the operation produced nothing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wow, what a strange feature. "respond with unintelligible garbage please" 😛

Suggested change
/// is the only thing telling the client the operation produced nothing.
/// is the only thing telling the client the operation produced nothing.
/// This is not a spec-compliant behaviour, but is asserted for backwards compatibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 6e975af. ROUTER-2063 gets to decide what disabled even means once data is absent — an empty response object is spec-invalid, so the option conflicts with request-error semantics outright.

}

/// `errors.response: extensions` moves the authorization errors under
/// `extensions.authorizationErrors` and leaves `errors` out of the response.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// `extensions.authorizationErrors` and leaves `errors` out of the response.
/// `extensions.authorizationErrors` and leaves `errors` out of the response.
/// This is not a spec-compliant behaviour, but is asserted for backwards compatibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 6e975af.

---
{
"data": {
"currentUser": null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm, might need to think about this a bit. My intuition says that since the field is phone: String (i.e., it's nullable), we should actually have { currentUser: { phone: null } } as the data here. Unless currentUser was null in the subgraph, then we should have { currentUser: null }. This might get at a more fundamental GraphQL correctness problem with query filtering right now. Let's at least not address it in this PR

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and left alone here as you say. Your intuition matches the spec's field-error semantics: phone: String erroring should yield {"currentUser": {"phone": null}}, and {"currentUser": null} should only appear when propagation forces it. The parent-nulling comes from filtering removing phone from the operation entirely, so formatting never sees a position to null. Recorded in the dev-doc's outstanding-issues section (c867886) so it doesn't get lost — it likely belongs alongside ROUTER-2063 since both are "filtering changes the shape the client observes".

/// plan and the operation proceeds as a plan with no work. A fully filtered
/// operation in a document that other operations keep non-empty is reported as
/// `Filtered`.
Emptied { paths: Vec<Path> },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not totally convinced this is a useful distinction from Filtered. Why would we not have a Filtered branch with an empty document?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fair — the document already carries the fact. Collapsed in e2eb4f0: filtering returns Filtered with the empty document, and the planner's arm matches on document.definitions.is_empty() (the federation planner can't plan an empty document, so that arm still answers with a workless plan).


ServiceBuilder::new()
// Ahead of the counter below, so a refused operation stays uncounted.
.checkpoint_async(move |request: execution::Request| async move {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This came up during a claude code-review, it seems like an unintended change:

Moving the reject_unauthorized refusal from the query planner to the execution service puts three supergraph-level gates ahead of it, so an unauthorized operation can be answered with an accept-header or variable-validation error instead of the authorization response.
Before this change, filter_query returned QueryPlannerError::Unauthorized and service_call answered from the (now-deleted) QueryPlannerContent::Response arm, which sat above the Plan arm and skipped everything in it. Now every case reaches the Plan arm in services/supergraph/service.rs:272, where is_subscription/is_deferred (406) and plan.query.validate_variables (400) run before execution_service.call. Concretely: subscription { secretFeed } with secretFeed marked @authenticated, an unauthenticated client, reject_unauthorized: true, POSTed with Accept: application/json. Filtering empties the document, so FilterResult::Emptied yields a plan with root: None, but QueryPlan::is_subscription() reads query.operation.kind() (plan.rs:81), not root, so it is still true. The router replies 406 SUBSCRIPTION_BAD_HEADER — telling the caller to fix its Accept header for an operation it is not allowed to run — and this checkpoint never executes, so no Authorization error is logged and no UNAUTHORIZED_FIELD_OR_TYPE error is returned. Previously the same request got 200 + UNAUTHORIZED_FIELD_OR_TYPE. The same substitution happens for a partially filtered @defer operation without multipart/mixed (406 DEFER_BAD_HEADER) and for an unauthorized operation with a missing/invalid required variable (400 variable-validation errors). None of these are covered by the new tests or mentioned in either changeset.

@goto-bus-stop goto-bus-stop Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah, we should call it out in a changeset, it's a correct change in behaviour given that authorization enforcement conceptually moves to execution regardless of the reject_unauthorized setting.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified on the wire: the variable case returns 400 VALIDATION_INVALID_TYPE_VARIABLE with no authorization error where it previously returned the refusal. b8d9c8a adds the changeset (breaking_bryn_router_1973_gate_ordering) and pins the variable case with variable_validation_answers_before_the_refusal. The subscription and @defer cases are unpinned — they need a schema with subscriptions — and are noted in the dev-doc's outstanding-issues section.

format: json
authorization:
directives:
enabled: true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should reject_unauthorized: true be here? The check runs in the checkpoint_async of execution_service in apollo-router/src/plugins/authorization/mod.rs for log_unauthorized_paths to be called, which the new test test_authorization_error_event_in_execution_span is trying to verify.

Full code-review report:

With only authorization.directives.enabled: true, { me { name } } is emptied, the planner returns a plan with root: None, and the Authorization error event comes from ExecutionService::process_graphql_response, which explicitly wraps it in execution_span.in_scope(...) (services/execution/service.rs:238-286) — code this PR did not touch. The new log_unauthorized_paths() call inside AuthorizationPlugin::execution_service's checkpoint_async (mod.rs:612) is never reached, because reject_unauthorized is false. So the assertion the test makes (exactly one event, under the execution span) is satisfied entirely by the old path. Deleting the log_unauthorized_paths() line from the checkpoint, or adding a second log there, leaves this test green; the only test covering the refusal log, assert_logs_contain_entire_request_authorization_error, deliberately asserts nothing about the span. Adding reject_unauthorized: true to the fixture (and keeping a second case without it) would cover both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — the fixture never reached the layer's log. Fixed in 20aab33 exactly as you suggest: this fixture gains reject_unauthorized: true, a second fixture without it covers the response-formatting emitter, and both cases share one assertion helper. Verified the isolation: deleting the layer's log_unauthorized_paths() fails the reject case and leaves the no-reject case green.

bryn added 8 commits August 19, 2026 10:59
Filtering that removes every definition returns `Filtered` with the empty document,
and the planner's empty-document arm matches on `document.definitions.is_empty()`.
The variant duplicated a fact the document already carries.
Review-suggested wording. Execution was prevented, so the spec calls for a request
error with no data; the layer answers with execution errors and `data: null` for
backwards compatibility. ROUTER-2063 tracks the compliance work.
The error is already emitted, path `/currentUser/phone` with
`UNAUTHORIZED_FIELD_OR_TYPE`, so the assertion pins existing behaviour: the field is
null because it errored, not merely stripped. The test doc shrinks to the observable
contract, and the two-pass ordering explanation moves to the formatting site in the
execution service, where the ordering is enforced.
The status and null-data assertions merge into `returns_http_200_with_null_data`, the
refusal, disabled, and extensions docs note that the behaviour is asserted for
backwards compatibility rather than spec compliance, and the exactly-once helper's doc
stops discussing spans it does not assert.
Variable validation and the subscription and @defer accept-header checks run in the
supergraph service, before the execution layer refuses, so an operation failing both
receives the validation error alone. Measured for the variable case: 400
VALIDATION_INVALID_TYPE_VARIABLE with no data key and no authorization error, where
the refusal previously answered. A breaking changeset describes the ordering.

The subscription and @defer cases carry no pin: they need a schema with subscriptions.
The fixture enabled directives without reject_unauthorized, so the operation ran its
empty plan and response formatting emitted the event; the layer's emitter went
unexercised, and deleting its log call left the test green. The fixture now sets
reject_unauthorized so the layer answers and logs, and a second fixture without it
covers the response-formatting emitter. Both cases assert the event lands under the
execution span exactly once. Deleting the layer's log call fails exactly the reject
case.
Refusal is reserved for reject_unauthorized and named wherever it comes up; the
directive case reads as authorization erroring every field rather than fields being
removed, since removal is how the router works around the planner, not what the client
experiences; and the shaped-data change is framed as the spec fix it is.
Gate ordering, the graphql::Response null-collapse and its subgraph-response
implication, the partial-filter parent-nulling question, and the ROUTER-2063 scope,
alongside the FilterResult references catching up with the Emptied collapse.

@goto-bus-stop goto-bus-stop left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partial review 2 out of 3 😁

let doc = Query::parse_document(query, None, &schema, &configuration).unwrap();

let content = planner
.get(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can this test use the public .call API instead of the internal .get() API?

The comment at the top of this test is very unclear on what the actual purpose of the test is. I think it's just: if an unauthenticated client requests only fields requiring authentication, then we expect an empty query plan + all fields in .unauthorized.paths? Warmup and caching are both not relevant to that. (Sure, PQs are always unauthenticated in warmup, but that doesn't matter here at all)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that's the whole claim. Rewritten in 0ec06b8: doc states exactly that, the test calls the planner as a tower service instead of the private get, and the warm-up/caching framing is gone. Renamed fully_unauthorized_operation_plans_no_work.

Comment thread apollo-router/src/spec/query/tests.rs
Comment thread apollo-router/src/spec/query/tests.rs Outdated
Comment on lines +7744 to +7752
/// `Thing` is an interface, so `apply_selection_set` takes the concrete type from the
/// response `__typename` rather than from the schema. The filtered pass has to copy
/// `__typename` into its output for the original pass to resolve the type condition on
/// `... on Foo`, so `inline` survives only if the copy happened.
///
/// One fragment form per test: each form copies `__typename` independently, so a query
/// carrying both keeps its fields alive when either copy runs.
#[tokio::test]
async fn filtered_query_keeps_typename_for_inline_fragment() {

@goto-bus-stop goto-bus-stop Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay, it took a while but I get it now. This is testing that the double-response-formatting produces the correct result for fragment spreads on abstract (interface/union) types, which rely on the __typename returned from the subgraph.

I think that leading with "__typename must be copied" is distracting and not what's actually important. This made it very confusing for me (I spent about 15 minutes reviewing and researching just these two tests).

I'd suggest the below, to first state the goal of the test, then why it's not self-evident, and with a rename that doesn't focus on __typename propagation:

Suggested change
/// `Thing` is an interface, so `apply_selection_set` takes the concrete type from the
/// response `__typename` rather than from the schema. The filtered pass has to copy
/// `__typename` into its output for the original pass to resolve the type condition on
/// `... on Foo`, so `inline` survives only if the copy happened.
///
/// One fragment form per test: each form copies `__typename` independently, so a query
/// carrying both keeps its fields alive when either copy runs.
#[tokio::test]
async fn filtered_query_keeps_typename_for_inline_fragment() {
/// Double-applying response formatting on an abstract type should apply the
/// correct fragment spread.
/// `__typename` is not selected by the input query, but internally we have to
/// pass it along to the final formatting pass, else it wouldn't know which concrete
/// type it's working on.
#[tokio::test]
async fn filtered_inline_fragment_on_abstract_type() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

took your doc and name in 95980bf, with the spread counterpart renamed to match (filtered_fragment_spread_on_abstract_type). Kept one trailing line explaining why the forms are split across two tests: each passes __typename along independently, so a query carrying both stays green when either does.

bryn added 4 commits August 19, 2026 12:42
…on and tests

`Query::format_response_filtered_then_original` runs the filtered pass, when one
exists, then the original, and returns the nullified paths from both. The execution
service and the spec tests call it instead of each sequencing the two
`format_response` calls, so the ordering the passes depend on lives in one place.
The test asserts one thing: an unauthenticated client requesting only fields that
require authentication gets a plan with nothing to execute and every requested field
in `unauthorized.paths`. It now says so, calls the planner as a tower service instead
of the private `get`, and drops the warm-up and caching framing, which were incidental
to that claim. Renamed `fully_unauthorized_operation_plans_no_work`.
Review-suggested doc and name: the tests verify that double-applied response
formatting resolves fragment spreads on abstract types correctly, and the internal
`__typename` hand-off is the mechanism rather than the point. Renamed
`filtered_inline_fragment_on_abstract_type` and
`filtered_fragment_spread_on_abstract_type`.

@goto-bus-stop goto-bus-stop left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've only skimmed the last few tests, I could probably come up with some similar nits if I tried, but I am struggling a bit to keep focus and they don't seem incorrect, so I'm good to land them right now.

Lets rename the PR/commit as we no longer intend to separate auth from query planning and we changed behaviour here

I'm glad you picked this up, doing the reject_unauthorized enforcement at execution direction was a great idea and I think we all learned a lot from finally doing a proper deep dive on auth!

bryn added 2 commits August 19, 2026 16:08
An operation whose every field fails authorization now returns each requested root
field as null rather than `data: null`, so these assert `data.secure` and
`data.private` are null instead of asserting on `data` itself. Each test's error
placement assertions are unchanged, and the docs name the field rather than the whole
response.
The cached value serializes as `{"Ok": <QueryPlan>}` now that
`QueryPlannerContent` is a type alias, so the `Plan` and `plan` levels are gone from
the JSON path. The header's sample value is elided rather than restated: the
instructions above it are about finding the key, and a full value transcript goes
stale on every schema or field change.
@BrynCooke BrynCooke changed the title test: authorization behaviour before separating it from query planning refactor: authorization behaviour before separating it from query planning Aug 19, 2026
@BrynCooke BrynCooke changed the title refactor: authorization behaviour before separating it from query planning refactor: query planner service no longer returns graphql response Aug 19, 2026
@mergify

mergify Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@BrynCooke
BrynCooke merged commit a8c6509 into dev-v3.x Aug 19, 2026
13 checks passed
@BrynCooke
BrynCooke deleted the agent/ROUTER-1973 branch August 19, 2026 16:35
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.

3 participants