Snapshot Aggregation
Snapshot aggregation treats each aggregate's current materialized state as the source of truth and returns dynamic table rows whose columns are group and metric aliases. See Aggregation Queries for the shared AST, aliases, sorting, and structural limits. This page applies that contract to snapshots.
Numeric FIELD inputs and arithmetic leaves follow numeric contributions and precision: each current record contributes only when exactly one stored numeric value remains after nulls are ignored; duplicates count separately. COUNT still counts records. This preserves singleton-array support without changing histogram bucket contracts.
Capabilities and Entry Points
- JVM Gateway: inject the aggregate-scoped
SnapshotQueryGateway<OrderState>through Spring, build anAggregationQuery, and callquery.query(snapshotQueryGateway). This Bean executes policies through QueryGateway; see Query Backends for the direct Backend Factory bypass boundary. - HTTP / OpenAPI: the example domain publishes
POST /sales-order/snapshot/aggregation,POST /tenant/{tenantId}/sales-order/snapshot/aggregation, andPOST /owner/{ownerId}/sales-order/snapshot/aggregation. The request body isAggregationQueryJSON, and the response can negotiateapplication/jsonortext/event-stream. Use the running service's generated OpenAPI for exact paths and scope parameters. - Snapshot API Client: reactive and synchronous clients use the separate
ReactiveSnapshotAggregationQueryApiandSynchronousSnapshotAggregationQueryApi; they are not folded into the regular snapshot-query interfaces. See the general API Client guide for dependencies and invocation.
Every HTTP JSON block below is a request body for one of these snapshot/aggregation routes. Results are representative dynamic rows, not fixed business data.
Field Paths and Counting Units
Without elements, the root filter, groups, and metrics use absolute snapshot logical paths such as state.status; one record is one current root snapshot document. Business fields under state need the corresponding filter, group, or numeric capabilities from the Query Model Schema (current guidance).
After expand("state.items"), the counting unit becomes one expanded order item. The first Element path remains absolute, while its filter and all following group, metric, and expression fields are relative to that element. Use quantity, productId, and price, not state.items.quantity. Groups only bucket records and do not change the counting unit; COUNT always counts the current innermost scope.
Scenario 1: Status Breakdown
Business question
How many current order snapshots are in each status?
Counting unit
Root snapshot documents; each current order snapshot is counted once.
Kotlin DSL
val query = aggregation {
terms("state.status", "status")
count("count")
}HTTP JSON and result interpretation
{
"groupBy": [
{"type": "TERMS", "field": "state.status", "alias": "status"}
],
"metrics": [
{"type": "COUNT", "alias": "count"}
]
}[
{"status": "PAID", "count": 42},
{"status": "FAILED", "count": 8}
]status is the group column, and count is the number of snapshots in each group. state.status must have TERMS aggregation capability. For example, Elasticsearch normally needs an aggregatable keyword field; an arbitrary text mapping is not equivalent.
Scenario 2: Filtered KPIs
Business question
How many orders have failed, and how many retries did they average?
Counting unit
Failed snapshots matching state.status = FAILED; with no group, all failed snapshots are summarized into one row.
Kotlin DSL
val query = aggregation {
filter { "state.status" eq "FAILED" }
count("failedCount")
avg("state.retryState.retries", "averageRetries")
}HTTP JSON and result interpretation
{
"filter": {"op": "EQ", "field": "state.status", "value": "FAILED"},
"metrics": [
{"type": "COUNT", "alias": "failedCount"},
{
"type": "NUMERIC",
"function": "AVG",
"expression": {"type": "FIELD", "field": "state.retryState.retries"},
"alias": "averageRetries"
}
]
}[
{"failedCount": 8, "averageRetries": 2.5}
]failedCount counts failed snapshots. averageRetries averages only contributing numeric retry counts and is null when no numeric value contributes. The fields need exact-match and numeric-aggregation capability, respectively.
Scenario 3: Numeric Range Distribution
Business question
Which 100-unit ranges contain the current order amounts?
Counting unit
Root snapshot documents; each current order snapshot enters one amount bucket.
Kotlin DSL
val query = aggregation {
histogram("state.totalAmount", 100.0, "amountRange")
count("orderCount")
}HTTP JSON and result interpretation
{
"groupBy": [
{
"type": "HISTOGRAM",
"field": "state.totalAmount",
"alias": "amountRange",
"interval": 100
}
],
"metrics": [
{"type": "COUNT", "alias": "orderCount"}
]
}[
{"amountRange": 0.0, "orderCount": 12},
{"amountRange": 100.0, "orderCount": 27}
]amountRange is the lower bound of each 100-wide bucket, and orderCount is the number of snapshots in that bucket. state.totalAmount must have numeric-histogram capability; a valid JSON shape cannot make an invalid or nonnumeric field aggregatable.
Scenario 4: Business-Time Trend
Business question
How are current order creations distributed across Shanghai business days?
Counting unit
Root snapshot documents; each current order snapshot enters one day according to the business field state.createdAt.
Kotlin DSL
val query = aggregation {
dateHistogram(
"state.createdAt",
AggregationDateUnit.DAY,
"day",
ZoneId.of("Asia/Shanghai"),
)
count("createdCount")
}HTTP JSON and result interpretation
{
"groupBy": [
{
"type": "DATE_HISTOGRAM",
"field": "state.createdAt",
"alias": "day",
"unit": "DAY",
"timeZone": "Asia/Shanghai"
}
],
"metrics": [
{"type": "COUNT", "alias": "createdCount"}
]
}[
{"day": 1787846400000, "createdCount": 31},
{"day": 1787932800000, "createdCount": 24}
]day is the bucket-start epoch milliseconds aligned to Asia/Shanghai, and createdCount is the number of snapshots in the bucket. state.createdAt is a business time defined by order state. Root createTime is an event-stream record field and is not part of the MaterializedSnapshot root model, so it cannot replace this field. MongoDB must prove a BSON Date or a declared numeric epoch, while Elasticsearch must prove date/date_nanos or runtime-date capability for a declared epoch. A formatted string does not gain date-histogram capability merely from a date pattern.
Scenario 5: Line-Item Top-N
Business question
Which products have the highest valid purchased quantity in paid orders?
Counting unit
Expanded order items; only items with quantity > 0 from root snapshots whose state.status is PAID contribute. Multiple items in one order are counted and summed separately.
Kotlin DSL
val query = aggregation {
filter { "state.status" eq "PAID" }
expand("state.items") { "quantity" gt 0 }
terms("productId", "productId")
sum("quantity", "totalQuantity")
sort { "totalQuantity".desc() }
limit(10)
}HTTP JSON and result interpretation
{
"filter": {"op": "EQ", "field": "state.status", "value": "PAID"},
"elements": [
{
"path": "state.items",
"filter": {"op": "GT", "field": "quantity", "value": 0}
}
],
"groupBy": [
{"type": "TERMS", "field": "productId", "alias": "productId"}
],
"metrics": [
{
"type": "NUMERIC",
"function": "SUM",
"expression": {"type": "FIELD", "field": "quantity"},
"alias": "totalQuantity"
}
],
"sort": [
{"field": "totalQuantity", "direction": "DESC"}
],
"limit": 10
}[
{"productId": "product-1", "totalQuantity": 96.0},
{"productId": "product-2", "totalQuantity": 71.0}
]Sorting references the metric alias totalQuantity, and limit: 10 makes this a Top-N query. state.items must have Element-scope capability; after expansion, productId and quantity are element-relative paths. Elasticsearch needs the corresponding nested mapping to preserve same-item field correlation. When expensive operators are disabled, HTTP rejects both Elements and metric-alias sorting.
Scenario 6: Derived Amount Metric
Business question
What is each product's net amount, price × quantity - discount, across all order items?
Counting unit
Expanded order items; derive an amount for each item before summing by product.
Kotlin DSL
val query = aggregation {
expand("state.items")
terms("productId", "productId")
sum(
field("price") * field("quantity") - field("discount"),
"netAmount",
)
}HTTP JSON and result interpretation
{
"elements": [
{"path": "state.items"}
],
"groupBy": [
{"type": "TERMS", "field": "productId", "alias": "productId"}
],
"metrics": [
{
"type": "NUMERIC",
"function": "SUM",
"expression": {
"type": "BINARY",
"operator": "SUBTRACT",
"left": {
"type": "BINARY",
"operator": "MULTIPLY",
"left": {"type": "FIELD", "field": "price"},
"right": {"type": "FIELD", "field": "quantity"}
},
"right": {"type": "FIELD", "field": "discount"}
},
"alias": "netAmount"
}
]
}[
{"productId": "product-1", "netAmount": 1280.0},
{"productId": "product-2", "netAmount": 930.0}
]netAmount is the sum of valid numeric-expression contributions in each group. All three operands are relative to one order item and need numeric-aggregation capability. HTTP rejects this non-Field expression when allow-expensive-operators=false.
Scenario 7: Multidimensional Cross-Analysis
Business question
How are current orders distributed across the status and channel dimensions?
Counting unit
Root snapshot documents; each current order snapshot enters one status-and-channel combination.
Kotlin DSL
val query = aggregation {
terms("state.status", "status")
terms("state.channel", "channel")
count("count")
}HTTP JSON and result interpretation
{
"groupBy": [
{"type": "TERMS", "field": "state.status", "alias": "status"},
{"type": "TERMS", "field": "state.channel", "alias": "channel"}
],
"metrics": [
{"type": "COUNT", "alias": "count"}
]
}[
{"status": "PAID", "channel": "APP", "count": 28},
{"status": "PAID", "channel": "WEB", "count": 14}
]Groups are declared in status, then channel order, and the two aliases identify each cross bucket. Both fields need TERMS capability; the number of returned combinations remains bounded by query limit and the HTTP result limit.
Scenario 8: Display Fields for Groups
Business question
How many order items belong to each product ID, with one product name added for display?
Counting unit
Expanded order items; lineCount counts order items in each product group, not order snapshots.
Kotlin DSL
val query = aggregation {
expand("state.items")
terms("productId", "productId")
any("name", "name")
count("lineCount")
}HTTP JSON and result interpretation
{
"elements": [
{"path": "state.items"}
],
"groupBy": [
{"type": "TERMS", "field": "productId", "alias": "productId"}
],
"metrics": [
{"type": "ANY", "field": "name", "alias": "name"},
{"type": "COUNT", "alias": "lineCount"}
]
}[
{"productId": "product-1", "name": "Keyboard", "lineCount": 19},
{"productId": "product-2", "name": "Mouse", "lineCount": 15}
]ANY is suitable only for a display field whose value is stable within each productId group. If one product ID has multiple name values, the selected non-null value is unstable across executions or backends. For deterministic results, repair the business data or model the name as a deterministic group key instead of relying on ANY.
Scenario 9: Distinct Customers and P95 Amount
Business question
How many distinct customers does each order status involve, and what are the P95 and the population standard deviation of those order amounts?
Counting unit
Root snapshot documents; each current order snapshot contributes one customer-ID participation value and one amount participation value.
Kotlin DSL
val query = aggregation {
filter { deletion(DeletionState.ACTIVE) }
terms("state.status", "status")
distinctCount("state.customerId", "customers")
percentile("state.totalAmount", 95.0, "p95Amount")
stddev("state.totalAmount", "amountStddev")
sort { "customers".desc() }
}HTTP JSON and result interpretation
{
"filter": {"op": "DELETION", "state": "ACTIVE"},
"groupBy": [
{"type": "TERMS", "field": "state.status", "alias": "status"}
],
"metrics": [
{
"type": "DISTINCT_COUNT",
"expression": {"type": "FIELD", "field": "state.customerId"},
"alias": "customers"
},
{
"type": "PERCENTILE",
"expression": {"type": "FIELD", "field": "state.totalAmount"},
"percentile": 95,
"alias": "p95Amount"
},
{
"type": "NUMERIC",
"function": "STDDEV",
"expression": {"type": "FIELD", "field": "state.totalAmount"},
"alias": "amountStddev"
}
],
"sort": [
{"field": "customers", "direction": "DESC"}
]
}[
{"status": "PAID", "customers": 35, "p95Amount": 812.5, "amountStddev": 143.2},
{"status": "FAILED", "customers": 6, "p95Amount": 240.0, "amountStddev": 87.6}
]customers is the distinct customer count per group: DISTINCT_COUNT deduplicates non-null contribution values only, yields 0 for an empty set, and lets array fields participate element by element, which differs from the NUMERIC rule (see numeric contributions and precision). p95Amount and amountStddev follow the same numeric-contribution rule as SUM/AVG and are null when nothing contributes; amountStddev is population-standard-deviation and returns 0 for a single value; p95Amount is approximated by t-digest. PERCENTILE on the MongoDB backend requires server 7.0+. The explicit deletion filter matches the Gateway's default DELETION = ACTIVE.
Scenario 10: Funnel: Conditional Counts in One Chart
Business question
Without issuing multiple queries, how can one result table show the two-level funnel of "all orders → paid orders"?
Counting unit
Root snapshot documents; orderCount counts every current snapshot, while paidCount counts only snapshots with state.status = PAID.
Kotlin DSL
val query = aggregation {
count("orderCount")
count("paidCount") { "state.status" eq "PAID" }
}HTTP JSON and result interpretation
{
"metrics": [
{"type": "COUNT", "alias": "orderCount"},
{"type": "COUNT", "alias": "paidCount", "filter": {"op": "EQ", "field": "state.status", "value": "PAID"}}
]
}[
{"orderCount": 50, "paidCount": 42}
]Both metrics share the same counting unit and root filter; each metric filter applies to its own metric only, so paidCount ≤ orderCount always holds. Each funnel level is one independent filtered COUNT — append more levels instead of splitting the funnel into multiple queries. See Metric Filter for empty-match semantics, limits, and version requirements.
Scenario 11: Attainment Ratio and Paid AOV
Business question
In one result table, what is each order status's attainment ratio of paid amount against target amount, and which status has the highest paid AOV?
Counting unit
Root snapshot documents; paid and paidAmount count only snapshots with state.status = PAID, targetAmount sums every snapshot in the group, and the two derived metrics divide those results after aggregation finishes.
Kotlin DSL
val query = aggregation {
terms("state.status", "status")
count("paid") { "state.status" eq "PAID" }
sum("state.totalAmount", "paidAmount") { "state.status" eq "PAID" }
derived("paidAov") { ref("paidAmount") / ref("paid") }
sum("state.targetAmount", "targetAmount")
derived("attainment") { ref("paidAmount") / ref("targetAmount") }
sort { "paidAov".desc() }
}HTTP JSON and result interpretation
{
"groupBy": [
{"type": "TERMS", "field": "state.status", "alias": "status"}
],
"metrics": [
{"type": "COUNT", "alias": "paid", "filter": {"op": "EQ", "field": "state.status", "value": "PAID"}},
{
"type": "NUMERIC",
"function": "SUM",
"expression": {"type": "FIELD", "field": "state.totalAmount"},
"alias": "paidAmount",
"filter": {"op": "EQ", "field": "state.status", "value": "PAID"}
},
{
"type": "DERIVED",
"alias": "paidAov",
"expression": {
"type": "BINARY",
"operator": "DIVIDE",
"left": {"type": "METRIC_REF", "metric": "paidAmount"},
"right": {"type": "METRIC_REF", "metric": "paid"}
}
},
{
"type": "NUMERIC",
"function": "SUM",
"expression": {"type": "FIELD", "field": "state.targetAmount"},
"alias": "targetAmount"
},
{
"type": "DERIVED",
"alias": "attainment",
"expression": {
"type": "BINARY",
"operator": "DIVIDE",
"left": {"type": "METRIC_REF", "metric": "paidAmount"},
"right": {"type": "METRIC_REF", "metric": "targetAmount"}
}
}
],
"sort": [
{"field": "paidAov", "direction": "DESC"}
]
}[
{"status": "PAID", "paid": 42, "paidAmount": 21420.0, "paidAov": 510.0, "targetAmount": 30000.0, "attainment": 0.714},
{"status": "FAILED", "paid": 0, "paidAmount": null, "paidAov": null, "targetAmount": 8000.0, "attainment": null}
]paidAov = paidAmount / paid, and attainment = paidAmount / targetAmount. The FAILED row demonstrates empty-set semantics: no PAID record exists in the group, so paidAmount is null on an empty set, and any null operand propagates null — both derived metrics are therefore null (dividing by paid = 0 yields null as well). A derived metric cannot carry a metric filter itself; its combination with metric filtering is to reference filtered metrics, and sort can reference a derived alias directly. HTTP rejects derived metrics as arithmetic expressions when expensive operators are disabled. See Derived Metrics for reference rules and semantics.
Scenario 12: Attainment-Threshold Filtering
Business question
Which order statuses reach at least 80% attainment of a fixed target (6000) in paid amount, with more than 10 paid orders?
Counting unit
Root snapshot documents; paid and paidAmount count only snapshots with state.status = PAID, attainment is computed after aggregation, and having then filters grouped rows by aggregated values.
Kotlin DSL
val query = aggregation {
terms("state.status", "status")
count("paid") { "state.status" eq "PAID" }
sum("state.totalAmount", "paidAmount") { "state.status" eq "PAID" }
derived("attainment") { ref("paidAmount") / constant(6000.0) }
having {
("attainment" gte 0.8) and ("paid" gt 10.0)
}
sort { "attainment".desc() }
limit(20)
}HTTP JSON and result interpretation
{
"groupBy": [
{"type": "TERMS", "field": "state.status", "alias": "status"}
],
"metrics": [
{"type": "COUNT", "alias": "paid", "filter": {"op": "EQ", "field": "state.status", "value": "PAID"}},
{
"type": "NUMERIC",
"function": "SUM",
"expression": {"type": "FIELD", "field": "state.totalAmount"},
"alias": "paidAmount",
"filter": {"op": "EQ", "field": "state.status", "value": "PAID"}
},
{
"type": "DERIVED",
"alias": "attainment",
"expression": {
"type": "BINARY",
"operator": "DIVIDE",
"left": {"type": "METRIC_REF", "metric": "paidAmount"},
"right": {"type": "CONSTANT", "value": 6000.0}
}
}
],
"having": {"type": "AND", "operands": [
{"type": "CONDITION", "metric": "attainment", "operator": "GTE", "value": 0.8},
{"type": "CONDITION", "metric": "paid", "operator": "GT", "value": 10}
]},
"sort": [
{"field": "attainment", "direction": "DESC"}
],
"limit": 20
}[
{"status": "PAID", "paid": 42, "paidAmount": 5400.0, "attainment": 0.9}
]Having filters groups by their per-row metric results after aggregation, keeping only statuses with attainment ≥ 0.8 and paid > 10; both sort and limit apply to the filtered rows, so non-qualifying statuses (for example attainment = 0.5 or paid ≤ 10) do not consume limit slots. Null fails: a group whose paidAmount is null (no PAID record in the group) fails every comparison; switch to isNull() when those groups are the target. This bites hard here: both metrics keep only PAID records while the query groups by state.status, so every non-PAID group evaluates paid = 0 and attainment = null and is dropped — only the PAID row can survive. Grouping by an independent dimension (product, customer) is the pattern to use when non-PAID groups should be measurable. Having may reference only declared metric aliases (group aliases and unknown names are rejected), never an ANY metric; referencing derived metrics has no declaration-order restriction. The HTTP guard counts filter and having nodes toward one shared max-filter-nodes budget and comparison values toward max-filter-values. Cost follows the required ordering: metric-value top-N inherently scans every bucket (having adds no extra scan), while group-alias sorting with a selective having stops early once limit survivors are collected and only degrades to a full scan when survivors are sparse. See HAVING for semantics and rules.
Scenario 13: Dense Time-Series Fill
Business question
When charting the daily order-creation trend, days without orders must still hold a place on the chart — what are the metric values on gap days?
Counting unit
Root snapshot documents; each current order snapshot enters one day according to the business field state.createdAt, and dense: true fills the dates between the first and last actual bucket into a consecutive series.
Kotlin DSL
val query = aggregation {
dateHistogram(
"state.createdAt",
AggregationDateUnit.DAY,
"day",
dense = true,
)
count("count")
sum("state.totalAmount", "total")
derived("aov") { ref("total") / ref("count") }
}HTTP JSON and result interpretation
{
"groupBy": [
{
"type": "DATE_HISTOGRAM",
"field": "state.createdAt",
"alias": "day",
"unit": "DAY",
"timeZone": "UTC",
"dense": true
}
],
"metrics": [
{"type": "COUNT", "alias": "count"},
{
"type": "NUMERIC",
"function": "SUM",
"expression": {"type": "FIELD", "field": "state.totalAmount"},
"alias": "total"
},
{
"type": "DERIVED",
"alias": "aov",
"expression": {
"type": "BINARY",
"operator": "DIVIDE",
"left": {"type": "METRIC_REF", "metric": "total"},
"right": {"type": "METRIC_REF", "metric": "count"}
}
}
]
}[
{"day": 1767225600000, "count": 1, "total": 10.0, "aov": 10.0},
{"day": 1767312000000, "count": 2, "total": 40.0, "aov": 20.0},
{"day": 1767398400000, "count": 1, "total": 30.0, "aov": 30.0},
{"day": 1767484800000, "count": 0, "total": null, "aov": null},
{"day": 1767571200000, "count": 0, "total": null, "aov": null},
{"day": 1769904000000, "count": 1, "total": null, "aov": null},
{"day": 1769990400000, "count": 1, "total": 50.0, "aov": 50.0}
]day is the bucket-start epoch milliseconds aligned to UTC. The window runs from the first actual bucket 2026-01-01 to the last actual bucket 2026-02-02 and fills interior gaps only: the example elides the filled days 2026-01-06..2026-01-31; the complete result has 33 rows, 28 of which are filled rows. Filled rows follow the empty semantics — count is 0 and total is null, and the derived aov evaluates over those values and is null as well. Note that 2026-02-01 is an actual bucket with count = 1; its total is null only because that day's snapshot contributed no valid amount, which differs from a filled row's count = 0. Filled rows participate in sorting, having, and limit: sort { "day".desc() } places filled rows in reverse grid order, having { "count" gte 1.0 } drops exactly the filled rows, and limit(5) counts filled rows toward its slots. dense requires DATE_HISTOGRAM to be the only group dimension; the MongoDB backend requires server 5.1+. See Dense Date Histograms for the semantics.
Scenario 14: Missing-Value Bucket
Business question
When counting order items by product name, how do items whose product name is missing or null keep a place in the result instead of disappearing?
Counting unit
Expanded order items; productName is a nullable single-valued string, and items where it is missing or null land in the sentinel-key __missing__ bucket.
Kotlin DSL
val query = aggregation {
expand("state.items")
terms("productName", "name", missingKey = "__missing__")
count("lineCount")
}HTTP JSON and result interpretation
{
"elements": [
{"path": "state.items"}
],
"groupBy": [
{"type": "TERMS", "field": "productName", "alias": "name", "missingKey": "__missing__"}
],
"metrics": [
{"type": "COUNT", "alias": "lineCount"}
]
}[
{"name": "Alpha", "lineCount": 1},
{"name": "Alpha 2026", "lineCount": 1},
{"name": "__missing__", "lineCount": 4}
]The __missing__ bucket collects the 4 of the example's 6 order items that carry no product name. The sentinel sorts as a plain string lexicographically — "Alpha" < "Alpha 2026" < "__missing__" (A is 0x41 and _ is 0x5F) — identically on MongoDB and Elasticsearch; the sentinel has no fixed first or last position and lands wherever the lexicographic order places it. The sentinel shares the key space with real keys: if the data really contains a product name equal to the sentinel, both merge into one bucket. missingKey may be declared only on single-valued string fields — nullable strings are the canonical case; multi-valued, numeric, and boolean fields are rejected at construction or schema validation, and HISTOGRAM/DATE_HISTOGRAM offer no missing bucket. See Missing-Value Buckets for the semantics.
Backend Capabilities and Stability Boundaries
- The Snapshot Gateway appends
DELETION = ACTIVEby default. Direct Backend callers supply deletion scope explicitly; the normalizer and compiler do not add defaults. The root filter first selects snapshots, and each Element filter then selects individual expanded elements. - The runtime Query Model Schema and selected MongoDB or Elasticsearch mapping jointly prove whether logical fields support exact match, range, Element scope, TERMS, numeric, or temporal aggregation. A valid request DTO does not establish backend support.
- The HTTP Handler applies QueryRequestScope and independent
HttpQueryGuardlimits before invokingSnapshotQueryGateway. When expensive operators are disabled, HTTP rejects Elements, metric-alias sorting, and arithmetic expressions. In-process JVM calls do not automatically receive these HTTP-only limits. - Masked fields remain valid for ordinary filters, full-text search, and sorting. A group, field metric, or arithmetic expression that references one is rejected by Gateway public validation;
COUNTis unchanged. See Field Masking for the complete matrix. - MongoDB and Elasticsearch share the public AST but do not promise identical physical pipelines, mappings, null handling, or bucket details.
ANYin particular provides no stable value across executions or backends. - A custom
SnapshotQueryBackendmust implement the aggregation contract. Working data-query routes or published OpenAPI alone do not prove that the Backend executes aggregation.