Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/28609-aggregation-entry-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': patch
'@objectstack/mcp': patch
---

fix(spec)!: an aggregation entry refuses an undeclared key by name on every door, and MCP's `aggregate_records` reads the same refusal (#28609)

Clause-②: no (narrowing) — `AggregationNodeSchema` refuses an entry key it does not declare, which the REST query door stripped and ignored before; no key, value or export is added.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) A query's aggregation entry is a request surface, the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata, so there is no source for a conversion to rewrite (the disposition the spec already records for the retired `distinct` on this same node). No declared key is added, removed or renamed and no export or type changes: an undeclared key used to be dropped without effect and is now refused by name, with `where` / `filters` / `conditions` hinted to `filter` and `as` to `alias`. -->

**BREAKING** accept-set narrowing of a query's aggregation entry (`AggregationNodeSchema`, `@objectstack/spec`), at every door that takes one.

- **Before.** The entry was a plain object, so zod deleted a key it does not declare and the call answered: `POST /api/v1/data/:object/query` with `{"function":"sum","field":"amount","alias":"s","bogusKey":true}` returned 200 and the sum, and an entry `where` meant as the entry's own `filter` computed over every row of the group. The runtime dispatcher's `/data/:object/query` did the same. The MCP tool `aggregate_records` alone refused the key, from its own closed copy of the entry (#28550), so one entry shape got two verdicts depending on the door.
- **Now.** The node is a `strictObject`. Every door that parses it (REST and the runtime dispatcher through `FindDataRequestSchema`, `EngineAggregateOptionsSchema.aggregations`, and `aggregate_records`, which now takes the node whole instead of its own copy) answers one refusal, word for word: `Unrecognized key(s) on this aggregation entry: KEY.` plus the near-miss hint, at `400 VALIDATION_FAILED` with `fields[]` on REST and as the tool's input error on MCP. Nothing reaches the engine. The retired `distinct` keeps its tombstone sentence on both doors (MCP used to answer it with the generic unknown-key refusal), and `tools/list` now lists `distinct` as a schema no value satisfies (`not: {}`) with its removal text.
- **Unchanged.** Entries that carry only `function`, `field`, `alias` and `filter` answer as before on every door. This supersedes the #28550 entry's "the REST query door is untouched": it is closed here.
- **Who sees it.** A REST, dispatcher or in-process caller that sent an extra key inside an aggregation entry: the query was answered with that key ignored, and it is now refused naming the key.

**The remedy.** Drop the key; for a per-aggregation condition send it as the entry's `filter`, and for the result column name use `alias`.
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `data/` | 158 | 92 | 2 | 0 | 64 |
| `data/` | 158 | 93 | 2 | 0 | 63 |

## `data/` — sites

Expand Down Expand Up @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**64 strip of 158**, in 10 file(s).
**63 strip of 158**, in 10 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All @@ -77,15 +77,15 @@ over it is here.
| `field.zod.ts` | 1 | 15 |
| `filter.zod.ts` | 11 | 13 |
| `hook.zod.ts` | 5 | 8 |
| `query.zod.ts` | 4 | 5 |
| `query.zod.ts` | 3 | 5 |
| `seed-loader.zod.ts` | 12 | 12 |
| **total** | **64** | **158** |
| **total** | **63** | **158** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 0 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 63 |
| wire / open — out of forced scope | 62 |
| no door — no carrier, ADR-0049 territory | 1 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |
7 changes: 4 additions & 3 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ column does not move and the `strip` column falls by the count of what left.
| `seed-loader.zod.ts` | wire | **re-verdicted 2026-08-14 (#4001 batch D)** — the `(p)` split's "authored" half dissolved under measurement. The seed FILE shape this file's old note meant is `SeedSchema` (`seed.zod.ts`, a registered type, strict since the registered-types batch) — it enters this file only as the `seeds[]` VALUE inside `SeedLoaderRequestSchema`, so the authored half was never one of these 12 sites. Every producer of the request/config halves is framework code, enumerated: `runtime/app-plugin.ts` ×3 (org replay / inline seed / hot-reload), `runtime/domains/packages.ts`, `metadata-protocol/protocol.ts` `applySeedBodies`, `metadata-protocol/seed-loader.ts` `validate()`, `cloud-connection` marketplace install — all `.parse()`/`.safeParse()` over config LITERALS written in code; no `defineStack` key, no metadata type, no Studio form, no CLI flag writes a loader config. `SeedIdentity` is server-constructed by design (`execution-context.zod.ts`: "never client-supplied"); graph/resolution/result/error shapes are built by the loader itself (`buildDependencyGraph`, `buildResult`). BFS from all 26 metadata-type roots + `ObjectStackSchema` (6689 nodes; `ObjectSchema` positive control REACHABLE, fresh uncarried shape negative control UNREACHABLE, synthetic-carrier flip green, same run): all 12 UNREACHABLE. Same class as `data-engine.zod.ts` — an internal service contract (`ISeedLoaderService`), machine-fed at every door |
| `field.zod.ts` | authorable | partially strict |
| `filter.zod.ts` | open | query dialect — user data flows through the predicate values; validated semantically elsewhere |
| `query.zod.ts` | open, **except `SortNodeSchema` → authorable** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites are the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and keep the class. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) |
| `query.zod.ts` | open, **except `SortNodeSchema` and `AggregationNodeSchema` → strict** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites were the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and kept the class; three still do. **`AggregationNodeSchema` is strict as of #28609** (`strictObject` + `aliases: { where, filters, conditions → filter; as → alias }`, the retired `distinct` keeping its tombstone): the MCP tool `aggregate_records` had closed its own copy of the entry (#28550) while `POST /data/:object/query` stripped the same undeclared key and answered, so one entry got two verdicts by door; closing the node at the producer gave every door the one refusal. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) |
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | wire | driver capability contracts |
| `datasource.zod.ts` | authorable | **strict as of #4001 data step** — all 6: `DatasourceSchema` (+ `pool` / `ssl`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. **#4583 B/C dropped two more sites**: the `healthCheck` and `retryPolicy` blocks are gone — nothing scheduled a probe and nothing retried, so their strictness was validating a shape no code consumed. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged
| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate |
Expand Down Expand Up @@ -1260,7 +1260,7 @@ triage row record which one was taken.
| `filter.zod.ts` | open | **out of scope** — query dialect; user data flows through, validated semantically elsewhere |
| `driver-nosql.zod.ts` | wire | **out of scope** |
| `driver.zod.ts` | wire | **out of scope** — driver capability contract |
| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 |
| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites kept `open`. Those 4 were the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721. #28609 then closed `AggregationNodeSchema` (one entry, one refusal on REST, the dispatcher and MCP), leaving 3 |
| `external-catalog.zod.ts` | wire (p) | **out of scope** |
| `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step |
| `field.zod.ts` | no door | ⛔ **not strictness work — re-verdicted 2026-08-13 (#4001 data batch).** *(2026-10-28, #27182: `CurrencyValueSchema` and its `CurrencyValue` type are DELETED — registered as `RETIRED_DEFS_BY_MAJOR[18]` `data/CurrencyValue` with the D3 entry `currency-value-schema-retired`, ADR-0104 D1 carried out ahead of the multi-currency epic #8345. One strip site remains, `LocationCoordinatesSchema`, and the verdict below stands for it.)* This row's own instruction was "check whether they are record data (→ open) before closing", and the measured answer is the THIRD one: neither authorable nor open. Both remaining strip sites (`LocationCoordinatesSchema`, `CurrencyValueSchema`) are `@deprecated` DEAD EXPORTS that contradict the enforced value contract — `currency` stores a BARE NUMBER everywhere (validator, SQL driver `float` column, import coercion, field-zoo oracle), `location` stores `{lat, lng}` not `{latitude, longitude}`. Carrier: absent — no schema in the tree references either, so unreachability from every authoring root holds by construction and no BFS is needed (the only non-test references are two `type-alias-convention.pin.test.ts` rows). Parse: absent outside their own `field.test.ts` cases. Consumers' vocabulary: absent — zero references in objectui; `field-value.test.ts` pins from the other direction that the enforced contract REJECTS the retired `CurrencyValueSchema` object shape. The ADR-0049 answer this class prescribes already exists: ADR-0104's "Reality wins" section decides both removals ("an exported-but-unconsumed value schema is exactly the inert metadata ADR-0078 forbids"), the JSDoc deprecations are on `main` with "Removal rides the next spec major", and the removal is tracked at **#8562** — this row points there, never at a batch. Closing them instead would be #4583's precisely-validated dead slot, and worse than most instances of it: a `strictObject` `surface` name plus did-you-mean suggestions on a shape authors must NOT use is an invitation dressed as enforcement, on the exact spelling (`{value, currency}` / `{latitude, longitude}`) the real contract rejects. The third named shape the old row carried, `Address`, was never this file's site — `AddressSchema` is DECLARED in `field-value.zod.ts` since #7127 and only re-exported here |
Expand All @@ -1287,7 +1287,8 @@ until #8075 retired the whole file under ADR-0049 — its per-schema read arrive
zero-consumer verdict, and the strictness question died with the shapes.) The rest is wire/open and out of the
ruling's forced scope; that count fell by one when #4721 closed `query.zod.ts`'s
`SortNodeSchema`, the one row in this directory where the per-schema read moved a site OUT
of `open` rather than confirming it, and by one more when **#4001 batch B** closed
of `open` rather than confirming it, by one when #28609 closed the same file's
`AggregationNodeSchema` (a door-parity close: MCP already refused the entry key REST stripped), and by one more when **#4001 batch B** closed
`driver/memory.zod.ts`'s remaining 5 sites (the persistence-adapter union under
`datasource.config` — `PersistenceAdapterSchema`, `FilePersistenceConfigSchema`,
`LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema`,
Expand Down
Loading
Loading