Skip to content

fix(service-analytics): the field-level read gate refuses a cube member that names no field instead of standing down (#20965) - #21153

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20965-cube-expression-stand-down
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20965-cube-expression-stand-down

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20965

Clause-②: no

⚠️ Security family, same disclosure discipline as the parent card: this body carries no request body, header, field spelling or returned value. Classes and positions only.

What changes

The analytics field-level read gate no longer stands down on a cube member that names no field. A member whose sql resolves to neither a field the gate judges nor the row wildcard '*' is now refused with the gate's own envelope, PERMISSION_DENIED / 403. The refusal comes before any strategy runs, on both the cube read and the SQL echo, and on both strategy paths. This is triage's correction 5921856508 as ruled: the gate fails closed, and such a member is never stood down and never passed.

  • analytics-service.ts, fieldsOfColumnSql: '*' names nothing (an empty list, as before). Anything that is neither a bare identifier nor an identifier path now answers "not a column reference" instead of an empty list. The deleted arm is the one documented as "Anything else is an EXPRESSION the cube's author wrote".
  • analytics-service.ts, namedQueryFields: such a member, or a declared member whose sql is not a string, is carried to the gate as a member that names no field, on the cube's base object. An undeclared measure still names nothing, because both strategies refuse a measure the cube does not carry.
  • field-read-admission.ts: assertNamedFieldsReadable refuses such a member ahead of that object's field verdicts, through a new constructor in the same envelope, whatever grants the caller holds. No new error code is added. The refusal names the member and the object. It never carries the member's sql, because echoing it would hand the cube author's text to a caller who was refused for not being judged able to read it.
  • Changeset: @objectstack/service-analytics patch.

Premise check (zone 2, measured at base c6954d6d)

  1. The stand-down's location. It was fieldsOfColumnSql (its doc arm at :421 at base; the card said about :409, the claim said :416). That is the field-level read gate's resolver. resolveMemberSource is not part of that gate. It belongs to the two source-field EXISTENCE gates, assertDimensionFields and assertWhereFields (INVALID_FIELD / 400, "does the object have this column"). Its source: null on an expression means "no column to check for existence", and it is left as it is. Those gates run first, in ensureCube, and stand down on an expression. The refusal then comes from the field gate in callCtx.
  2. How a stored cube reaches the gate. There is no metadata read path into the analytics registry. CubeRegistry has two writers: configuration cubes (AnalyticsServiceConfig.cubes, fed by AnalyticsServicePlugin({ cubes }), which the CLI's analyticsCubes capability arg builds) and compiled datasets. register never parses. So a cube configured before the parse refusal, or never put through it, reaches the gate as written. The fixture is built that way: the test file's authored cube, unparsed. A new pin asserts that CubeSchema refuses exactly its two expression members, plus one member with no sql string, and nothing else. A plugin-level pin passes the same cube through AnalyticsServicePlugin({ cubes }) with the security bridge.
  3. "The engine's 403 shape". It is reused from field-read-admission.ts: PERMISSION_DENIED, pinned against the standard catalog, with status 403 and the object property. No new code.
  4. Consumers of resolveMemberSource at base, by git grep: assertDimensionFields (one site) and assertWhereFields (two sites), plus doc mentions in where-source-field-gate.test.ts. None consumes the field gate's verdict. The field gate's collector namedQueryFields has two consumers. queryObjects (the object-admission and read-scope set) is unchanged, because a member that names no field adds only the base object, which is already in the set. assertFieldsReadable now refuses.

Measured before and after (unit probe through AnalyticsService.query, not committed)

At base, with a reader wired and answering, these positions were all served, and the native-SQL statement carried the member's sql as written: an expression member as a grouped dimension, an aggregated measure, a filter member and an order key. A member the query named itself that is not a column reference was served the same way, on a configured cube and on an inferred one. After the change, all of these are refused PERMISSION_DENIED / 403 on both strategy paths, with nothing executed. With no reader, the behaviour is unchanged (see Acceptance notes).

Pins

  • Deleted: the expression half of field-read-admission-gate.test.ts › "stands down where no field can be named: an authored expression member, and an object the reader has no answer for". It held the stand-down. The other half, an object the reader has no answer for, is kept, retitled "stands down for an object the reader has no answer for".
  • Added (field-read-admission-gate.test.ts, block "a member that names no field is refused, never stood down"):
    • the fixture is written around the parse (CubeSchema refuses exactly the members the gate refuses);
    • refusal, for each strategy path and on both doors (cube read and SQL echo): an aggregated expression measure, a grouped expression dimension, a filter member, an order key, a declared member with no sql string, and a member the query names itself that is not a column reference. Each asserts code, status, object and member, that nothing executed, and that the message carries neither of the author's expression texts;
    • no grant makes it judgeable: a reader answering every field still refuses it;
    • it is refused ahead of a hidden field on the same object;
    • every field member is judged as before, on the same cube;
    • '*' still counts (the control): a count beside a field member is served, and a count alone is served with the reader never asked;
    • plugin bridge: a configured cube's expression member is refused through the security service's reader.
  • The existing field-gate table (14 refusal cases × 2 strategies × 2 doors) and the rest of the file are unchanged and green.

Ablations (each from committed head f6f10470, through scripts/ablation-replace.mjs: anchor hit once, blob changed, restored blob equal to HEAD, git diff HEAD empty)

The test file imports the service by relative path (../analytics-service.js), so the ablated source is what runs and no dist/ is involved.

leg mutation predicted measured
A1 the stand-down put back: fieldsOfColumnSql answers an empty list for a non-column sql 15 red / 55 green 15 failed / 55 passed. Every refusal row except the no-sql one, no-grant, ahead-of, and the plugin pin. Failure shape: expected null to match object { code: 'PERMISSION_DENIED', …(3) }, and promise resolved … instead of rejecting
A2 a declared member with a non-string sql names nothing 2 red 2 failed / 68 passed. The no-sql row, where the strategy then crashed with a TypeError
A3 the gate's refusal statement removed (field-read-admission.ts) 17 red 17 failed / 53 passed
A4 the '*' arm removed, so the wildcard reads as an expression the control turns red, along with every count under an answering reader 47 failed / 23 passed, both '*' control cases among them

Restored state: 70 / 70 green.

Tests (final head f6f10470)

  • pnpm --filter @objectstack/service-analytics test: 156 files, 3560 passed, 21 skipped. At base, with only the source change, it was 2 failed (the stand-down pin, one row per strategy) and 3536 passed.
  • pnpm --filter @objectstack/service-analytics typecheck: exit 0, and --listFiles includes the edited test file.
  • Route level, over the real security plugin, engine and SQL driver: @objectstack/rest analytics-field-permission-gate, analytics-masked-field-gate, analytics-relationship-path-admission and analytics-hop-object-reference gave 4 files, 60 passed, against the rebuilt service-analytics dist.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 62 commands. All 62 were run, and --ran reconciled them as "62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN". 61 exited 0. The other one, pnpm check:dual-build-cjs-loads, exited 3: PREREQUISITE NOT MET, because it needs every package's dist/ and this worktree built only the analytics closure. It is NOT MEASURED and declared to CI.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 3 changed lintable files gave 3 files, 0 errors and 0 warnings at f6f10470.
    • The population comes from eslint.config.mjs's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} block; the fourth changed file is the .md changeset.
    • The count comes from the JSON output.
    • Invariance: the config never enables type-aware linting (every parserOptions is ecmaVersion and sourceType only, and no typed rules are on), so this diff cannot move a verdict on an untouched file. The whole-repo pnpm lint is CI's.

Acceptance notes

  • File surface, declared. field-read-admission.ts is outside the claim's named file, in the same package and directory. It is the module that holds the gate's refusal envelope and the per-object tiers, which zone 2 item 3 pointed to. The refusal has to sit inside those tiers (next note), so it lands there. None of the three files held by the seat-1 PR is touched.
  • The tier the refusal respects. The refusal fires where the gate judges the member's object, that is, where a reader gives a field answer for it. With no security service (the plugin's reader answers "no answer") or for an object the reader cannot resolve, no member of that object is judged, field or not. That is the gate's existing tier. Refusing there would put a permission refusal on a deployment that has no permissions. Raised to the seat as an open question in the report; no pin holds either reading for that tier.
  • Caller-named members. The rule covers every member the gate sees, so a member the query names itself that is not a column reference is now refused too, wherever the reader answers. At base it reached the native-SQL statement as written. The remaining no-reader case of that shape is reported to the seat, not fixed here.
  • Left as they are: the existence gates' stand-down (above); and a cube whose own base sql is not a bare object name, which names no attributable field (a cube-level shape this card does not cover).
  • Spec doc. analytics.zod.ts's note that the runtime's expression branches "are left as they are here" stays true of that PR. This PR deletes the gate's half. The raw-SQL emit half lives in strategies/native-sql-strategy.ts, which is held elsewhere, and it is not touched here.
  • Landing. A security-family change, left draft for the seat's review.

Generated by Claude Code

claude added 3 commits October 1, 2026 09:32
…er that names no field

A cube member whose sql is neither a column reference nor the row
wildcard names no field the analytics field-level read gate can judge.
The gate used to stand down on such a member and let the query run; it
now refuses it with the gate's PERMISSION_DENIED / 403 envelope, ahead
of the field verdicts on its object, wherever the gate judges that
object. The wildcard still names nothing and is not refused.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…t names no field

The case that pinned the stand-down loses its expression half; its other
half (an object the reader has no answer for) stays. New pins: each
position an expression member can take, a declared member with no sql
string and a member the query names itself are refused PERMISSION_DENIED
/ 403 on both doors and both strategy paths before anything runs; no
grant makes such a member judgeable; field members are judged as before;
the row wildcard is the control; and the plugin's security bridge
refuses a configured cube's expression member. The fixture is written
around the parse, which refuses exactly the refused members.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…l of a member that names no field

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@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).

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

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

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
  • 3 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 e952cff578cc936daab3241570c4c2c36804bbd6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0a2352b5d8f961471f9831c6f716d0f78093a789 — the merge of head f6f10470b3929a9033e2ca8af54c7940e1781a19 into base e952cff578cc936daab3241570c4c2c36804bbd6, 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 0a2352b5d8f961471f9831c6f716d0f78093a789 && git checkout 0a2352b5d8f961471f9831c6f716d0f78093a789
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e952cff578cc936daab3241570c4c2c36804bbd6 f6f10470b3929a9033e2ca8af54c7940e1781a19 && git checkout -B drift-repro e952cff578cc936daab3241570c4c2c36804bbd6 && git merge --no-ff f6f10470b3929a9033e2ca8af54c7940e1781a19

node scripts/docs-audit/affected-docs.mjs --json e952cff578cc936daab3241570c4c2c36804bbd6

⚠️ 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 e952cff578cc936daab3241570c4c2c36804bbd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 10:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit ae1e950 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20965-cube-expression-stand-down branch October 1, 2026 11:04
os-bill pushed a commit that referenced this pull request Oct 1, 2026
…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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…e analytics query door in every tier (objectstack-ai#21156) (objectstack-ai#21173)

Fixes objectstack-ai#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 (objectstack-ai#20917/objectstack-ai#20935/objectstack-ai#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 (objectstack-ai#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 (objectstack-ai#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, objectstack-ai#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.
- The judged tier already refused it: the minted measure reads as a
declared expression member, and objectstack-ai#21153's gate catches it.
- The ungated tiers did not refuse it. This round closes them.
- Author-declared measures are untouched. A dotted non-qualifier measure
keeps objectstack-ai#5918's refusal, which fires ahead of this gate.
- `native-sql-strategy.ts` is still untouched.

**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:**
- Measure pins in each tier × strategy × ad-hoc and registered cube ×
the suffixed and the no-suffix forms: refused on both doors, with
nothing executed. Positive controls, and an author-declared
expression-measure non-regression pin.
- The ablation of the measure refusal turns exactly the 16 ungated-tier
measure pins red, while the judged-tier pins stay green (objectstack-ai#21153).
- The package suite has 3679 passed. All 62 derived gate families pass,
`check:dual-build-cjs-loads` included.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

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