Repository navigation
fix(objectql)!: engine aggregate judges the sum row too — sum over a refused type answers INVALID_FIELD / 400 on every driver - #21103
Conversation
…refused type answers INVALID_FIELD / 400 on every driver The aggregate x field-type door held the table's sum row back for a census answer; triage released it. ROWS_HELD_FOR_TRIAGE is deleted (it would be empty), the held sum pins flip to refusal pins, and the spec TSDoc states that the door asks every row. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. 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 cf6577efba20539fc698369d03840758afc94dc8 && git checkout cf6577efba20539fc698369d03840758afc94dc8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e470f8c1c6ac2ef5f05c4d65a7ab0b0ac388c9f 1519ed6bdd2a958501c84b4c3241c79bf6961fb9 && git checkout -B drift-repro 5e470f8c1c6ac2ef5f05c4d65a7ab0b0ac388c9f && git merge --no-ff 1519ed6bdd2a958501c84b4c3241c79bf6961fb9
node scripts/docs-audit/affected-docs.mjs --json 5e470f8c1c6ac2ef5f05c4d65a7ab0b0ac388c9f |
Contract reviewServed-tier: PR #21103 ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…e table for every measure (objectstack-ai#21044) (objectstack-ai#21128) Fixes objectstack-ai#21044 Clause-②: no (narrowing) ## What changed A configured cube's `max` over a `text` column 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's `fields[]` declared the measure `number`. The dataset door refuses that pair at compile by the spec table `AGGREGATE_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. - **The judgment** (`packages/services/service-analytics/src/cube-measure-field-type-door.ts`, new, beside `measure-result-type.ts`): `assertCubeMeasureFieldTypesAccepted` refuses the first `measures` entry whose aggregate the table refuses for its column's declared type, `INVALID_FIELD` / 400 through `invalidMemberError`, with `member`, `param: 'measures'`, `cube`, `field` and `object` on the error. The verdict is `isAggregateCompatibleWithFieldType`'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 objectstack-ai#127, with no scope condition on top of it. `count_distinct` keeps its own door (objectstack-ai#20912, `structured-json-dimension-door.ts`), which asks the same row plus the `multiple: true` declaration, so one pair has one verdict and one wording. - **Where it runs** (`analytics-service.ts`): `assertMeasureFieldTypes`, called in `ensureCube` on all three paths (inferred cube, augmented cube, declared cube), right after the objectstack-ai#20807 / objectstack-ai#20912 door and before the `where` gate. That is ahead of `callCtx` and 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 query `DatasetExecutor` runs through `queryIn`. - **The column description** (`analytics-service.ts`): `withMeasureResultTypes`, applied at the result seam in `queryIn` beside `withDeclaredMeasureFormats`, 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. A `min` / `max` over `date` / `datetime` / `time` is now described `time`. ⛔ No copy of the rule in `buildFieldMeta`: 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-analytics` minor, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused)`. `check:adr-0087-registration` accepts it. ### The four measured questions of the dispatch - **H1, the card's reading on `main`.** Confirmed through the real dispatcher route at base `2821e9f15b`, SQLite and PostgreSQL 16.13, both strategies (table below). The native face served every refused `min` / `max` pair with the column's text under `fields[]` `number`. Since PR objectstack-ai#21037 the ObjectQL face already answered `400 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 no `member`. `sum` over the same text column answered `0` on SQLite on both faces and `500 DATABASE_ERROR` on PostgreSQL; `avg` answered `0` / `500` on the native face and `400` on the ObjectQL face. - **H2, where the door learns the source field type.** From the resolver this file already uses for measure columns: `declaredMemberEntry(cube, member, 'measure')` (the one `withDeclaredMeasureFormats` reads) gives the cube measure, its `sql` is the column when it is a bare identifier, and the type is `sourceFieldMeta(object, column).type`, the base-object declaration the objectstack-ai#20807 / objectstack-ai#20912 door and `compile()` 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. - **H3, where the refusal goes and its code.** In `ensureCube`, as above. The code is `INVALID_FIELD` / 400, not the dataset door's `DATASET_INVALID` / 400, for the reason `dataset-refusal.ts`'s header gives: `DATASET_INVALID` is a verdict about a dataset document, and `/analytics/query` carries none; a verdict about one member the request named is the `INVALID_FIELD` family. It is also the code the cube door's three source-field gates and its objectstack-ai#20912 `count_distinct` door 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 keeps `DATASET_INVALID` at compile and never reaches this door for a pair it refuses. - **H4, `fields[]` for an accepted non-numeric pair.** By `measureResultType`, read at the cube door's result seam without widening the claimed surface: `min` / `max` over a temporal column is `time`; over a `boolean` column the rule declines (three readings disagree), so the producer's `number` stands, which is what SQLite (`1`) and the ObjectQL face on PostgreSQL (`1`) answer. Triage's second sentence, "`buildFieldMeta` stops minting `number` for a non-numeric `min` / `max`", is delivered at the seam both strategies' results leave through rather than inside `buildFieldMeta`, which has no field types to read: a refused pair never reaches a descriptor, a temporal one is re-described `time` by the one rule, and a boolean one keeps `number` by that rule's own verdict. ## Per-row readings, before and after | driver | strategy | measure | pair | before (`2821e9f15b`): status, value, `fields[]` type | after (`d85b700030`) | |:--|:--|:--|:--|:--|:--| | SQLite | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | native SQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `avg_note` | `avg` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | native SQL | config `max_flag` | `max` over flag (boolean) | 500 `DATABASE_ERROR` | 500 `DATABASE_ERROR` | | PostgreSQL 16.13 | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `avg_note` | `avg` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | PostgreSQL 16.13 | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | "Before" is base `2821e9f15b`; "after" is this branch's head `d85b700030` (two merges of `main` in, the second carrying objectstack-ai#21098's 401 for an anonymous analytics caller, so the probe signs its caller in). Both were read through the real `dispatcher-plugin` mount of `POST /api/v1/analytics/query`, over `AnalyticsServicePlugin` composed on a real `ObjectQL` engine and `SqlDriver`, by a scratch probe that was deleted after each run. Two rows of a ledger: `note` `x` / `y` (text), `status` `open` / `won` (select), two instants and two days, `flag` `true` / `false`, `amount` `10` / `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 of `main` (`0abe2120fb`): no row differs from the head's. Since objectstack-ai#21103 landed (in the second merge) the engine's door also refuses `sum` over a refused type, so on the ObjectQL face the `sum` rows would answer `400` without this change too; this door answers first. ## Pins (red first), the ablation - **Red, on the tree committed as `be5c1a69b2`** (pins only, no fix; base `2821e9f15b`), SQLite and a private PostgreSQL 16.13: - `service-analytics` `src/__tests__/cube-measure-field-type-door.test.ts` (new) and the flipped `native-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 no `member`), `max_opened is described time: expected 'number' to be 'time'`, `expected the query to be refused, but it resolved` (dry run). - `runtime` `src/analytics-cube-measure-field-type-door.test.ts` (new): 6 failed, 6 passed. Native `max_note` answered `200`; both faces described `max_opened` `number`. The ObjectQL face's `400 INVALID_FIELD` and both faces' `number` controls were green already. - **Fixture triage.** `native-sql-measure-number-presentation.test.ts` (objectstack-ai#20889) read a configured cube's `max` over its `code` text 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 asserts `INVALID_FIELD` / 400 with no statement run, keeps its text-dimension half, and the cube read above it no longer asks for `max_code`. - **Green, at `d85b700030`:** the same files 32 passed, 1 skipped (the PostgreSQL native `max` over `boolean` cell, a named skip: an accepted pair that is a 500 there, see Acceptance notes) and 12 passed. - **Ablation** (the cube door's table check removed), from committed code at `5d69394a60`. Predicted before running, in `progress.log`: service-analytics 12 red (per dialect: the native refusal, the ObjectQL refusal, both inferred-measure cases, the dry run and the flipped objectstack-ai#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's `400 INVALID_FIELD` on the wire. - The mutation went through `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, blob `6c7bdce48e43` to `f49be3058c84`. A second `trap` in the outer script restored by absolute path too. - `service-analytics` was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 2 built files (`dist/index.cjs`, `dist/index.js`). - Observed: service-analytics 12 failed, 20 passed, 1 skipped; runtime 2 failed, 10 passed. Exactly as predicted. - Restore: blob after restore `6c7bdce48e43` equals HEAD, `git diff HEAD` empty, whole-tree porcelain 0 lines. Then rebuilt, and `ablation-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`, after `pnpm install --frozen-lockfile` and a full `turbo run build`, 73 tasks) - `@objectstack/service-analytics`, full suite with `OS_TEST_POSTGRES_URL` set (no PostgreSQL cell skipped): 156 files, 3558 passed, 1 skipped (the named cell above). `typecheck` exit 0; `tsc --noEmit --listFiles` lists both touched test files. - `@objectstack/rest`, `src/analytics-*` and `rest-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. `typecheck` exit 0, `check:test-typecheck` holds its ledger (27 files, 190 errors, 68 signatures; the new file adds none). - Not run locally, declared to CI: the whole `rest` and `runtime` suites, and `packages/qa/dogfood` (`Dogfood Regression Gate`). ## Gates (at `d85b700030`, as ONE sequential script under the shared verify lock, each exit code captured before any pipe) - **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 62. `--ran` reconciles: `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-loads` and `check:type-check-debt` answered `PREREQUISITE NOT MET` (exit 3) on the first sweep at `0abe2120fb`, which had built only the dependency closure; after a full `turbo run build` (73 tasks) both exit 0, and both are 0 in the sweep at this head. - **The five roster families the derivation marks ⛔ (a roster in a directory this diff touches):** `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-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (already-registered)`. `check-changeset-no-major`: no `major` bump. `check:nul-bytes`: 9734 text files, no raw control bytes. ## ESLint, a declared narrowing (the repo-wide `pnpm lint` is CI's) - Touched files: `eslint --no-inline-config --format json` over the 6 touched `.ts` files at `0abe2120fb` (the two later commits are a merge of `main` and a test-only harness change of 7 lines). From the JSON: 6 files, 0 errors, 0 warnings, 0 ignored. - Population: `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, which contains all 6 (none came back ignored). - Invariance: the config enables no type-aware linting (no `parserOptions.project` or `projectService` in any block), and this diff does not touch the config, so no untouched file's verdict can move. ## Beyond the claimed file surface - **The route pin lives in `packages/runtime/src/`, not beside `packages/rest/src/analytics-*.test.ts`.** The cube door, `POST /api/v1/analytics/query`, is mounted by `@objectstack/runtime`'s `dispatcher-plugin`; `RestServer` mounts only `/analytics/dataset/query`, so a pin in `rest` cannot 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` / `avg` are judged by the same door.** The claim priced the narrowing as `min` / `max`; the door asks the table for every row, as the dataset door has since decision batch objectstack-ai#127, because a `min` / `max`-only condition would be a second scope on top of the one table, the shape `dataset-compiler.ts` records retiring. Measured, those rows answered `0` on SQLite and `500` on PostgreSQL before (table above), and the changeset prices them. No shipped cube authors either. ## Acceptance notes - **NOT MEASURED: MySQL** (no server here). The door runs before any driver, so its verdict cannot depend on the dialect; the `time` description reads metadata only. - **NOT MEASURED locally: `packages/qa/dogfood`** (`Dogfood Regression Gate` is CI's). The one shipped cube, `examples/app-showcase`'s `showcase_delivery`, declares `count` over `*` and `sum` / `avg` over `estimate_hours` (`Field.number`): every pair accepted. Every other `min` / `max` / `sum` / `avg` in `examples/**` is a dataset measure, which the dataset door already judged. - **NOT MEASURED: the console.** A console widget that sends a suffix-inferred `FIELD_max` / `FIELD_sum` to `/analytics/query` over a refused field now gets `400 INVALID_FIELD`; the sibling repository was not read (dispatch order). - **Relationship-path measures are not judged here, measured at the head:** a configured measure `{ type: 'max', sql: 'account.name' }` over a related `text` field is still served on the native face (`200`, `"zeta"`, `fields[]` `number`, SQLite and PostgreSQL), and `{ type: 'max', sql: 'account.revenue' }` over a related `number` field answers the string `"250.000000000000000000000000000000"` on PostgreSQL's native face under `fields[]` `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 (objectstack-ai#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 comment `5924234751` names; reported to the seat, not fixed here. - **PostgreSQL's native face answers `500 DATABASE_ERROR` for `max` over a `boolean` column**, a pair the table ACCEPTS (`function max(boolean) does not exist`); SQLite and the ObjectQL face on PostgreSQL answer `1`. Unchanged by this PR (measured before and after); the boolean pin skips that one cell by name. Reported to the seat. - `main` advanced three commits after the second merge (`a11faeecb3`, `99398542b3`, `53ed3d1093`: objectql's aggregation-filter door, driver-mongodb and the showcase's security set). None touches `service-analytics`, the dispatcher or the spec table; CI and the merge queue read the merged generation. - The private PostgreSQL 16.13 cluster used for every live cell runs on `127.0.0.1` from `/tmp`; it is stopped and its directory removed with this delivery. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20914
Clause-②: no (narrowing)
The follow-up round of this card, after PR #21037 landed as
a75311dd2(Part of, thesumrow held). It executes triage's answer 5924445782 (A), whose execution line reads: "'sum'leavesROWS_HELD_FOR_TRIAGE, and the held pins flip. If the constant is then empty, delete it." With this PR the engine's aggregate door asks every row ofAGGREGATE_FIELD_TYPE_COMPATIBILITY, so the card's direction (the whole table) is complete.What this changes
packages/objectql/src/aggregate-field-type-door.ts:'sum'leavesROWS_HELD_FOR_TRIAGE. The set is then empty, so the constant is deleted with its one read.assertAggregationFieldTypesAcceptednow judges all six rows (countrefuses no type). ⛔ No second table and no per-pair exemption: the door still carries no accept list of its own. In the module header, the section "The row the census held back" becomes "Every row is judged —sumincluded" and records the release. It also gains the measuredsumtable, and "a held row" leaves the not-judged list.FUNCTION_WORDS.sum: "sums" / "does not sum"). The route is read off the table'ssumrow:sum accepts a field of type number, currency, rating, slider, progress, summary, boolean or toggle: aggregate a field of one of those types, or count the rows with count.It sits inside the 500 characters the REST door keeps.packages/spec/src/data/aggregate-field-type-compatibility.ts, TSDoc only. Review 5924038187 ① 7 named three published sentences; each now states the rows the engine door judges:count_distinctsince [finding] two more JSON-stored columns as a group or distinct key answer 500 on PostgreSQL:groupByon amultiple: trueselect, andcount_distincton ajsonfield #20808 …, andsum,avg,minandmaxsince [finding]max/minover a JSON-stored field answers per driver at the engine (memory an object, SQLite a string, PostgreSQL 500): the compatibility table refuses them, but the engine aggregate door enforces only itscount_distinctrow #20914 (countrefuses no type, so its row never refuses there)". The pointer to an objectql source file a spec consumer cannot see is gone.radiojoins the multi-capable list in the JSON-stored paragraph (MULTI_CAPABLE_TYPESholds it).removeComments) of the file at base and head is byte-identical, sha25622d8df93…on both sides: ⛔ the table and the predicate did not move. The new clause is in the builtdist/data/index.d.ts, and "other rows since [finding]max/minover a JSON-stored field answers per driver at the engine (memory an object, SQLite a string, PostgreSQL 500): the compatibility table refuses them, but the engine aggregate door enforces only itscount_distinctrow #20914" occurs in 0 built.d.ts..changeset/20914-release-sum-row.md:@objectstack/objectql: minorwith a BREAKING banner andClause-②: no (narrowing). It carries the FROM → TO per type class, what to write instead, and who is affected.not-required (already-registered dataset-measure-aggregate-field-type-refused). That registered id's prescription already coverssum/avgover every class the table refuses; the selecting id covers onlymin/max.radioomission (review ① 3) in its own text, and that entry's "Not judged yet:sum" paragraph as superseded. ⛔ The landed file is not edited.@objectstack/spec: patchfor the TSDoc.FROM → TO, measured on three drivers (H2)
Through
engine.aggregate, two rows, on the realInMemoryDriver, onSqlDriverover SQLite, and on a private PostgreSQL 16.14. Before = base2821e9f15; after = this branch's builtdist.sumoverjson0/0/ 500function sum(json) does not existINVALID_FIELDtext0/0/ 500function sum(text) does not existINVALID_FIELDselect0/0/ 500function sum(character varying) does not existINVALID_FIELDformula0/ 400INVALID_FIELD(driver: no column) / 400 (driver: no column)INVALID_FIELD, the engine's wordstags0/0/ 500INVALID_FIELDdatetime0/4052(the years added) / 500INVALID_FIELDpercent(refused by the table: a rate does not add)30/30/30INVALID_FIELDcurrency,number,boolean;maxnumber;countjson30,3,1,2,2on all threeThe
percentrow is the one whose old answer agreed across drivers. The table refuses it on the semantic ground its TSDoc names (isIncoherentAggregate), and it is in the changeset's FROM → TO with its route (avg).The census, re-run before the flip (H1)
Query: every line carrying a
'sum'/"sum"literal, plus the unquoted and backtick spellings. Each hit was resolved by hand to its object and the field's declared type, then asked of the table.examples/**at2821e9f15: 17 lines, 0 refused pairs. They are dataset measures, roll-upsummaryOperations, a cube measure, akpimetric and anObjectChartaggregate. Every operand is acurrency,numberorsummaryfield (11 distinct object.field pairs).objectstack-ai/hotcrmcloned at its headfb408a73): 33 lines insrc. One is a comment (aderivedop), which leaves 32 authoredsumaggregations over 18 distinct object.field pairs. 31 are overcurrencyornumber. 1 refused pair:src/sales/views/forecast.view.ts:28,crm_forecast.expected_amount(Field.formula), the list-columnsummary: 'sum'. That is the known hit: triage-answered and carried by crm_forecast: theall_forecastslist view authorssummary: 'sum'on the formula fieldexpected_amount, a pair objectstack's aggregate compatibility table refuses (objectstack-ai/objectstack#20914) hotcrm#1980, which has not changed it yet. No other hit, so the flip stands. Outsidesrc: 2 lines intest, and 3 in docs and the changelog (prose).Pins: the held
sumpins flipped, ⛔ not deletedpackages/objectql/src/engine-aggregate-field-type-door.test.ts:sum meta,sum title, which reached the driver while the row was held) move into a new refusal test. It coverssumoverjson,text,select,formula,tags,percent,datetimeand aselectwithmultiple: true, with envelopeINVALID_FIELD/ 400 /httpStatus400,field/fields/object/param, the message head and the route, and no read.sumover acurrencyand aboolean.sum× every type" now walks every row read off the table, so a row the door skipped would turn it red. It asserts the key set is the six functions, with asumfloor beside the others.sumtoo.packages/rest/src/data-aggregate-field-type-door.test.ts: asumrefusal cell overjson,text,select,tagsandformulaatPOST /api/v1/data/:object/query(SQLite always; PostgreSQL whereOS_TEST_POSTGRES_URLis set). The CONTROL gainssumover acurrency(60) and aboolean(2).packages/objectql/src/engine-json-stored-group-distinct-door.test.ts, its GUARD clause:summoves from "passes, held for triage" to the refused set besideavg/min/maxoverjson, in the same envelope at the same position.countstill passes.Ablation (from committed code,
1519ed6bd)node scripts/ablation-replace.mjs(WRAP, trap-restored) putsumback in the held set: the table-vocabularycontinuebecame… || (fn === 'sum' ? 'ablation-20914-sum-A1' : '')) continue;. Anchor 1 → 0, blob9a2bc89178af→adcfb213d670. objectql was rebuilt, andablation-dist-preflight.mjsfound the marker in 4 built files.sumpins only.sumrefusal test, the every-row GUARD (sum × text: expected null), and the group-distinct GUARD clause. Every other pin was green.sumcell on SQLite and on PostgreSQL. The controls and themin/max/avg/count_distinctcells were green.9a2bc89178af),git diff HEAD0 bytes, rebuilt.--absentfound the marker in 0 of 14 built files and the tree clean. The same pins were green again: objectql 31 / 31, REST 16 passed + 8 skipped.Tests (at
1519ed6bd, the merge oforigin/mainb3d7a7086; all with a private PostgreSQL 16.14 where a cell reads it)pnpm --filter @objectstack/objectql typecheckexit 0; test layer 40 files / 234 errors / 65 pinned, held.OS_TEST_POSTGRES_URLset): 259 files, 5124 passed / 52 skipped. typecheck exit 0; test layer 0 / 0 / 0.sumaggregation and reaches the engine: runtime 4 files / 25 tests, plugin-security 2 / 297, platform-objects 2 / 56, mcp 1 / 17, dogfood 2 / 12. All passed.1519ed6bd:--no-inline-config --format jsonover the 6 changed.tsfiles: 6 results, 0 errors, 0 warnings;isPathIgnoredis false for all 6;calculateConfigForFileshows noparserOptions.project/projectServiceon any of them. No type-aware lint is on, so this diff cannot move a verdict on an untouched file.pnpm lintis CI's.pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts up to date.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths, derived 88 commands at1519ed6bd. All 88 were run, each exit code recorded before any pipe, and all exited 0.--ranreconciles: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. Among them:check:adr-0087-registration(accepted,not-required (already-registered)),check:changeset-no-major,check:empty-changeset,check:doc-authoring,check:nul-bytes,check:engine-double-contractandcheck:api-surface.NOT MEASURED locally (CI-owned): the 5 path-scheduled CI jobs, the workspace type-check lanes beyond objectql and rest, the MySQL cells (no server here), and repo-wide
pnpm lint.origin/mainmoved 13 commits after this merge. A localgit merge-treeof the head with it is clean. One of them,88b484e00, touchesengine.tswhere metadata collections load (picklists), not at the aggregate call site. The merge queue rebuilds on currentmain.Deviations from the claim's file surface, each named
packages/objectql/src/engine.ts, one comment at the aggregate door's call site. "Thesumrow is held back by that card's census (see the door's header)" would have been false after this PR. It now says every row is asked, and adds the measuredsumanswer. No code line moved.radioadded to two comment lists of the multi-capable types: the door's DECLARATION paragraph, and the spec TSDoc's JSON-stored paragraph. This is the same omission review ① 3 named in the landed changeset. The door's own behaviour already heldradio(it asksisMultiValueField).Acceptance notes
sum×percentanswers with the door's generic reason ("accepts only the pairs every backend answers alike, and it refuses this one"), which is true but not the table's ground for that pair (a rate does not add). Its route lists the accepted types, notavg; the changeset carries theavgroute. A rate-specific reason would be a words change on the door, so it was left out of this PR. Carrier: none.crm_forecast.expected_amount,summary: 'sum'on aformula) stays with crm_forecast: theall_forecastslist view authorssummary: 'sum'on the formula fieldexpected_amount, a pair objectstack's aggregate compatibility table refuses (objectstack-ai/objectstack#20914) hotcrm#1980. It is a client-side list footer and does not reachengine.aggregate, so this PR does not change what that view shows.Generated by Claude Code