From 1d1c74f794084977809d0d359a4cedf39516e5ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 02:34:50 +0000 Subject: [PATCH 1/4] docs(spec,objectql,analytics): restate the dissolved #5499 freeze prose in past tense at the surviving product-source sites --- packages/objectql/src/having-filter.ts | 9 ++++++--- .../services/service-analytics/src/read-scope-sql.ts | 7 ++++++- .../semantic/17.aggregation-node-distinct-retired.ts | 3 ++- .../17.driver-sql-distinct-bare-filter-typed.ts | 5 +++-- .../semantic/17.query-array-string-agg-retired.ts | 3 ++- packages/spec/src/migrations/registry.ts | 10 ++++++---- 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index afd1278a07..92e02ff899 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -37,9 +37,12 @@ // the same vocabulary and was not in that PR's inventory, which left this // file as the lone holdout (#5905) — and the only face no conformance table // covers, since `FILTER_LOGIC_CASES` does not drive the HAVING path. -// driver-memory / driver-mongodb still answer the old way only because -// #5499 freezes them; the divergence is against a frozen face, not against -// the ruling. +// driver-memory / driver-mongodb still answer the old way. That was because +// #5499 froze them — the divergence was against a frozen face, not against +// the ruling — but the freeze dissolved 2026-08-11 (head note of +// `@objectstack/spec`'s `aggregation-conformance.ts`), so the divergence is +// now unexcused rather than deferred. Nothing has been triaged against it +// yet, and disposing of it is not this file's call. // // [#7158] A THIRD divergence has been REMOVED rather than added: this face had // no comparand-shape gate, which is what the five sibling faces refuse an diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index d78f370889..bf7c24ce54 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -177,7 +177,12 @@ import { * value as a THIRD semantics ("the key is absent from the record") and is * deliberately left alone — deciding it here would settle #5299's * key-missing-vs-value-null question as a side effect — and `driver-memory` / - * `driver-mongodb` stay pin-only under the #5499 freeze. + * `driver-mongodb` were pin-only under the #5499 freeze when #6125 scoped this, + * so this compiler and those two answer the cell differently on purpose — a debt + * #6125 recorded as owed AT THAW. The thaw has arrived: the freeze dissolved + * 2026-08-11 (head note of `@objectstack/spec`'s `aggregation-conformance.ts`), + * so that debt is now DUE rather than deferred, and nothing has been triaged + * against it yet. * * The eleventh message was measured against `looksLikeInternalErrorLeak` before * being added, because the section above turns on that predicate answering FALSE diff --git a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts index e0fcf64958..63fa050672 100644 --- a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts @@ -29,7 +29,8 @@ export const entry: SemanticMigration = { + 'fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move ' + '`min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): ' + '`count_distinct` already covers the only spelling anyone has measured demand for, ' - + 'and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — ' + + 'and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under ' + + '#5499, a freeze lifted 2026-08-11, after this ruling — ' + 'buys a shape that is near-universally a modelling mistake. A REQUEST surface — ' + '`QueryAST` is the client SDK builder\'s output and the `POST /data/:object/query` ' + 'body, never stored in stack metadata — so there is no source for the chain to ' diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts index 950faa19de..45a4067135 100644 --- a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts @@ -41,8 +41,9 @@ export const entry: SemanticMigration = { + 'valid filter — one constraining columns named `object` and `where` — and so is a ' + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' - + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 ' - + 'freeze (#6320). ADR-0087, #6320.', + + 'BARE spelling returns the unfiltered set in silence — stayed open under the #5499 ' + + 'freeze, which was lifted on 2026-08-11; it is still open, now unexcused rather than ' + + 'deferred (#6320). ADR-0087, #6320.', acceptanceCriteria: 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts index b447c5275a..9330a6224f 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts @@ -21,7 +21,8 @@ export const entry: SemanticMigration = { + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was ' + + 'ruling ended, and #5499 had both of those backends frozen at the time (that freeze ' + + 'was lifted on 2026-08-11). `count_distinct` was ' + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.', acceptanceCriteria: diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 02b7716e01..a6e0d941cd 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1249,8 +1249,9 @@ const step17: MigrationStep = { 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ', 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ', 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ', - 'backends that implemented these two are inside the #5499 freeze and are untouched; their ', - 'code is simply no longer reachable through a spec-valid request.\n\n', + 'backends that implemented these two were inside the #5499 freeze when this was decided ', + '(it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable ', + 'through a spec-valid request.\n\n', 'The same aggregation node loses one more member, and it is the sharper class of the two: ', '`aggregations[].distinct` is removed (#6815, ADR-0049, maintainer ruling 2026-08-09). ', 'The functions above were declared and UNLOWERED — a caller on a SQL datasource got a ', @@ -1270,8 +1271,9 @@ const step17: MigrationStep = { 'per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL ', 'lowering #6409 landed) already covers the only deduplicating spelling with measured ', 'demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling ', - 'mistake and would have to be lowered across five faces, two of them frozen under #5499, ', - 'to buy it. The blast radius inside the fallback is narrower than the key suggests and was ', + 'mistake and would have to be lowered across five faces, two of them then frozen under ', + '#5499 (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the ', + 'fallback is narrower than the key suggests and was ', 'measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned ', 'from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe ', 'does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is ', From 1436d84c597d606ac31775956676aa03615a60d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 02:46:52 +0000 Subject: [PATCH 2/4] chore(spec): regenerate migration registry, spec-changes and the upgrade guide --- docs/protocol-upgrade-guide.md | 10 +++++----- packages/spec/spec-changes.json | 12 ++++++------ packages/spec/src/migrations/registry.ts | 11 +++++++---- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 16daf731cc..c756598277 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -226,9 +226,9 @@ That count turned out to be incomplete, and #6776 finishes it: five more keys th The third, `record:details.layout`, is a sharper shape and the one worth reading twice: it IS read. The renderer computes `schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, while the declared enum is `auto | custom` — so neither legal value can match, both take the same branch, and a key that was accepted and read still selected nothing, under a `.describe()` promising "auto uses object highlightFields, custom uses explicit sections". The behaviour that prose describes is real, but the renderer keys it off whether `sections` was authored, never off this flag. Every gate stayed green because `check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same `auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip for the same reason: `auto`, `custom` and omission were behaviourally identical, so there is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key and is untouched. objectui#3829 and objectui#3818 drop the exemptions, the input and the dead branch on the next pin bump. -Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request. +Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two were inside the #5499 freeze when this was decided (it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable through a spec-valid request. -The same aggregation node loses one more member, and it is the sharper class of the two: `aggregations[].distinct` is removed (#6815, ADR-0049, maintainer ruling 2026-08-09). The functions above were declared and UNLOWERED — a caller on a SQL datasource got a refusal. This flag was declared and lowered by exactly ONE of the six faces that read an aggregation: the engine's in-memory fallback deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored it. So the same query answered a deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, with the engine choosing between the two per query — by driver, by a non-UTC date bucket, by whether the driver aggregates natively at all. That is the divergence class #6203 and #5907 each closed on this axis, still open on this key, and it is worse to sit on because the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing for a dashboard author to notice. It survived the #4286 sweep of this very schema because that sweep asked which members no executor reads, and this one had a reader — the wrong question for a key whose defect is WHICH executor reads it. Remove rather than enforce, per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL lowering #6409 landed) already covers the only deduplicating spelling with measured demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling mistake and would have to be lowered across five faces, two of them frozen under #5499, to buy it. The blast radius inside the fallback is narrower than the key suggests and was measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is `retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod silently STRIP what callers still send, trading a divergent flag for an ignored one (#3733, ADR-0104). One tombstone covers every aggregation door, because `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one schema by reference. No conversion: a request surface with no stored source — one semantic TODO below, the disposition every other `data.query.*` retirement in this major already takes. +The same aggregation node loses one more member, and it is the sharper class of the two: `aggregations[].distinct` is removed (#6815, ADR-0049, maintainer ruling 2026-08-09). The functions above were declared and UNLOWERED — a caller on a SQL datasource got a refusal. This flag was declared and lowered by exactly ONE of the six faces that read an aggregation: the engine's in-memory fallback deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored it. So the same query answered a deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, with the engine choosing between the two per query — by driver, by a non-UTC date bucket, by whether the driver aggregates natively at all. That is the divergence class #6203 and #5907 each closed on this axis, still open on this key, and it is worse to sit on because the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing for a dashboard author to notice. It survived the #4286 sweep of this very schema because that sweep asked which members no executor reads, and this one had a reader — the wrong question for a key whose defect is WHICH executor reads it. Remove rather than enforce, per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL lowering #6409 landed) already covers the only deduplicating spelling with measured demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling mistake and would have to be lowered across five faces, two of them then frozen under #5499 (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the fallback is narrower than the key suggests and was measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is `retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod silently STRIP what callers still send, trading a divergent flag for an ignored one (#3733, ADR-0104). One tombstone covers every aggregation door, because `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one schema by reference. No conversion: a request surface with no stored source — one semantic TODO below, the disposition every other `data.query.*` retirement in this major already takes. One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` resolves to `'service'` instead of `'any'`, so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (#5561, ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before a user meets a run that will not continue. @@ -320,7 +320,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048). - Done when: No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; every such read is `.positions` and observes the SAME array — the value was `ExecutionContext.positions` on both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed one silently sees `undefined` — move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence (`'roles' in ctx.user === false`) rather than by `undefined`, which cannot tell a removed key from one left behind holding nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way. - **`aggregation-node-distinct-retired`** — `data.query.aggregations[].distinct` → the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data - - Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815. + - Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under #5499, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815. - Done when: No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through the SDK; a deduplicated count is written as `{ function: 'count_distinct', field }` and reads the same number on every backend. A query still carrying the key fails to parse with the removal prescription — including through `EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry at `aggregations..distinct` instead of serving a number. Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on exactly one path and that is the point of the change: a `sum`/`avg` that used to be deduplicated by the in-memory fallback now answers what every SQL face has always answered for the same query. Verify against the SQL answer, not against the pre-upgrade fallback answer — the two disagreed, which is why the key is gone. - **`analytics-query-request-envelope-retired`** — `api.analyticsQueryRequest.query` → bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...) - Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. @@ -404,7 +404,7 @@ What makes this one cheaper to meet than its two siblings, and worth saying beca - Why not automatic: The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634. - Done when: No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 retired bits — a driver class that still writes one fails tsc against `IDataDriver.supports` (the bit is `never`), and a parsed config fails with the per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare only live bits; cloud's TursoDriver keeps compiling via its `...super.supports` spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine behaviour is byte-identical: every removed bit had zero readers, and the three live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, plugin.ts + engine.ts batched schema sync, verify date-bucket parity). - **`driver-sql-distinct-bare-filter-typed`** — `SqlDriver.distinct() third argument — any value` → a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope - - Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320. + - Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the #5499 freeze, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (#6320). ADR-0087, #6320. - Done when: No caller passes a non-object to `distinct()`'s third argument. A scalar there is now a compile error (`TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'`); rewrite it as the bare filter it was always meant to be — `'completed'` becomes `{ status: 'completed' }`. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400. - **`engine-dotted-projection-refused`** — `engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress` → read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924 - Why not automatic: #7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `"account"."name"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation. @@ -503,7 +503,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds `permissions` on a sys_position record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for permissions next to sys_position — delete the key from any seed row; prose that was documenting intent belongs in `description`. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant to grant capability, author it where it is enforced: bind permission sets to the position (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) — the authz resolver then expands the bindings from the position name at request time. - **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared - - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188. + - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188. - Done when: No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style roll-ups are assembled by the caller from an ordinary `fields` query, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is a `tsc` error at the call site; `count_distinct` continues to parse and is unaffected. - **`query-cursor-retired`** — `data.query.cursor` → a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern) - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index f28c63447c..693e3e8427 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -521,7 +521,7 @@ "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data", "migrationId": "aggregation-node-distinct-retired", "toMajor": 17, - "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815." + "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under #5499, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815." }, { "surface": "api.analyticsQueryRequest.query", @@ -703,7 +703,7 @@ "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", "migrationId": "driver-sql-distinct-bare-filter-typed", "toMajor": 17, - "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." + "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the #5499 freeze, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (#6320). ADR-0087, #6320." }, { "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", @@ -878,7 +878,7 @@ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", "migrationId": "query-array-string-agg-retired", "toMajor": 17, - "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." }, { "surface": "data.query.cursor", @@ -1606,7 +1606,7 @@ "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data", "migrationId": "aggregation-node-distinct-retired", "toMajor": 17, - "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815." + "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under #5499, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815." }, { "surface": "api.analyticsQueryRequest.query", @@ -1788,7 +1788,7 @@ "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", "migrationId": "driver-sql-distinct-bare-filter-typed", "toMajor": 17, - "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." + "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the #5499 freeze, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (#6320). ADR-0087, #6320." }, { "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", @@ -1963,7 +1963,7 @@ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", "migrationId": "query-array-string-agg-retired", "toMajor": 17, - "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." }, { "surface": "data.query.cursor", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a6e0d941cd..267cae2ca1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1695,7 +1695,8 @@ const step17: MigrationStep = { + 'fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move ' + '`min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): ' + '`count_distinct` already covers the only spelling anyone has measured demand for, ' - + 'and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — ' + + 'and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under ' + + '#5499, a freeze lifted 2026-08-11, after this ruling — ' + 'buys a shape that is near-universally a modelling mistake. A REQUEST surface — ' + '`QueryAST` is the client SDK builder\'s output and the `POST /data/:object/query` ' + 'body, never stored in stack metadata — so there is no source for the chain to ' @@ -2796,8 +2797,9 @@ const step17: MigrationStep = { + 'valid filter — one constraining columns named `object` and `where` — and so is a ' + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' - + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 ' - + 'freeze (#6320). ADR-0087, #6320.', + + 'BARE spelling returns the unfiltered set in silence — stayed open under the #5499 ' + + 'freeze, which was lifted on 2026-08-11; it is still open, now unexcused rather than ' + + 'deferred (#6320). ADR-0087, #6320.', acceptanceCriteria: 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' @@ -4048,7 +4050,8 @@ const step17: MigrationStep = { + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was ' + + 'ruling ended, and #5499 had both of those backends frozen at the time (that freeze ' + + 'was lifted on 2026-08-11). `count_distinct` was ' + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.', acceptanceCriteria: From 370cd4073105315de161ae699ba13b4f12a99a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 03:22:58 +0000 Subject: [PATCH 3/4] chore: changeset for the published #5499 freeze-prose correction --- .changeset/freeze-prose-past-tense-5499.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/freeze-prose-past-tense-5499.md diff --git a/.changeset/freeze-prose-past-tense-5499.md b/.changeset/freeze-prose-past-tense-5499.md new file mode 100644 index 0000000000..7e4bfe043f --- /dev/null +++ b/.changeset/freeze-prose-past-tense-5499.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": patch +--- + +Restate the dissolved #5499 investment freeze in the past tense at the migration-registry sites that still asserted it as live. + +The maintainer lifted the #5499 investment freeze for `driver-mongodb` and `driver-memory` on 2026-08-11 (recorded in the head note of `packages/spec/src/data/aggregation-conformance.ts`), but five sentences in the ADR-0087 migration registry still asserted it in the present tense — three of them citing the freeze as the standing reason a known divergence is not being fixed. Those sentences ship: they are carried by the published `spec-changes.json` and by the generated `docs/protocol-upgrade-guide.md`, so a reader of either was told a premise that expired 18 days earlier. + +Each site is re-dated so it records what was true when the ruling was taken, keeping the #5499 anchor rather than deleting it. No ruling is re-argued and no divergence is dispositioned — the rationales all survive losing the freeze premise, and the debts the thaw makes due are left explicitly open for triage. From bdc42f7ef5b594c7fca93e2d8288524328736456 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 03:25:49 +0000 Subject: [PATCH 4/4] Carve packages/objectql/src/having-filter.ts out of this PR That site is claimed as a deliverable by open card #13166, which records that rewriting it to past tense without first deciding the underlying divergence would turn an actionable defect into settled-looking prose. #13166 also measures the sentence's driver-mongodb half as never having been true for this operator family, so a tense-only correction would ship a claim already measured false. Left untouched for #13166 to land with its decision. --- packages/objectql/src/having-filter.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index 92e02ff899..afd1278a07 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -37,12 +37,9 @@ // the same vocabulary and was not in that PR's inventory, which left this // file as the lone holdout (#5905) — and the only face no conformance table // covers, since `FILTER_LOGIC_CASES` does not drive the HAVING path. -// driver-memory / driver-mongodb still answer the old way. That was because -// #5499 froze them — the divergence was against a frozen face, not against -// the ruling — but the freeze dissolved 2026-08-11 (head note of -// `@objectstack/spec`'s `aggregation-conformance.ts`), so the divergence is -// now unexcused rather than deferred. Nothing has been triaged against it -// yet, and disposing of it is not this file's call. +// driver-memory / driver-mongodb still answer the old way only because +// #5499 freezes them; the divergence is against a frozen face, not against +// the ruling. // // [#7158] A THIRD divergence has been REMOVED rather than added: this face had // no comparand-shape gate, which is what the five sibling faces refuse an