Repository navigation
fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure (#21044) - #21128
Conversation
…d-type judgment (red) Pins first, ahead of the fix: a configured cube measure whose aggregate the aggregate x field-type table refuses for its column's declared type is refused INVALID_FIELD / 400 before either strategy reads anything; max over a number column is served typed number (the control); min / max over a temporal column is described time. The #20889 control that read max over a text column back is flipped to the refusal. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…e table for every measure A configured or suffix-inferred cube measure whose aggregate AGGREGATE_FIELD_TYPE_COMPATIBILITY refuses for its column's declared type is refused INVALID_FIELD / 400 in ensureCube, ahead of both strategies, as the dataset door refuses the pair at compile. count_distinct keeps its own door. A min / max over a temporal column is described time in fields[] by the dataset door's one rule, measureResultType, at the seam every strategy's result leaves through. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…be-measure-type-table
…be-measure-type-table
The analytics dispatcher faces now refuse an anonymous caller with 401 before the analytics service is reached (ADR-0056 D2), so the route pin stubs the `auth` slot the way the sibling analytics route pins do. The route, the service and the engine stay real. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 — 10 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 05e96aef676c8676a10126e490c5a7fa2348f9e3 && git checkout 05e96aef676c8676a10126e490c5a7fa2348f9e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a11faeecb34eb14800d64769cb86a8e1140988f1 d85b700030e079e1d3804c81f62e3ca208ce898f && git checkout -B drift-repro a11faeecb34eb14800d64769cb86a8e1140988f1 && git merge --no-ff d85b700030e079e1d3804c81f62e3ca208ce898f
node scripts/docs-audit/affected-docs.mjs --json a11faeecb34eb14800d64769cb86a8e1140988f1
|
Contract reviewServed-tier: Isolated review for the ① Derived judgments
② Semver level
③ Boundary flags
Read at 2026-10-01T08:38Z: the card, the PR, the net diff and the head's check-runs; nothing built, run or re-run. Implemented-by: VERDICT: PASS Generated by Claude Code |
…e policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (objectstack-ai#21042) (objectstack-ai#21209) Fixes objectstack-ai#21042 Clause-②: yes (widening) ## What this changes The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a SQL driver) skipped three aggregate policies that `SqlDriver.aggregate()` applies. So one route answered different numbers, or a `500`, depending on which strategy served it. This PR follows the route ruling on objectstack-ai#21042 (comment `5925613967`): the operand policies are hoisted into `@objectstack/core`, beside `AGGREGATE_ANSWER_KIND`, and both faces read them from there. - **`packages/core/src/utils/aggregate-answer.ts`** (`@objectstack/core`, `minor`). It takes: - `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock (the docblock and the table are byte-identical to the base, apart from the `export` keyword); - `aggregandColumnClass({ type, multiple })`, the one column-class predicate (`'fractional'`, `'integral'`, `'boolean'`, or none); - `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the objectstack-ai#11635 cast, as a `Record` over `AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the counts never are; - `doubleAccumulationOperand(operand, dialect)`, the double operand with the dialect as a parameter; - `aggregandOperandSql(func, columnClass, dialect, operand)`, the one composition both faces emit (the cast inside, the double operand around it). No `index.ts` line was added: the module is already exported. - **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer plus an import. `isFractionalNumericType` is deleted. The two registry fills fill `fractionalNumericFields` through the predicate. `accumulatesInDouble` / `doubleAccumulationOperand` are replaced by `aggregandColumnClassOf`, which maps the driver's registries onto the predicate's classes. In `aggregate()`, the private boolean-cast condition and the accumulation call become one `aggregandOperandSql(funcName, class, this.dialectName, '??')`. - **`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`** (`patch`), in two places. - **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` / `CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class comes from the declaration the host already relays (`declaredValueShape`), on the object the column lives on (`columnObjectOf`, the one hop resolver). The dialect comes from `sqlDialect`, which the strategy already reads. - **The `execute` shaping point** that PR objectstack-ai#21040 added now folds a `null` measure answer to `emptyGroupValueFor(measure.type)` (`@objectstack/spec`). It does this for every measure, measure-scoped ones included, before the number presenter, in `driver-sql`'s order. The dataset door's `DatasetExecutor` fill stays, and it is idempotent on a folded row. - The one line outside those two regions is the `generateSql` call site, which now passes `ctx` to `resolveMeasureSql`. - No native copy of any policy, and no runtime hook asks the driver: the rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the hop-object sites, the filter / text-match rendering, `analytics-service.ts` and `field-read-admission.ts` are untouched. ## The card's table, before and after Measured through `AnalyticsService.query` (the cube door, which `POST /api/v1/analytics/query` relays verbatim) and `AnalyticsService.queryDataset` (the dataset door), on `AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`. **Native** is the plugin's own composition (`NativeSQLStrategy` answered, with one raw statement and no engine aggregate). **ObjectQL** is the same composition narrowed to `engine.aggregate`. "Before" is the strategy file at the base `d34aa58a2a`; "after" is this branch at `61aab5013a`. Neither merge since then touches the aggregate code paths, and the pins below are green at `ef1f9d8484`. Fixture: - group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag` holds true and false; - group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and `flag` holds 7 trues and 2 falses; - group `n`: every aggregand is NULL in all three rows. The measure-scoped measures filter on `tag = x`, which only group `f` holds. **PostgreSQL 16.13** (a private local server; the ObjectQL column is the same before and after): | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` | `0.30000000000000004` | | `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` | `0.15000000000000002` | | `avg(stars)`, an integer column | i | cube, dataset | `1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` | | `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` | | `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0.7777777777777778` | `0.7777777777777778` | | `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0` / `1` | `0` / `1` | | `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` | `0` | | `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` | `null` | | measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `avg(frac)`, no admitted row | i | cube | `null` | `null` | `null` | | `count` control | n | cube, dataset | `3` | `3` | `3` | | measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` | **SQLite** (better-sqlite3): accumulation and the boolean answers already agreed on every face (`0.30000000000000004`, `0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`, `0` / `1`). The fold is the policy that diverged there: | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0` (executor fill) | `0` | `0` | After the fix, the native and ObjectQL faces **differ in 0 of 144 cells** (2 drivers × 2 doors × 12 measures × 3 groups). **MySQL is NOT MEASURED**: there is no MySQL server in this container. The MySQL operand text is pinned offline: by `core`'s `aggregate-answer.test.ts`, and by the `driver-sql` move proof for the driver's own statements. ## The move proof `driver-sql`'s aggregate statements were dumped at the base, before any consumer changed. The dump covered `SqlDriver.aggregate()` for every function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and `count(*)`), aliased and unaliased, over 23 columns: every fractional, integral and boolean type, the `float` / `integer` / `int` aliases, multi-valued and untyped columns, and text / date / lookup / formula. It ran on SQLite, PostgreSQL and MySQL, through both registration paths (`registerObjectMetadata` and `registerExternalObject`), offline (knex `toSQL()`). The policies were then hoisted, `driver-sql` was switched to the imports, and the same dump was run again: - base dump: 1668 entries, 0 errors, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`; - after dump (at `bc8aa0cc2f`): 1668 entries, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps are **byte-identical**. `sql-driver.ts` and `aggregate-answer.ts` are unchanged between `bc8aa0cc2f` and `61aab5013a`. The committed move-proof pin, `packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`, holds the captured expressions for one column of each class, on each dialect and through each registration path. It passed at the base (`192fc0010b`: 54 / 54) and passes after (54 / 54). ## Pins (committed red first, then the fix) | file | at the pins commit (`192fc0010b`, base code) | after | |:--|:--|:--| | `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist) | 22 / 22 | | `service-analytics` `native-sql-aggregate-policies.test.ts` (each measure on both faces at both doors, against the engine's arithmetic; SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red. PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 | | `service-analytics` `cube-measure-field-type-door.test.ts`, **the lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 | | `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route, both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite green (that door already folded) | 19 / 19 | | `driver-sql` move proof | 54 / 54 | 54 / 54 | **The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')` in `cube-measure-field-type-door.test.ts` (from PR objectstack-ai#21128) is gone. Its comment now says why the cell runs on every cell and face. The PostgreSQL native `max_flag` cell answers `1`. `native-sql-measure-number-presentation.test.ts` gets a comment-only edit: its header said the native statement does not carry objectstack-ai#20387's accumulation, and it now points at the new pin. ## Ablations There was one ablation per policy, each predicted in writing before it ran. Each mutation was planted through `scripts/ablation-replace.mjs`, which checks that the anchor hit and that the blob changed. Each mutation was confirmed in the built `dist/` (`ablation-dist-preflight.mjs`: marker present). Each restore ran by absolute path (`git checkout HEAD`), and the file's blob was proven equal to its `HEAD` blob with `git diff HEAD` empty. After each restore the package was rebuilt, and the marker was proven absent from `dist/` with the tree clean. Every prediction held exactly. | ablation | mutation | predicted red | observed red | |:--|:--|:--|:--| | A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg and mysql × both fills × the six numeric / boolean columns); `service-analytics` 10, PostgreSQL only (both doors × `sum` / `avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3, PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green; `avg(flag)` green as predicted | | A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1; move proof 4 (pg × both fills × `boolean` / `toggle`); `service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 / 2 / 4 | | A3 fold | the native shaping point never folds | `service-analytics` 8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and the measure-scoped `sum`); everything else green, the `rest` dataset-door route included, because the executor fill folds there | the same 8; `rest` 19 / 19 green | A1 and A2 show one policy reaching both faces. Each one turned `driver-sql`'s own statements red. Under A2 the ObjectQL face's `max(boolean)` cell failed too, with the driver's refusal. Under A1 the ObjectQL face answered the same exact decimal as the native face for the plain measures: the failing assertion was the engine's number, while native and ObjectQL still agreed. A **reverse type check** also ran. Passing a dialect the new type rejects (`'oracle'`) to `aggregandOperandSql` turned `service-analytics`' typecheck red (`TS2345 ... not assignable to parameter of type 'AggregandSqlDialect'`), which shows the rebuilt `core` `.d.ts` was read. The file was restored byte-identical. ## Verification (at `ef1f9d8484`, after merging `origin/main` at `cb45469e67`, which carries PR objectstack-ai#21170 and PR objectstack-ai#21173) Everything below ran as one locked script, at `ef1f9d8484`, with each exit code captured before any pipe. The live PostgreSQL 16.13 server ran at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under `TZ=America/New_York`, which are its own non-vacuity preconditions. - **Refresh after the merge:** `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm --filter @objectstack/spec check:generated` exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the real diff (10 paths). All 67 exited 0. Reconciliation with `--ran` and the recorded exit codes printed: `Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.` - **Typecheck:** `pnpm --filter … typecheck` exit 0 for `@objectstack/core`, `@objectstack/driver-sql`, `@objectstack/service-analytics` and `@objectstack/rest`. - **Tests, every vitest project of every touched package** (`vitest run --maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran): | package / project | files | tests | |:--|:--|:--| | `core` local | 74 passed | 2120 passed | | `core` repo | 3 passed | 48 passed | | `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped | | `service-analytics` | 161 passed | 3765 passed | | `rest` local | 256 passed | 5027 passed, 127 skipped | | `rest` repo | 5 passed | 179 passed, 1 skipped | - **The five pin files, run explicitly:** move proof 54 / 54, `core` 22 / 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle), field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL + the oracle). - **Lint, narrowed and proven:** `eslint --no-inline-config --format json` over the 9 changed code files answered 9 results, 0 errors and 0 warnings. The population is read from eslint's own config: `ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing excludes nothing that could move, because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - **The `driver-sql` live preconditions:** the first gate run used a private server at UTC, and the suite's four timezone non-vacuity cells failed by design (`… start it with timezone=Asia/Shanghai`). With the server at `Asia/Shanghai` and the process at `America/New_York` the suite is green, as listed above. - **`main` after the final merge:** `origin/main` moved 4 commits past `cb45469e67` before this PR opened (objectstack-ai#21149, objectstack-ai#21188, objectstack-ai#21195, objectstack-ai#21192). None of them touches `core`, `driver-sql`, `service-analytics` or the analytics `rest` tests. The one `packages/spec` file in the analytics area, `ui/dataset.zod.ts`, changes a comment only. They are not merged here; CI runs on the merge ref. ## Acceptance notes - **MySQL is NOT MEASURED** (no server in this container). The MySQL operand text is pinned offline in `core` and in the `driver-sql` move proof. - **The live PostgreSQL cells are not run in CI.** No CI step sets `OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells above ran against a private PostgreSQL 16.13 started for this run and removed afterwards. In CI the SQLite cells run, and so do the offline `core` / move-proof pins. - **Phase 0's note on the dataset door, which is no divergence:** a measure-scoped `avg` is **absent** from a row its supplementary query reported no row for. That is `x_avg_frac` for groups `i` and `n`, on both strategies and before and after. It is not `null`. The new service pin holds this cell only to "both faces agree", not to a value. Relatedly, a dataset-door selection made only of measure-scoped measures reports only the groups their filter admits, so the pin asks each one beside the base count. - **Residual, as stated in the ruling:** a host that relays no field declarations (`declaredValueShape`), or names no SQL dialect, gets no column class or no policy. It keeps the native arithmetic it had, and a PostgreSQL boolean `sum` there still answers `500`. The plugin's own composition wires both. - **How `driver-sql` reads the class:** it reads its own registries rather than calling the predicate per column. `fractionalNumericFields` is filled by the predicate. `booleanFields` and `numericFields` are filled by the driver's coercion rules, whose populations equal the predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The move proof pins that equality per column class. Asking the predicate per column through the driver's `valueShapeFields` would retire `fractionalNumericFields`, but its declaration and shard-alias regions are outside the ruled surface, so this PR does not do it. - **The scan-order residual is unchanged.** On PostgreSQL and MySQL the double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s docblock), so three or more fractions can still differ in the last place from SQLite and the rows path. The pins use two addends. - objectstack-ai#21129 (the presenter for a relationship-path `min` / `max`) is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21044
Clause-②: no (narrowing)
What changed
A configured cube's
maxover atextcolumn was served at the cube door (POST /api/v1/analytics/query) by the native-SQL strategy, with the column's text, while the same response'sfields[]declared the measurenumber. The dataset door refuses that pair at compile by the spec tableAGGREGATE_FIELD_TYPE_COMPATIBILITY, and the cube door consulted nothing. Triage's direction (5924500811) is carried out as ruled: the cube door asks the one table, and no second table exists.packages/services/service-analytics/src/cube-measure-field-type-door.ts, new, besidemeasure-result-type.ts):assertCubeMeasureFieldTypesAcceptedrefuses the firstmeasuresentry whose aggregate the table refuses for its column's declared type,INVALID_FIELD/ 400 throughinvalidMemberError, withmember,param: 'measures',cube,fieldandobjecton the error. The verdict isisAggregateCompatibleWithFieldType's; the accepted set the words name is read off the exported table. It judges every row of the table, as the dataset door does since decision batch 🔗 Broken links detected in documentation #127, with no scope condition on top of it.count_distinctkeeps its own door ([finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912,structured-json-dimension-door.ts), which asks the same row plus themultiple: truedeclaration, so one pair has one verdict and one wording.analytics-service.ts):assertMeasureFieldTypes, called inensureCubeon all three paths (inferred cube, augmented cube, declared cube), right after the [finding] analytics: a cube / dataset dimension on ajsonfield, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807 / [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 door and before thewheregate. That is ahead ofcallCtxand strategy selection, so both strategies see it once and nothing is read before it answers. The same placement covers the dry run (POST /api/v1/analytics/sql) and every queryDatasetExecutorruns throughqueryIn.analytics-service.ts):withMeasureResultTypes, applied at the result seam inqueryInbesidewithDeclaredMeasureFormats, asks the dataset door's one rule,measureResultType, with the cube measure's aggregate and the declared type of the column it reads, and writes only what the rule answers. Amin/maxoverdate/datetime/timeis now describedtime. ⛔ No copy of the rule inbuildFieldMeta: both strategies are untouched.measure-result-type.ts: TSDoc only, one paragraph naming the cube door as the rule's second reader..changeset/21044-cube-measure-field-type-table.md:@objectstack/service-analyticsminor, BREAKING banner,Clause-②: no (narrowing), ADR-0087not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused).check:adr-0087-registrationaccepts it.The four measured questions of the dispatch
main. Confirmed through the real dispatcher route at base2821e9f15b, SQLite and PostgreSQL 16.13, both strategies (table below). The native face served every refusedmin/maxpair with the column's text underfields[]number. Since PR fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver #21037 the ObjectQL face already answered400 INVALID_FIELD, from the engine's aggregate door, after the strategy had begun: the words name the engine's position (aggregate('…'): aggregations[0].field takes the max of 'note', a declared text field…), and the error carries nomember.sumover the same text column answered0on SQLite on both faces and500 DATABASE_ERRORon PostgreSQL;avganswered0/500on the native face and400on the ObjectQL face.declaredMemberEntry(cube, member, 'measure')(the onewithDeclaredMeasureFormatsreads) gives the cube measure, itssqlis the column when it is a bare identifier, and the type issourceFieldMeta(object, column).type, the base-object declaration the [finding] analytics: a cube / dataset dimension on ajsonfield, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807 / [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 door andcompile()already read. ⛔ No second resolution of a member to a field. A relationship-path column is not judged (the declaration read is the base object's), which is the dataset door's tier.ensureCube, as above. The code isINVALID_FIELD/ 400, not the dataset door'sDATASET_INVALID/ 400, for the reasondataset-refusal.ts's header gives:DATASET_INVALIDis a verdict about a dataset document, and/analytics/querycarries none; a verdict about one member the request named is theINVALID_FIELDfamily. It is also the code the cube door's three source-field gates and its [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912count_distinctdoor answer, and the code the engine's door already answered for this very pair on the ObjectQL face, so that face's wire code does not move. The dataset door keepsDATASET_INVALIDat compile and never reaches this door for a pair it refuses.fields[]for an accepted non-numeric pair. BymeasureResultType, read at the cube door's result seam without widening the claimed surface:min/maxover a temporal column istime; over abooleancolumn the rule declines (three readings disagree), so the producer'snumberstands, which is what SQLite (1) and the ObjectQL face on PostgreSQL (1) answer.Triage's second sentence, "
buildFieldMetastops mintingnumberfor a non-numericmin/max", is delivered at the seam both strategies' results leave through rather than insidebuildFieldMeta, which has no field types to read: a refused pair never reaches a descriptor, a temporal one is re-describedtimeby the one rule, and a boolean one keepsnumberby that rule's own verdict.Per-row readings, before and after
2821e9f15b): status, value,fields[]typed85b700030)max_notemaxover note (text)"y",numberINVALID_FIELDmin_noteminover note (text)"x",numberINVALID_FIELDmax_statusmaxover status (select)"won",numberINVALID_FIELDmax_openedmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timemin_dueminover due_on (date)"2026-01-15",number"2026-01-15",timemax_flagmaxover flag (boolean)1,number1,numbermax_amountmaxover amount (number) control32,number32,numbersum_notesumover note (text)0,numberINVALID_FIELDavg_noteavgover note (text)0,numberINVALID_FIELDcd_notecount_distinctover note (text) control2,number2,numbernote_maxmaxover note (text)"y",numberINVALID_FIELDamount_maxmaxover amount (number) control32,number32,numberopened_at_maxmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timenote_maxmaxover note (text)"y",numberINVALID_FIELDmax_notemaxover note (text)INVALID_FIELDINVALID_FIELDmin_noteminover note (text)INVALID_FIELDINVALID_FIELDmax_statusmaxover status (select)INVALID_FIELDINVALID_FIELDmax_openedmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timemin_dueminover due_on (date)"2026-01-15",number"2026-01-15",timemax_flagmaxover flag (boolean)1,number1,numbermax_amountmaxover amount (number) control32,number32,numbersum_notesumover note (text)0,numberINVALID_FIELDavg_noteavgover note (text)INVALID_FIELDINVALID_FIELDcd_notecount_distinctover note (text) control2,number2,numbernote_maxmaxover note (text)INVALID_FIELDINVALID_FIELDamount_maxmaxover amount (number) control32,number32,numberopened_at_maxmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timenote_maxmaxover note (text)INVALID_FIELDINVALID_FIELDmax_notemaxover note (text)"y",numberINVALID_FIELDmin_noteminover note (text)"x",numberINVALID_FIELDmax_statusmaxover status (select)"won",numberINVALID_FIELDmax_openedmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timemin_dueminover due_on (date)"2026-01-15",number"2026-01-15",timemax_flagmaxover flag (boolean)DATABASE_ERRORDATABASE_ERRORmax_amountmaxover amount (number) control32,number32,numbersum_notesumover note (text)DATABASE_ERRORINVALID_FIELDavg_noteavgover note (text)DATABASE_ERRORINVALID_FIELDcd_notecount_distinctover note (text) control2,number2,numbernote_maxmaxover note (text)"y",numberINVALID_FIELDamount_maxmaxover amount (number) control32,number32,numberopened_at_maxmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timenote_maxmaxover note (text)"y",numberINVALID_FIELDmax_notemaxover note (text)INVALID_FIELDINVALID_FIELDmin_noteminover note (text)INVALID_FIELDINVALID_FIELDmax_statusmaxover status (select)INVALID_FIELDINVALID_FIELDmax_openedmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timemin_dueminover due_on (date)"2026-01-15",number"2026-01-15",timemax_flagmaxover flag (boolean)1,number1,numbermax_amountmaxover amount (number) control32,number32,numbersum_notesumover note (text)DATABASE_ERRORINVALID_FIELDavg_noteavgover note (text)INVALID_FIELDINVALID_FIELDcd_notecount_distinctover note (text) control2,number2,numbernote_maxmaxover note (text)INVALID_FIELDINVALID_FIELDamount_maxmaxover amount (number) control32,number32,numberopened_at_maxmaxover opened_at (datetime)"2026-03-04T05:06:07.000Z",number"2026-03-04T05:06:07.000Z",timenote_maxmaxover note (text)INVALID_FIELDINVALID_FIELD"Before" is base
2821e9f15b; "after" is this branch's headd85b700030(two merges ofmainin, the second carrying #21098's 401 for an anonymous analytics caller, so the probe signs its caller in). Both were read through the realdispatcher-pluginmount ofPOST /api/v1/analytics/query, overAnalyticsServicePlugincomposed on a realObjectQLengine andSqlDriver, by a scratch probe that was deleted after each run. Two rows of a ledger:notex/y(text),statusopen/won(select), two instants and two days,flagtrue/false,amount10/32. "Inferred" is an unregistered cube name (the object's), "augmented" a suffix-inferred measure on the configured cube. The same 56 readings were taken again after the first merge ofmain(0abe2120fb): no row differs from the head's. Since #21103 landed (in the second merge) the engine's door also refusessumover a refused type, so on the ObjectQL face thesumrows would answer400without this change too; this door answers first.Pins (red first), the ablation
be5c1a69b2(pins only, no fix; base2821e9f15b), SQLite and a private PostgreSQL 16.13:service-analyticssrc/__tests__/cube-measure-field-type-door.test.ts(new) and the flippednative-sql-measure-number-presentation.test.ts: 16 failed, 16 passed, 1 skipped. Failures:max_note must not be served: expected { rows: [ { max_note: 'y' } ], …(1) } to be undefined(native),max_note: expected undefined to be 'max_note'(ObjectQL: the engine's refusal carries nomember),max_opened is described time: expected 'number' to be 'time',expected the query to be refused, but it resolved(dry run).runtimesrc/analytics-cube-measure-field-type-door.test.ts(new): 6 failed, 6 passed. Nativemax_noteanswered200; both faces describedmax_openednumber. The ObjectQL face's400 INVALID_FIELDand both faces'numbercontrols were green already.native-sql-measure-number-presentation.test.ts([finding] analytics: on PostgreSQL the native-SQL path answers a measure the response declaresnumberas a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889) read a configured cube'smaxover itscodetext column back as text, as the second half of its "keyed on the declared function, never on the value" control. That pin held exactly the served pair this card refuses, so it is flipped, not deleted: the case now assertsINVALID_FIELD/ 400 with no statement run, keeps its text-dimension half, and the cube read above it no longer asks formax_code.d85b700030: the same files 32 passed, 1 skipped (the PostgreSQL nativemaxoverbooleancell, a named skip: an accepted pair that is a 500 there, see Acceptance notes) and 12 passed.5d69394a60. Predicted before running, inprogress.log: service-analytics 12 red (per dialect: the native refusal, the ObjectQL refusal, both inferred-measure cases, the dry run and the flipped [finding] analytics: on PostgreSQL the native-SQL path answers a measure the response declaresnumberas a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889 case), 20 green, 1 skipped; runtime 2 red (the native refusal on both dialects), 10 green, because the engine's door still answers the ObjectQL face's400 INVALID_FIELDon the wire.scripts/ablation-replace.mjs(WRAP mode, trap-restored, absolute path):if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue;gained|| String(aggregate) !== 'ABLATION-21044', so every pair passes. Anchor 1 to 0, marker 0 to 1, blob6c7bdce48e43tof49be3058c84. A secondtrapin the outer script restored by absolute path too.service-analyticswas rebuilt, andablation-dist-preflight.mjsfound the marker in 2 built files (dist/index.cjs,dist/index.js).6c7bdce48e43equals HEAD,git diff HEADempty, whole-tree porcelain 0 lines. Then rebuilt, andablation-dist-preflight.mjs --absent: marker absent from all 6 built files and the tree clean. The pins are green again at both later heads.Tests (at
d85b700030, afterpnpm install --frozen-lockfileand a fullturbo run build, 73 tasks)@objectstack/service-analytics, full suite withOS_TEST_POSTGRES_URLset (no PostgreSQL cell skipped): 156 files, 3558 passed, 1 skipped (the named cell above).typecheckexit 0;tsc --noEmit --listFileslists both touched test files.@objectstack/rest,src/analytics-*andrest-hook-refusal-message-parity, with PostgreSQL: 20 files, 279 passed.@objectstack/runtime,src/analytics-*,src/dispatcher*,src/http-dispatcher*,src/domains/analytics*and the other analytics-route suites: 51 files, 843 passed.typecheckexit 0,check:test-typecheckholds its ledger (27 files, 190 errors, 68 signatures; the new file adds none).restandruntimesuites, andpackages/qa/dogfood(Dogfood Regression Gate).Gates (at
d85b700030, as ONE sequential script under the shared verify lock, each exit code captured before any pipe)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstacknames 62.--ranreconciles:62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3). All 62 exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtansweredPREREQUISITE NOT MET(exit 3) on the first sweep at0abe2120fb, which had built only the dependency closure; after a fullturbo run build(73 tasks) both exit 0, and both are 0 in the sweep at this head.node scripts/check-changeset-fixed.mjs,pnpm check:authz-resolver,pnpm check:error-code-casing,pnpm check:filter-alias-parity,pnpm check:route-ledger-census. All exit 0.check:adr-0087-registrationreads the changeset as[BREAKING+bang+clause-②-narrowing] not-required (already-registered).check-changeset-no-major: nomajorbump.check:nul-bytes: 9734 text files, no raw control bytes.ESLint, a declared narrowing (the repo-wide
pnpm lintis CI's)eslint --no-inline-config --format jsonover the 6 touched.tsfiles at0abe2120fb(the two later commits are a merge ofmainand a test-only harness change of 7 lines). From the JSON: 6 files, 0 errors, 0 warnings, 0 ignored.eslint.config.mjs'sfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED, which contains all 6 (none came back ignored).parserOptions.projectorprojectServicein any block), and this diff does not touch the config, so no untouched file's verdict can move.Beyond the claimed file surface
packages/runtime/src/, not besidepackages/rest/src/analytics-*.test.ts. The cube door,POST /api/v1/analytics/query, is mounted by@objectstack/runtime'sdispatcher-plugin;RestServermounts only/analytics/dataset/query, so a pin inrestcannot reach this route. The new file is test-only and sits beside the sibling analytics route pins there.rest's analytics suites were run as well (above).sum/avgare judged by the same door. The claim priced the narrowing asmin/max; the door asks the table for every row, as the dataset door has since decision batch 🔗 Broken links detected in documentation #127, because amin/max-only condition would be a second scope on top of the one table, the shapedataset-compiler.tsrecords retiring. Measured, those rows answered0on SQLite and500on PostgreSQL before (table above), and the changeset prices them. No shipped cube authors either.Acceptance notes
timedescription reads metadata only.packages/qa/dogfood(Dogfood Regression Gateis CI's). The one shipped cube,examples/app-showcase'sshowcase_delivery, declarescountover*andsum/avgoverestimate_hours(Field.number): every pair accepted. Every othermin/max/sum/avginexamples/**is a dataset measure, which the dataset door already judged.FIELD_max/FIELD_sumto/analytics/queryover a refused field now gets400 INVALID_FIELD; the sibling repository was not read (dispatch order).{ type: 'max', sql: 'account.name' }over a relatedtextfield is still served on the native face (200,"zeta",fields[]number, SQLite and PostgreSQL), and{ type: 'max', sql: 'account.revenue' }over a relatednumberfield answers the string"250.000000000000000000000000000000"on PostgreSQL's native face underfields[]number. The ObjectQL face refuses both as a cross-object measure (400 INVALID_FIELD). Judging them needs the declaration on the hop's object, which is the hop-object resolution this dispatch fences off (analytics: on an inferred cube, a dotted path through a lookup whose name differs from its target object is never served — the door admits (and refuses) the lookup's NAME as if it were an object #20986's sites); the door stands down on a dotted column rather than guess, the dataset door's tier. This is the second position the seat's comment5924234751names; reported to the seat, not fixed here.500 DATABASE_ERRORformaxover abooleancolumn, a pair the table ACCEPTS (function max(boolean) does not exist); SQLite and the ObjectQL face on PostgreSQL answer1. Unchanged by this PR (measured before and after); the boolean pin skips that one cell by name. Reported to the seat.mainadvanced three commits after the second merge (a11faeecb3,99398542b3,53ed3d1093: objectql's aggregation-filter door, driver-mongodb and the showcase's security set). None touchesservice-analytics, the dispatcher or the spec table; CI and the merge queue read the merged generation.127.0.0.1from/tmp; it is stopped and its directory removed with this delivery.Generated by Claude Code