Repository navigation
fix(objectql,spec)!: a groupBy on a multi-value field and a count_distinct on a JSON-stored field are refused INVALID_FIELD / 400 at the engine aggregate door, on every driver (#20808) - #20911
Conversation
…istinct on a JSON-stored field at the engine aggregate door (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ed count_distinct refusals (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
… false, and the lint fixture that pinned the old row (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…8 entry prose (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…nt_distinct narrowings (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…refusal, and the census pin the narrowed table moves (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…on-group-distinct-refusal
… unchanged the two shapes this release also refuses (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…; this entry names the two shapes it narrows (#20808) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1f7c22ded851d1e5c134b24515b5f1e7c320c2c9 && git checkout 1f7c22ded851d1e5c134b24515b5f1e7c320c2c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf684c98eb8b10398729befff5220ae5c84014b9 f53718f1ee9182aa33ff33e83218ef15dd164ab7 && git checkout -B drift-repro cf684c98eb8b10398729befff5220ae5c84014b9 && git merge --no-ff f53718f1ee9182aa33ff33e83218ef15dd164ab7
node scripts/docs-audit/affected-docs.mjs --json cf684c98eb8b10398729befff5220ae5c84014b9
|
Contract reviewServed-tier: PR #20911 for card #20808, reviewed at the head above (it had not moved when read), as the net diff against ① Derived judgmentsEvery accept-set or public-surface change the diff implies, each named right or wrong:
Nothing widens: the The refusal texts an author reads: the multi-value ② Semver level
The declaration, ADR-0087: exactly one marker in the changeset, ③ Boundary flagsDev flags (os-dev-report 5916514958), each answered:
Seat answers (5916552699), each judged against the diff:
Review faces, each read sentence by sentence: the changeset (true; its "Unchanged" list holds because the doors read only Check-runs on the head, read at 2026-09-30T17:56Z and not polled: 32 runs, zero failures at read time.
Implemented-by: VERDICT: PASS Generated by Claude Code |
… contract, and an identity-only column is judged as the type it renders (objectstack-ai#20901) (objectstack-ai#20927) Closes objectstack-ai#20901 Clause-②: yes (narrowing) - `FormViewSchema.subforms[].columns` references `InlineGridColumnSchema` (was `z.array(z.any())`): the card's typed `currency` + `scale` column and its `zzz_not_a_key` column are refused at the view parse. - `defineStack`'s cross-reference check (`packages/spec/src/stack.zod.ts#collectHydratedInlineColumnErrors`, called from `validateCrossReferences`) re-parses a column that declares no `type` as the type it renders (`currency` over a `currency` child field) through `InlineGridColumnSchema`, on both carriers. The card's identity-only column with `scale` is refused there with the column schema's own message; there is no second `scale` rule. - ADR-0087: D3 entries `form-view-subform-columns-closed` and `inline-grid-column-identity-only-currency-scale-refused`; the registry was regenerated after merging a `main` that carries objectstack-ai#20903 and objectstack-ai#20911. Evidence at `feba1a99bd`: pins `packages/spec/src/inline-grid-column-carriers.test.ts` 13/13; `@objectstack/spec` suite 584 files / 17179 tests green; `@objectstack/spec` typecheck green; 114/114 derived gates exit 0. Ablations, each restored with `git diff HEAD` empty: reference removed, 5 red; cross-reference call removed, 3 red. `os validate` on a probe stack: identity-only column, exit 1 `STACK_CROSS_REFERENCE_INVALID`; typed and bogus columns, exit 1 `STACK_SCHEMA_INVALID`; valid columns, exit 0. ## Acceptance notes - No check read `subforms[].childObject` before this change (`validateCrossReferences` read only `form.data.object`), so the child-object lookup is new here. - The identity-only check runs in `defineStack` only. A view saved through the metadata door, or a subform whose child object lives in another package, gets the schema half alone (NOT MEASURED at the save door). objectui's `@object-ui/types` mirror is still `z.any()`, and the render-time warning stays the backstop there. - `field.zod.ts`'s `scale` describe still names only the declared-type refusal. PR objectstack-ai#20908 holds that file. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…lowering, lower type-blind without a field map, then delete driver-memory F3 (objectstack-ai#5930 step 4, group 1b) (objectstack-ai#20925) Part of objectstack-ai#20822 Clause-②: no objectstack-ai#5930 step 4, **group 1b: F3** (`driver-memory`'s query path). This follows the seat's answer B (5915193659 on objectstack-ai#20822), read from ADR-0053 D-D1 items 5, 7 and 10 as amended: first route the direct caller, then make the engine seam type-blind when it has no field map, then delete. It is one PR in three ordered commits on `main` at `4d0b9cd542`: | # | Commit | What it does | |:--|:--|:--| | 1 | `0bd0e6d4f7` fix(metadata) | `DatabaseLoader.queryHistory` in driver mode runs `lowerFilterCondition` on its own `where`. The reader is typed by the history object the loader syncs (`recorded_at` is `Field.datetime`). The loader becomes a seam (item 5). | | 2 | `5317b5aa22` fix(objectql) | `declaredDatetimeLowering`'s absent-map branch drops the reader, so an object with no field map is lowered type-blind (item 7). An object with a field map keeps the typed scope byte-identical. | | 3 | `e15606bae2` refactor(driver-memory) | F3's four whole-day sites are deleted. The 43 direct-call tests are routed through `lowerFilterCondition` with the declared-datetime reader. New pins cover item 5 (one cell per deleted site) and item 7's convergence. | | 4 | `86ccdc099e` docs(changeset) | The `driver-memory` bullet names the RLS no-guard path (review 5919688563, FAIL 1), and no longer says that every seam hands the driver a lowered filter. Changeset text only. | 13 files against `4d0b9cd542` (+662 / -102 at `e15606bae2`; commit 4 changes one changeset line). The changeset is `.changeset/20822-f3-route-then-delete.md`: `patch` for `@objectstack/metadata`, `@objectstack/objectql` and `@objectstack/driver-memory`, with the (b) convergence stated. ## The answers that move, named These were measured through the real engine (`ObjectQL` dist, `engine.find`) on `SqlDriver` (`driver-sqlite-wasm`) and `InMemoryDriver`. The table was synced through `driver.syncSchema`. The object was either registered in the engine with its field map ("registered") or not registered ("unregistered"). - Rows: `r1` = `2026-07-28T00:00:00.000Z`, `r2` = `…T12:00:00.000Z`, `r3` = `…T23:59:59.999Z`, `r4` = `2026-07-29T00:00:00.000Z`. The same instant is written into `at` (`datetime`), `txt` (`text`) and `extra` (not declared; memory only). `d` (`date`) holds the calendar day. - Filter: `{ col: { $lte: '2026-07-28' } }`. - Columns: BASE = `main` with commit 1 only; c2 = after commit 2; c3 = after commit 3. | Object · column | Driver | BASE | c2 | c3 | |:--|:--|:--|:--|:--| | registered · `at` (datetime) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | registered · `d` (date) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | registered · `txt` (text, ISO) | sqlite | none | none | none | | registered · `txt` (text, ISO) | memory | r1,r2,r3 | r1,r2,r3 | **none** | | registered · `extra` (undeclared) | memory | r1,r2,r3 | r1,r2,r3 | **none** | | unregistered · `at` / `d` | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | unregistered · `txt` | sqlite | none | **r1,r2,r3** | r1,r2,r3 | | unregistered · `txt` / `extra` | memory | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | any · `at` `$lte '9999-12-31'` | both | r1..r4 | r1..r4 | r1..r4 | | any · `at` `$between` the day | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | - **Commit 2 moves one answer, a widening, on `SqlDriver`.** Take an unregistered object's non-datetime column that holds ISO instant text. A bare-day `$lte` on it now keeps the whole day (none becomes r1,r2,r3). That is item 7's reading for a seam that cannot read the declared type: "applies the rewrite type-blind". The dispatch expected that `SqlDriver`'s answer for an unregistered object would not move yet, because its F1 copy still exists (H2). That holds for `datetime` and `date` columns only. F1 covers the columns the driver itself knows as `datetime`, and nothing else. - **Commit 3 moves the (b) cells, both narrowings on `driver-memory`, onto `SqlDriver`'s answer.** On a registered object: - a declared `text` column holding ISO text; - a column the object does not declare. The typed seam leaves both byte-identical, and the deleted copy used to widen them. The seat's answer calls this item 7's scope ("it is not a decision"). It is declared in the changeset. An unregistered object does **not** narrow on memory, because commit 2 now lowers it at the seam. - Neither move is a narrowing beyond what item 7 names, so nothing stopped. ## Commit 1: `queryHistory` becomes a seam (H1: held) These were measured with a scratch probe over the built `dist` of `@objectstack/metadata`, `driver-memory` and `driver-sqlite-wasm`. The mode is driver mode (`new DatabaseLoader({ driver })`), with two saves on one day and `until` / `since` = that day: | State | memory `until` | memory `since = until` | sqlite `until` | sqlite `since = until` | |:--|:--|:--|:--|:--| | `main` (F3 present) | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 | | all three commits | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 | | F3 deleted, loader lowering removed (dist ablation) | **0 / 0** | **0 / 0** | 2 / 2 (F1 still present) | 2 / 2 | - **Other direct driver callers in `packages/metadata`.** The other one is `utils/history-cleanup.ts` (`recorded_at: { $lt: cutoffISO }`, twice). That is an instant `$lt`, which no rule widens, so it is unaffected. The other `_find` / `_count` filters in the loader are equality only. No other temporal bound was found. - **H4.** Group 2 (`driver-sql` F1) meets the same `queryHistory` caller. Commit 1 lowers it for every driver, so **group 2 has no caller left to route** in `packages/metadata`. §A below is the answer group 2 must keep once F1 is gone. - **Pin:** `database-loader-20822-history-whole-day.test.ts`. It fakes `Date` only. - §A: the rows on real SQLite in driver mode. - §B: the `where` the driver's `find` / `count` receive (`recorded_at: { $lt: next day }`, lower bound kept, instant `until` and other columns byte-identical). §B is driver-agnostic. It is the half that goes red when the loader stops lowering. - **Not pinned on memory inside `@objectstack/metadata`.** A new test consumer of `@objectstack/driver-memory` needs a maintainer ruling (`scripts/driver-memory-census.ledger.json`, `RULED_CEILING = 2`). So the memory half is covered in two other ways: - §B (what every driver receives), plus `driver-memory`'s own pin of how it answers the lowered and the unlowered filter; - the dist measurement in the table above. - Engine mode is untouched: the engine's `where` seam lowers it, typed by the registered history object. ## Commit 2: an object with no field map is lowered type-blind (H2: held for datetime and date, falsified for text) The only change is `if (fields === null || typeof fields !== 'object') return {};`. The typed branch is unchanged. The control in the new pin (`engine-20822-no-field-map-type-blind-lowering.test.ts`) and the existing `engine-shared-filter-lowering-seam.test.ts` stay green. The new pin covers `find`, `findOne`, `count`, `aggregate`'s `where` and the judge on an unregistered object. `having` has its own aggregated-row reader (F8, group 3), which is untouched. ## Commit 3: F3 deleted (H3: held) - **Deleted:** - the `$lte` and `$between` arms of the FilterCondition translator; - the less-or-equal and `between` arms of the AST-node translator (`{ type: 'comparison' }`, which no seam emits; only direct callers reach it). `nextUtcCalendarDay` / `isUnboundedAbove` are no longer imported by `memory-driver.ts`. The clobber-class table in `assembleLoweredWrites`' docblock loses the `$lt` / `$ne` writers the rewrite added. No driver-local guard is kept. - **F3's typed reader** is the engine's `declaredDatetimeLowering`. It is typed when the object has a field map, and type-blind without one (commit 2). The driver's own `syncSchema` temporal index is not consulted by any seam. - **The 43 direct-call tests** are the same 43 that went red with the deletion alone: | Suite | Tests | |:--|:--| | temporal-conformance | 25 | | calendar-day-upper-bound | 5 | | analytics-20661 | 5 | | datetime-storage | 4 | | temporal-storage-form | 2 | | shared-lowering-door | 2 | Each now hands `find()` what a typed seam hands it: `lowerFilterCondition` with a reader over the fixture's own declared field map. In the 20661 file, the `undeclared` reading is lowered type-blind, which is commit 2's reading. **0 `expect(` lines changed** in the six routed files. - **New pin:** `memory-driver-20822-comparison-as-written.test.ts`. - §A (item 5): a direct call gets the comparison it wrote. There is one cell per deleted site. On a `datetime` column a bare day takes its storage form, the midnight instant, so `$lte` keeps the midnight row. - §B (item 7): the registered-object convergence cells. - §C: the type-blind reading. ## Ablations: each one committed first, restored and proven by blob hash, re-run at the final head `e15606bae2` Every mutation went through `scripts/ablation-replace.mjs` (anchor must hit, blob verified, restored blob equal to HEAD, `git diff HEAD` empty). | Commit | Mutation | Red | Green | |:--|:--|:--|:--| | 1 | loader lowering removed | 2 of 8 (§B's two bare-day cells) | §A stays green through `SqlDriver`'s F1 copy | | 2 | absent-map branch removed | 4 of 19 (the four unregistered lowering cells) | the control, the instant and the judge cells | | 3 | `$lte` arm restored (with its import) | 4 of 1424 | the other 1420 | | 3 | `$between` arm restored | 2 of 1424 | the other 1422 | | 3 | AST less-or-equal arm restored | 1 of 1424 | the other 1423 | | 3 | AST `between` arm restored | 1 of 1424 | the other 1423 | In every commit-3 row, the red cells are the matching cells of the new pin and nothing else. Each restored copy is idempotent on lowered input (item 9). The H1 dist counterfactual (the memory 2 / 2 to 0 / 0 row above) ran through `ablation-dist-preflight.mjs` for the restore leg: marker absent from all 30 built files and the tree clean. The mutate leg's arrival in `dist` is shown by the probe's answer moving. ## Tests, gates and lint, all at `e15606bae2` - `@objectstack/driver-memory` vitest: 66 files / 1424 passed. - `@objectstack/metadata` vitest: 56 files / 836 passed. - `@objectstack/objectql` vitest `--project local`: 348 files / 6807 passed. `--project repo`: 1 / 5 passed. - The three packages' `typecheck`: exit 0. That covers objectql's `check:test-typecheck` (OK, 234 errors / 65 signatures held in the ledger). driver-memory's `tsconfig.json` program lists all 66 test files. - `node scripts/pm/dispatch-gates.mjs --commands` (merge base `4d0b9cd54`) derived 66 families. **66 run, all exit 0.** The `--ran` verdict: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero …)". This includes: - `check:driver-conformance`: "OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt". - `check:driver-memory-census`: "OK — every declaration is ledgered …". - `check:dual-build-cjs-loads` and `check:type-check-debt`, after a whole-workspace build. - `check:query-options-erasure`: back at 236 test sites. My first draft added one `{ where } as any`, and it is now typed. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 12 changed `.ts` files gave 12 files, 0 errors and 0 warnings. Each file resolves under `--print-config`. `eslint.config.mjs` enables no type-aware linting ("no `parserOptions.project`, no typed `@typescript-eslint` rules"), so no untouched file's verdict can move. The full `pnpm lint` is CI's. ## Acceptance notes - **The RLS compile seam's no-guard reading.** This is noted, not filed. `carrier:` objectstack-ai#20822 group 2 (`driver-sql` F1), which removes the next copy standing behind this reading. - `plugin-security` `rls-compiler.ts` `rlsLowering` reads an absent guard as "no datetime column". The guard is absent when `getObjectFieldNames` cannot resolve the object. This is the same population, and the same reading, that commit 2 changed on the engine. - Its existing pin says so: "a guard with no types reads no column as datetime" gives `{ signed_on: { $lte: '2026-01-05' } }`. - After this PR, an RLS `using` policy with a bare-day upper bound, on an object whose declared fields the security plugin cannot resolve, reaches `driver-memory` as written, where the deleted copy used to widen it. (A `check` clause reaches `matchesFilterCondition`, not this driver, so it does not move here.) The changeset's `driver-memory` bullet names this path (`86ccdc099e`, after contract review 5919688563), and the seat's answer 5918373748 (A) carries the `rlsLowering` twin into group 2. - Item 5 covers a filter composed after the engine's seam. Item 7's general rule would read that seam type-blind. - Measured only at unit level (that pin and §A here), not through a public door. It is outside this card's file surface. - The metadata-side memory pin is replaced by §B plus the `driver-memory` pins because of the census ledger (above). - The branch was re-stacked twice before this PR opened, with `--force-with-lease` and all five conditions met: first to fold two WIP commits into commit 3, then onto `main` `4d0b9cd542` after objectstack-ai#20911 landed in `engine.ts`. No merge commit remains. - Not done here: objectstack-ai#20822 group 2 (F1, F2), group 3 (F6, F7, F8), and the stale matcher pointers the last group PR corrects. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20808
Clause-②: no (narrowing)
What this changes
Two more JSON-stored keys are refused
INVALID_FIELD/ 400 byengine.aggregate, in the engine's words, before any driver is asked. The aggregate × field-type table stops acceptingcount_distinctover the JSON-stored types, so every reader of that row refuses the pair too. Nothing widens.The narrowed accept sets, per shape and per door:
groupByentry naming a multi-value field (multiselect,checkboxes,tags;select,lookup,user,file,imagedeclaredmultiple: true), as a name or as{ field }aggregate(@objectstack/objectql): REST query door, flows, hooks, the analytics ObjectQL strategy400 INVALID_FIELDatgroupBy[i]/groupBy[i].fieldcount_distinctover a structured-JSON field (json,composite,repeater,record,location,address,vector)aggregate400 INVALID_FIELDataggregations[i].fieldcount_distinctover a multi-value field (the same eight declarations)aggregate400 INVALID_FIELDataggregations[i].fieldcount_distinct× the ten JSON-stored types (structured-JSON seven plusmultiselect,checkboxes,tags)AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct(@objectstack/spec)isAggregateCompatibleWithFieldType('count_distinct', t)isfalsecount_distinct× a JSON-stored typemeasure-aggregate-field-type-refused(os validate, and the runtime gate on a dataset save)400 DATASET_INVALIDThe words, as
POST /api/v1/data/:object/queryreturns them (the route sits inside the 500 characters the REST door keeps; the REST pins assert it there):The thrown error carries
code: 'INVALID_FIELD',statusandhttpStatus400,field,fields(every offender),objectandparam(groupByoraggregations).Landing site.
packages/objectql/src/group-by-structured-json-door.ts: the structured-JSON door gains its second class, the multi-value field, judged by@objectstack/spec/data'sisMultiValueFieldin the SAME walk over the entries, so the first offending position is named whichever class it is. The export is renamedassertGroupByNamesNoJsonStoredField. The structured-JSON words keep their verdict and route; their reason clause now also holds forvector(the review note carried on the previous door's landing).packages/objectql/src/count-distinct-json-stored-door.ts(new):assertCountDistinctNamesNoJsonStoredField. The TYPE half asks the spec table (isAggregateCompatibleWithFieldType('count_distinct', type)), never a second list. The DECLARATION half asksisMultiValueField, because a per-type table cannot seemultiple: true. Not judged: any other function, acount_distinctnaming no field, an undeclared name, a host with no field map, and a type outsideFieldType(the table is fail-closed on vocabulary, so an introspectedintegerorobjectcolumn is left alone).packages/objectql/src/engine.ts: one call each, at the entry ofaggregate, right after the credential refusal and in the order groupBy, then count_distinct.packages/spec/src/data/aggregate-field-type-compatibility.ts: thecount_distinctrow is everyFieldTypeexceptJSON_STORED_AGGREGATE_FIELD_TYPES(spelled out, held equal toSTRUCTURED_JSON_TYPES∪MULTI_OPTION_TYPESby the pin, as the file does for its other classes). The TSDoc states the ground, the measurement, and the third reader.INVALID_FIELD, an existing code: the verdict is about the named field's type at a position, the question the structured-JSON door beside it answers the same way.Before, measured on
origin/main42d78b97feThrough
POST /api/v1/data/:object/query(the realRestServerroute overObjectStackProtocolImplementationandObjectQL). Drivers: InMemoryDriver, SqlDriver on SQLite (better-sqlite3), and SqlDriver on a private PostgreSQL 16.13 started for this run. Three rows; two of them hold EQUAL values under every JSON-stored field.groupBytitle(text) /status(single-value select), the controlsx2 ·y1 /a2 ·b1groupByeach of the 8 multi-value declarations, and{ field: 'tags' }DATABASE_ERROR(could not identify an equality operator for type json)count_distincttitle/status, the controlscount_distinctjson(three different documents)count_distincteach other structured-JSON typecount_distincteach multi-value declarationcountoverjson/tags(unchanged)After, the same run on this branch
Every multi-value
groupByand every JSON-storedcount_distinctabove answers400 INVALID_FIELDin the engine's words on all three drivers, naming the position, the field and its declaration. The controls andcountanswer exactly as before. The InMemoryDriver cells of both runs come from an uncommitted scratch script over the built packages;check:driver-memory-censusrefuses a new test consumer of that driver without a ruling, so the committed memory cell is the recording driver below, by construction.Hypotheses (zone 2): which held
group-by-structured-json-door.tsat the aggregate entry, and the multi-value refusal is one more class there. It is judged onisMultiValueField, which reads the declaration AND the type:select,radio,lookup,user,file,imagecarrymultiple(MULTI_CAPABLE_TYPES;radioplusmultipleis already refused at parse), whilemultiselect,checkboxesandtagsare multi-valued by type with nomultipleat all (MULTI_OPTION_TYPES). Those three split exactly the same way (measured), so they are in the class; judgingmultiple: truealone would have left them grouping per serialization. A multi-valuedlookup(anduser) is in scope: it is amultiple: truefield, and it measured the same (PostgreSQL 500).validateDatasetMeasureAggregates(gating, run byos validate/os lintand, since the dataset runtime gate, on a runtime dataset save), which now refuses at save time a dataset measure pairingcount_distinctwith a JSON-stored field; ② the analytics dataset compile leg (assertAggregateFieldTypeCompatible),400 DATASET_INVALID; ③measureResultType(result typing only: a refused pair gets no corrected type); ④ the new engine door; ⑤ the prose of two step-18 migration entries. The narrowing on authored metadata is dataset measures only; the census below finds zero authoredcount_distinctanywhere. Ablation A3 below proves the door reads the table: putting the old row back reds the engine pins.AnalyticsServicewired asAnalyticsServicePlugin's own bridges (executeAggregatetoengine.aggregate,executeRawSqltoengine.execute), after mergingorigin/main00a92e18da(which carries the analytics structured-JSON dimension door):meta_count_distinctanswer this 400;NativeSQLStrategyon SQLite and PostgreSQL does NOT: a cube or dataset dimension on a multi-value field answers one group per serialized array on SQLite and 500 on PostgreSQL; an inferred cube measureFIELD_count_distinctover a JSON-stored field answers 2 on SQLite and 500 on PostgreSQL; a dataset measurecount_distinctover aselectdeclaredmultiple: true(the table sees onlyselect) answers 200 on SQLite (distinct serialized arrays) and 500 on PostgreSQL;count_distinctover a JSON-stored TYPE is refused by the compile leg on every driver (DATASET_INVALID).The native-SQL reach is reported to the seat, not fixed here. The memory cube (
MemoryAnalyticsService) has zero constructors outside tests (git grep "new MemoryAnalyticsService"excluding tests: 0; including them: 5 files), so it was not measured.Census (before narrowing)
A
groupBy/ grouping / dimension on a multi-value field, and acount_distincton a JSON-stored field, in datasets, reports, views, dashboards, pages and code.examples/**at42d78b97fe(unchanged at this head): the fourobjectstack.config.tsapps andembed-objectql. Stacks loaded throughtsx(showcase through its metadata modules, its plugin imports unbuilt) and walked for every grouping-shaped position.todo_task.tags), showcase 8 (showcase_field_zoo.f_multiselect,f_checkboxes,f_tags,f_lookups,f_users;showcase_project.labels,team_members;showcase_task.labels); crm, multi-package and embed-objectql 0. Structured-JSON fields: showcase 10.groupByField, chartdimensions, dataset dimensions, pagedimensions, reportxAxis). Reportrows/columns:status,priority(showcase),status,priority,owner,category(todo). Hits naming a multi-value or structured-JSON field: 0.count_distinct: 0 occurrences inexamples/.objectstack-ai/hotcrmcloned at4ca8e2d4bb(the repository'smain; the npm tarball itself was not read). Its dependency tree was not installed, so the census is by source text oversrc/**,test/**andscripts/**.crm_knowledge_article.tags(select,multiple: true), andattendee_contacts/attendee_users(lookup,multiple: true) in an activity action. Structured-JSON fields:billing_address(contract, quote, account),shipping_address(quote),address(lead),office_location(account).groupByFieldstatus,owner_id,stage,crm_account,channel;xAxisstage,status,priority,owner; reportrows/columnsover dataset dimensionsindustry,type,lead_source,last_contacted_date,stage,owner,forecast_category,close_quarter; dataset dimensionfield:values none of the fields above. Hits: 0. Its_picklists.tsalready records why a grouped field stays single-valued ("amultiple: truecolumn groups by the COMBINATION").count_distinct: 1 occurrence, a comment inscripts/analytics-reconcile/reconcile.ts. Hits: 0.packages/platform-objects: 0count_distinct.No hit, so neither narrowing went back to triage.
Scope beyond the declared surface, and why
The claim names
packages/objectql/src/**, the one spec file, andrestpins. Four more places move, each because the ruled table change makes a pin red or a shipped sentence false. They sit in separate commits so each can be judged or dropped on its own:packages/lint: the table-agreement pinaccepts count and count_distinct over every declared FieldTypegoes red on the ruled row, so it is triaged (count over every type; count_distinct over every type but the ten, which it refuses). The refusal hint said "count/count_distinctaccept every type"; it now sayscount_distinctaccepts every type but the JSON-stored ones.packages/services/service-analytics: the census pinthe refused set … 155 pairscounts the table's refused pairs, so it moves to 165 with adistinctbucket of 10. The compile leg's words for acount_distinctrefusal fell into the "derives a NUMBER … coerces the stored form" branch and then said "count/count_distinctaccept every type";DIVERGENCE_BY_AGGREGATEgains acount_distinctbranch (equality, not arithmetic or order),REMEDY_BY_SOURCE_CLASSgains the distinct prescription, and the two other sentences stop claimingcount_distinctaccepts every type. A new case pins those words. The dispatch said not to editpackages/services/**; the two claims on that package (the analytics dimension door and the analytics seam lowering) had both landed (00a92e18da,793fb839) before this edit, and no file here overlaps theirs.packages/spec/src/migrations/entries/semantic/18.dataset-measure-*.ts(+ the regeneratedregistry.ts): the min / max entry's route "① …count/count_distinct, which accept every type" is false for the ten types now; the sum / avg entry's surface and acceptance prose name thecount_distinctrider this change's ADR-0087 marker points at.content/docs/deployment/validating-metadata.mdx: the sentence "count/count_distinctare accepted over every type".Tests
packages/objectql/src/engine-json-stored-group-distinct-door.test.ts(7 tests, recording driver, so the in-memory cell by construction): every multi-value declaration refused as agroupBywith the full envelope, the declaration and the$containsroute; the{ field }form and the first offending position across both classes; every structured-JSON type and multi-value declaration refused as acount_distinct, and the first of two offenders named; the REST door intofindData; controls (scalar group keys and distinct counts,countover JSON-stored columns, the$containsroute itself) reach the driver; GUARDs: thecount_distinctdoor agrees with the spec table on everyFieldType(floor: exactly 10 refused) and refuses every flagged multi-capable type; no verdict without a field map, for an undeclared name, an off-vocabulary type or any other function.packages/rest/src/data-json-stored-group-distinct-door.test.ts: SQLite always, PostgreSQL / MySQL whereOS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URLare set. Both shapes answer 400 with the route in the REST body and zero reads; the controls (statusgroups,titleandstatusdistinct counts) and the named route (countwithwhere { tags: { $contains } }answersa3 ·b2) are served by the driver.engine-group-by-json-door.test.ts(the structured-JSON door's pin) named amultiple: trueselect as a CONTROL reaching the driver. That branch is closed now, so the control is replaced by a single-value select. Its GUARD over everyFieldTypenow expects the multi-option types refused too.fb239eb2f3(the merge oforigin/main00a92e18da); the later commits touch only the changeset:pnpm --filter @objectstack/objectql test: 347 files / 6799 passed.pnpm --filter @objectstack/rest testwith the live PostgreSQL URL: 239 files / 4731 passed / 39 skipped.pnpm --filter @objectstack/spec test: 582 files / 17164 passed / 1 todo.pnpm --filter @objectstack/lint test: 117 files / 5438 passed.pnpm --filter @objectstack/service-analytics test: 145 files / 3326 passed.typecheckfor objectql, spec, lint, rest and service-analytics: exit 0. Eachcheck:test-typecheckheld its ledger (objectql 40 files / 234 errors / 65 signatures, spec 52 / 249 / 137, lint 2 / 6 / 2, rest 0), so the new test files compile.Reverse verification (three ablations), each from committed code through
scripts/ablation-replace.mjsWRAP (trap-restored), the package rebuilt, andablation-dist-preflightfinding the marker in the built files before any reading:groupByisMultiValueField(...)class test fed a global flag that is never set (anchor 1 to 0, marker 0 to 1, blob322c8098d2d1to39a2feb82b08); objectql dist marker in 4 filesFieldType); rest pins 2 failed / 12 passed / 7 skipped (sqlite + postgres multi-value groupBy); every count_distinct case and control greencount_distinctdoorquery.aggregations(blobcd1e9e39967fto3123c092583e); marker in 4 filescount_distinct:fed the old every-type row (blob94cf5a2e3f56toa343f47f2212); spec dist marker in 4 filesRestore leg for each: blob equals HEAD,
git diff HEADempty. After rebuilding spec and objectql,--absentfound all three markers absent (spec 230 built files, objectql 14), whole-tree porcelain empty, and the pins green again (objectql 13 passed; rest 14 passed / 7 skipped; spec 24; lint 24).Gates
node scripts/pm/dispatch-gates.mjs --commands(no paths) atf53718f1eederived 117 commands over 17 paths vs merge base00a92e18d. All 117 were run atf53718f1eewith their exit codes recorded, and--ranreconciles them: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Among them:check:adr-0087-registration --base origin/main:not-required (already-registered dataset-measure-aggregate-field-type-refused)accepted.check:changeset-no-major,check-empty-changeset("No changeset from the merge base modified or deleted by this diff"),check:doc-authoring,check:nul-bytes,check:issue-citations,check:engine-double-contract,check:driver-memory-census,check:query-options-erasure,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage.@objectstack/specgenerated-artifact gates,check:migration-registry,check:spec-changesandcheck:upgrade-guideincluded. The registry was regenerated for the amended entries, and again after the merge (no diff).check:skill-examples,check:dual-build-cjs-loadsandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (exit 3). Afterturbo run build --filter='./packages/*' --filter='./packages/*/*'they answered 0; that build is offb239eb2f3, whose package sources equal this head's.Lint, narrowed and proven at
f53718f1ee: eslint with inline config disabled, over the 15 changed.tsfiles, found 15 files, 0 errors, 0 warnings. Three facts make this narrowing a measurement:isPathIgnoredisfalsefor all 15.parserOptions.projectandprojectServicearenullfor every file, so type-aware linting is not enabled.Changeset
.changeset/20808-json-stored-group-distinct-refused.md:@objectstack/objectqlminorand@objectstack/specminor, each with its own BREAKING banner.@objectstack/lintand@objectstack/service-analyticsarepatch, for their words.Clause-②: no (narrowing), and exactly one ADR-0087 marker:not-required (already-registered dataset-measure-aggregate-field-type-refused). The table row is that family's narrowing, and the non-temporal sum / avg narrowing rode the same id the same way; this diff amends that entry's prose to name the rider. The engine-door halves refuse a query shape, not a stored one.groupByentry of this same release lists as unchanged. That pending note is left as it landed: rewriting it redscheck-empty-changesetfor a human's confirmation.No export or published type changes (
assertGroupByNamesNoJsonStoredFieldand the new door are internal; objectql's root and./coreexports are unchanged).Acceptance notes
packages/services/service-analytics, beside its structured-JSON dimension door.min/maxover a JSON-stored field at the engine door:POST /api/v1/data/:object/querywithmax(meta)answered{a:1}on memory,'{"b":1}'(a string) on SQLite and 500 on PostgreSQL (function max(json) does not exist), andmax(tags)the same way. The table already refuses those pairs, and its dataset legs enforce that; the engine enforces only thecount_distinctrow, as ruled here.file,image,avatar,video,audio) is a JSON column on a deployment that has not moved its media columns (JSON_COLUMN_TYPES' header in driver-sql), so agroupByorcount_distincton one there would split the same way. This is by reading, not measured: a fresh deployment creates the string column. Not judged here, because the type alone cannot tell the two deployments apart.groupBy[0],aggregations[0].field), not the cube member the caller wrote. The same note was made on the structured-JSON door.Generated by Claude Code