Repository navigation
fix(service-analytics): refuse a caller-named non-column member at the analytics query door in every tier (#21156) - #21173
Conversation
…mber at the query door in every tier (#21156) A member the caller names that is neither a declared cube member nor a column reference (a field, a relationship path, or '*') named nothing the field-level read gate could judge, and reached the native statement as written in the tiers that gate does not cover: a deployment with no security service, and an object its reader answers undefined for. Refuse it at the door, before any strategy compiles it, reusing the existing field-read refusal (PERMISSION_DENIED / 403) — one judge, no new error code, and the declared-cube paths unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) 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 ff256647d9f2eb8878b785b93f0e488beba8482a && git checkout ff256647d9f2eb8878b785b93f0e488beba8482a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c1cef3345738b5f2486eed5b0608687dba36f11 52d6bfac9e2ded1c7328a2d9432ab3e32a13d0cc && git checkout -B drift-repro 2c1cef3345738b5f2486eed5b0608687dba36f11 && git merge --no-ff 52d6bfac9e2ded1c7328a2d9432ab3e32a13d0cc
node scripts/docs-audit/affected-docs.mjs --json 2c1cef3345738b5f2486eed5b0608687dba36f11
|
…es to a non-column source (#21156) The member-shape door gate skipped the measure position: namedQueryFields drops an undeclared measure (the strategies refuse one the cube does not carry), so a caller-named measure that inference mints — its sql built from the caller's own text by inferMeasure — slipped the gate and reached the aggregate position of the native statement verbatim in the ungated tiers (no security service; an object the reader answers undefined for). The judged tier already refused it (the minted measure reads as a declared expression member and #21153 catches it); the ungated tiers did not. Judge the inferred source here too, against the same rule inference applies: a caller-named measure must reduce — after inferMeasure's suffix strip, the no-suffix default included — to count / '*' or to a column reference (bare identifier / relationship path), or it is refused with the same fieldReadUnjudgeableError (PERMISSION_DENIED / 403). Author-declared measures are untouched; a dotted non-qualifier measure is already refused by #5918 ahead of this. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
…alytics-member-unjudged
…fter the merge with main (#21156) The merged-in stored-metadata-body refusal test builds a NamedExpression literal for "an authored expression"; this branch made NamedExpression's `declared` flag required, so the literal no longer type-checked once the two met. Set `declared: true`, which is the test's stated intent (an authored expression). The only construction of the type outside namedQueryFields. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
…d field expression at the analytics door Narrows #21177 to the residual main does not judge after #21156 (#21173) landed. The /analytics/query door half — a caller-named non-column member — is superseded by #21156's single judge (PERMISSION_DENIED / 403), so this drops the duplicate handling of that class and reverts the #20965 pin on field-read-admission-gate.test.ts to main. The residual is an INLINE (caller-POSTed) dataset's own dimension/measure `field` text: the service compiles the dataset into a cube whose members read as declared, so a `field` that is a raw expression resolved to a declared cube member and was left to the field-level read gate, which stands down with no security service and on an object its reader answers `undefined` for. In those tiers the expression reached the native statement as written. The dataset's own `field` text is now judged at the dataset door, before compile and before any strategy runs, through main's existing judge (assertCallerMembersJudgeable / fieldReadUnjudgeableError): PERMISSION_DENIED / 403, naming the member and never the expression text, for every caller (admin included) and with or without a security service. No new error code and no second admission module; caller-content-admission.ts is removed. The dataset's filter, the selection's runtime filter and cube-query members are lowered into the compiled query and already judged on the query path, so they are unchanged; a registered dataset's own field text is author text and stays with the existing gates. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…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>
…at is not a column reference at the door (objectstack-ai#21190) Fixes objectstack-ai#21177 Clause-②: no (narrowing) ## What The analytics doors evaluated **caller-supplied content** at query time that neither the object-level nor the field-level read admission could judge. After merging `main`, this PR is narrowed to the residual that `main` does not yet judge. ### The `/analytics/query` half is superseded `main` landed objectstack-ai#21156 (as objectstack-ai#21173): a caller-named member that is neither a declared cube member nor a column reference is refused at the `/analytics/query` door in every tier, through the single field-read judge (`PERMISSION_DENIED` / 403 — "one judge, one shape, no new error code"). This PR therefore: - drops its own duplicate handling of that class, and - reverts its edits to `field-read-admission-gate.test.ts` back to objectstack-ai#20965's pin as `main` has it. ### The residual: a dataset's own `field` text The remaining gap is content `main` does not judge: the dimension and measure `field` text of a dataset sent to `POST /api/v1/analytics/dataset/query`. The service compiles that dataset into a cube whose members read as **declared**, so a dimension's or measure's `field` that is a raw expression resolves to a declared cube member — and objectstack-ai#21156 leaves a declared expression member to the field-level gate, which stands down with no security service and on an object its reader answers `undefined` for. In those tiers the expression reached the native statement as written. This PR judges the dataset's own `field` text at the dataset door, **before the dataset is compiled and before any strategy runs**, through `main`'s existing judge (`assertCallerMembersJudgeable` / `fieldReadUnjudgeableError`): `PERMISSION_DENIED` / 403, naming the member and never the expression text, for **every caller** (admin included) and whether or not a security service is wired. No new error code and no second admission module — `caller-content-admission.ts` is removed. What is left to the existing gates, unchanged: - The dataset's own `filter`, the selection's runtime filter and cube-query members are lowered into the compiled query and already judged on the query path by objectstack-ai#21156. - A dataset registered through the configuration door and queried by cube name runs through `query()`, where objectstack-ai#21156 leaves its members to the existing gates. **Inline and saved alike.** The route hands this door an inline dataset (`body.dataset`) and a saved one alike: it loads `body.datasetName` from metadata and calls the same `queryDataset`, and the build probe calls it with a saved dataset too. The service cannot tell the branches apart, so the refusal is uniform, which is the safer reading. A saved dataset whose `field` is an expression is refused the same way as an inline one. No shipped dataset carries a non-column `field` (`examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and `platform-objects` were read; the showcase `done_rate` is a derived measure, not a field expression). Refusing such a `field` when it is authored belongs to the dataset schema's own retirement of expression fields, a separate change. This aligns the caller-supplied dataset surface with ADR-0021's "zero raw expressions". ## What stays answerable Every dataset (inline or saved) and cube query that names columns, relationship paths and declared members — the whole legitimate author and query surface — is unchanged. A plain-column inline dataset, a saved plain-column dataset and a dataset registered through the configuration door are all still answered. Only a dataset `field` whose text is a raw expression is refused, inline or saved; the BREAKING note in the changeset covers both. ## Scope / serial - The fix is at the **analytics door** (`analytics-service.ts`), never in a strategy. `read-scope-sql.ts`, `strategies/native-sql-strategy.ts` and `contains-membership-sql.ts` (objectstack-ai#21117 / objectstack-ai#20987) are untouched. - `origin/main` was merged in (no rebase, no stacking). The residual gate sits beside objectstack-ai#21120's stored-metadata-body refusal and objectstack-ai#21156's caller-member gate, on the same unconditional seam. ## Tests - `inline-dataset-field-admission-door.test.ts` — the residual, on the dataset door × both strategies × four provider tiers (no provider, admin-unrestricted, non-admin field list, reader answers `undefined` for the object): refused `403 PERMISSION_DENIED`, the strategy/driver never called; a plain-column inline dataset and a dataset registered through the configuration door still answered. - `field-read-admission-gate.test.ts` — reverted to `main` (objectstack-ai#20965's pin stays). - `packages/rest/src/analytics-16019-driver-declared-fault.test.ts` — its first block drove a driver fault through exactly the inline-dataset `field` expression path this change closes, so it now pins the door refusal (`403 PERMISSION_DENIED`) plus a positive control. It also pins the route's saved branch end to end: a saved dataset reached through `body.datasetName` whose `field` is not a column reference is refused `403 PERMISSION_DENIED` with the driver never called, and a saved plain-column dataset is answered `200`. The objectstack-ai#16019 relay stays covered by its second block and by `driver-sql`'s own `sql-driver-16019-raw-statement-fault-envelope.test.ts`. - Ablation: disabling the new seam on disk turns the residual pins red (the three tiers the field gate stands down in, and the saved-branch pin, which is then served `200`) while the controls stay green; restored. The full `@objectstack/service-analytics` suite is green, as is the non-SQL temporal step under a skewed process zone. ## Review Security boundary. The maintainer has authorized landing; the first merge-queue build failed on the semantic overlap with objectstack-ai#21156 described above, and this push carries the narrowing. Its draft/ready state is unchanged by this push, and it is not queued or armed for auto-merge here — the owning seat re-enqueues once CI is green on this head. ## Thread receipts (round 5) - **Docs Drift Check** (`5934340689`): computed on the previous head `5428f7e627`; both release pages it lists were reached through a string literal in `assertQueryMembersJudgeable`, which this push deletes. Release pages are read-only here and none is edited. - **Landing note** (`5934790041`): the maintainer's landing authorization on objectstack-ai#21177 is recorded there. The ready state and auto-merge it describes belong to the owning seat; this push does not change either. - **Merge-queue triage** (`5935112816`, run `36885164397`): triaged as case 1 — every failing test is in `@objectstack/service-analytics`, which this PR changes, and each is the semantic overlap with objectstack-ai#21156 (an expected `INVALID_FIELD` / 400 met `main`'s `PERMISSION_DENIED` / 403). This push resolves both failing files: `caller-content-admission-door.test.ts` is removed with the duplicate `/analytics/query` handling, and `field-read-admission-gate.test.ts` is back to `main`'s pin. The full `@objectstack/service-analytics` suite and the non-SQL temporal step (the two failing jobs' steps) pass locally on this head. <sub>read 2026-10-01T17:23Z.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21156
Clause-②: no
Summary
An analytics query path could carry caller-supplied member text into the native statement, in a tier the field gate does not judge. A member the caller names — in
dimensions,timeDimensions, awhereleaf, or anorderkey — that is neither a declared cube member nor a column reference (a field, a relationship path, or'*') names nothing any field gate can attribute to a column. Where the field-level read gate (#20917/#20935/#20965) does not judge the queried object, that member reached the SQL strategy as written.This refuses such a member at the query door, in every tier, before a strategy compiles it —
PERMISSION_DENIED/ 403, the same refusal the field-level gate reaches where it judges the object (#20965'sfieldReadUnjudgeableError), so there is one judge, one shape, no new error code. The cube author's own declared members are untouched: a declared expression member keeps the read gate's verdict where it applies and the parse's (#20943) otherwise.The two tiers this closes (by class)
The field-level read gate judges a member only where a reader answers for its object. Two tiers are outside that:
undefinedfor — the gate stands down for that object.In both, a caller-named non-column member previously reached the native statement unjudged. The new gate is provider-independent: it asks only whether the member names a column or a member the cube's author declared, so it applies in every tier.
Shape of the fix
analytics-service.ts, beside where the existence gates andnamedQueryFieldsrun), so both doors (queryandgenerateSql) and both strategies inherit the verdict by construction.native-sql-strategy.tsis not touched.Pins and ablation
packages/services/service-analytics/src/__tests__/caller-member-column-reference-gate.test.tspins, in each tier (no security service; an object the reader answersundefinedfor) × each strategy × an ad-hoc and a registered cube × each query position: the member is refusedPERMISSION_DENIED/ 403 on both doors, with nothing executed. A judged object with a real column is the control (same refusal, one door earlier). Positive controls confirm a real column member and a bare count are served in every tier, and a non-regression pin confirms an author-declared expression member still reaches the strategy with no security service.Every refusal pin was ablated: with the door gate disabled on disk, the 32 tier pins turn red (the member reaches the strategy) while the control, positive and non-regression pins stay green; restored and re-run green. The ablation used the repo's on-disk-verified mutation tool.
Validation
pnpm --filter @objectstack/service-analytics typecheck— pass.pnpm --filter @objectstack/service-analytics test— 158 files, 3648 passed, 21 skipped, 0 failed (at31a582c9).scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 62 derived, 62 run, 61 pass;check:dual-build-cjs-loadsreports PREREQUISITE NOT MET (it reads a full-repo build this checkout did not produce) — left to CI, not a measured failure.Acceptance notes
answerDataset's in-memory branch) evaluates a selection over seed rows in memory and does not reach this door's gate. It compiles no native statement, so it is not a native-statement vector; it keeps the field-level read gate it already runs. Boundary noted, not extended.Patch round 1 (
130e8c0c): the measure position, and the censusAdded by the reviewing seat (
domain:servicesseat 2, #21118), from the dev's round-1 report. The dev writes the PR body once.The measure position. The door gate now also judges a caller-named measure. After the inference rule strips its suffix (the no-suffix default included), it must reduce to
count/'*'or to a column reference (a bare identifier or a relationship path). Otherwise it is refused with the samefieldReadUnjudgeableError(PERMISSION_DENIED/ 403): one judge, no new error code.measures上的关系穿越点号 member 仍被剥成基表列 ——owner.region_count_distinct静默聚合基表region(#5739 裁决未覆盖的第四个铸造点) #5918's refusal, which fires ahead of this gate.native-sql-strategy.tsis still untouched.Census: every caller-supplied slot of an analytics query that could reach a statement.
cubemeasures[]dimensions[]timeDimensions[].dimensiontimeDimensions[].granularitytimeDimensions[].dateRange400whereleavesorderkeyslimit/offsettimezonesegments/havingEvidence for this round:
check:dual-build-cjs-loadsincluded.Generated by Claude Code