Skip to content

fix(service-analytics): refuse a caller-named non-column member at the analytics query door in every tier (#21156) - #21173

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21156-analytics-member-unjudged
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21156-analytics-member-unjudged

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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, a where leaf, or an order key — 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's fieldReadUnjudgeableError), 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:

  1. No security service — no field reader is wired, so the gate is a no-op.
  2. An object the reader answers undefined for — 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

  • One check at the query door (analytics-service.ts, beside where the existence gates and namedQueryFields run), so both doors (query and generateSql) and both strategies inherit the verdict by construction. native-sql-strategy.ts is not touched.
  • The member is judged against the cube as it existed before ad-hoc inference — an inferred cube mints every caller member into itself, which would otherwise launder caller text into a "declared" member. A probe cube with the base object but no declared members stands in for the inferred case.
  • A dataset's own filter is author text and is deliberately not judged here; the existing gate judges it where it applies.
  • Runs after the cube/object-existence check, so a non-existent cube still answers 404 first.

Pins and ablation

packages/services/service-analytics/src/__tests__/caller-member-column-reference-gate.test.ts pins, in each tier (no security service; an object the reader answers undefined for) × each strategy × an ad-hoc and a registered cube × each query position: the member is refused PERMISSION_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 (at 31a582c9).
  • The dispatch-derived gate families (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 62 derived, 62 run, 61 pass; check:dual-build-cjs-loads reports PREREQUISITE NOT MET (it reads a full-repo build this checkout did not produce) — left to CI, not a measured failure.

Acceptance notes

  • The ADR-0037 draft-preview path (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 census

Added by the reviewing seat (domain:services seat 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 same fieldReadUnjudgeableError (PERMISSION_DENIED / 403): one judge, no new error code.

Census: every caller-supplied slot of an analytics query that could reach a statement.

Slot Verdict
cube already judged: it must name a registered object, and a non-bare name is refused
measures[] judged by this PR (round 1), plus the existing source-field existence check
dimensions[] judged by this PR, plus the existing existence check
timeDimensions[].dimension judged by this PR, plus the existing existence check
timeDimensions[].granularity not free text: a closed enum, and any other value is refused by the schema
timeDimensions[].dateRange not an identifier: a closed preset vocabulary, or explicit bounds bound as parameters. An unrecognised string is refused 400
where leaves the member is judged by this PR, plus the existing check; values are bound parameters
order keys the member is judged by this PR, plus the existing check; the direction is a closed enum
limit / offset numbers, not string slots
timezone not an identifier in the native statement: it resolves date ranges (parameterised comparands) and is forwarded to engine bucketing
segments / having not slots: the query schema is closed and declares neither

Evidence for this round:


Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 8 documentable anchor(s).

⛔ 2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class), generateSql (symbol, a method of class AnalyticsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2c1cef3345738b5f2486eed5b0608687dba36f11 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ff256647d9f2eb8878b785b93f0e488beba8482a — the merge of head 52d6bfac9e2ded1c7328a2d9432ab3e32a13d0cc into base 2c1cef3345738b5f2486eed5b0608687dba36f11, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2c1cef3345738b5f2486eed5b0608687dba36f11 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 14:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 1, 2026
claude added 2 commits October 1, 2026 14:20
…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
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 0b12b9e Oct 1, 2026
35 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21156-analytics-member-unjudged branch October 1, 2026 15:29
os-bill pushed a commit that referenced this pull request Oct 1, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants