Repository navigation
Commit 5f6b63a
Fixes #20933
Clause-②: no (narrowing)
## What this changes
The analytics door asks two questions over one object set before either
strategy runs: the object-level read admission, and the read scope each
strategy applies to the objects it reads. That set held the cube's base
object and the joins the cube declares (`joins`, or a dataset's
`include`). An object a query reaches through a relationship path the
cube does not declare was not in it, although both strategies read that
object. The class: **a related object reached through a relationship
path was read without its admission or its row scope** on the native-SQL
strategy, and was not asked for at the door on the ObjectQL strategy.
Every such object now joins the one set
(`AnalyticsService.queryObjects`). It is admitted and row-scoped exactly
as the base object and a declared join are, through the same mechanism,
on both strategies and on every analytics face (the cube read, the SQL
echo and the dataset door). There is no per-strategy copy of the rule
and no scope applied only inside the native join: the strategies apply
the scope of every object they read from the set they are handed.
Three source files change, all in `@objectstack/service-analytics`:
- `analytics-service.ts`: `queryObjects` adds, per hop, the object each
member the query names reaches through a relationship path. The hop's
object is read from `namedQueryFields`, the field gate's own resolution
(PR #20931), reused rather than re-derived. `callCtx` derives the set
once and hands the same value to the admission and to the read-scope
pre-pass, and passes it to the strategy as `readScopedObjects`.
- `strategies/native-sql-strategy.ts`: the cross-field decline (a read
scope carrying a field reference routes the query to the engine path)
reads the scopes over the door's set, rather than re-deriving base plus
declared joins.
- `strategies/types.ts`: declares `readScopedObjects` on the
package-internal `DatasetScopedStrategyContext`. It is not exported: the
built declaration file carries no hit for it.
## Per position (disclosure-safe: classes, no request shapes)
Reference: the ObjectQL strategy's answer, and the same question asked
through a declared join to the same object.
| Position | Strategy | Before (class) | After |
|---|---|---|---|
| Inferred cube: a dimension through a relationship path | native |
related object read with no admission and no row scope | unreadable
related object: `403 PERMISSION_DENIED` naming it, before any statement
runs; related rows outside the caller's scope are not read |
| | ObjectQL | refused by the engine's own admission (its generic
refusal); the door never asked | the door's refusal naming the object
(same code and status, differs in form); scope unchanged (rows outside
it grouped as restricted) |
| Inferred cube: a filter member through a path | native | filtered on
related values with no admission and no row scope | refused as above; a
related value outside the scope matches nothing |
| | ObjectQL | `400 INVALID_FIELD` (the strategy cannot filter across
objects) | unreadable related object: the door's `403`; otherwise the
same `400` |
| Inferred cube: a time-dimension window through a path | native |
windowed on related values with no row scope | refused, or scoped, as
above |
| | ObjectQL | `400 INVALID_FIELD` | unreadable related object: the
door's `403`; otherwise the same `400` |
| Authored cube: a dimension or measure over a relationship its `joins`
does not list | native | read with no admission and no row scope |
refused, or scoped, as a declared join is |
| | ObjectQL | engine refusal / engine scope | the door's refusal; scope
unchanged |
| Authored cube: a path the query names itself | both | as the two rows
above | as the two rows above |
| Two-hop path (first hop falls back to its alias, second hop keyed by
the cube's join) | native | the first hop's object read with no
admission and no row scope | each hop admitted and scoped on its own
object |
| | ObjectQL | `400 INVALID_FIELD` (single hop only) | an unreadable
hop: the door's `403`; otherwise the same `400` |
| SQL echo of any row above | both | statement echoed with no admission
of the related object | refused as the read is; on the native strategy
the echoed statement carries the related object's scope clause, as a
declared join's does |
| Dataset door: a related object named through a relationship the
dataset does not declare | native | `400 DATASET_INVALID` (the native
compile's own refusal) | unreadable related object: the door's `403`;
otherwise the same `400` |
| | ObjectQL | engine refusal | the door's refusal |
| Control: a readable related object | both | answered | answered,
within the caller's row scope |
**E4, native "after" against the reference.** The native answer now
equals the native answer for the same question through a declared join,
exactly: the same refusal envelope, and the same scope clause on the
joined object. Against the ObjectQL reference it **differs in form** on
out-of-scope related rows only. Native applies the joined object's scope
as a predicate over the join (ADR-0021 D-C), so a base row whose related
record is outside the caller's scope drops out of the answer. ObjectQL
groups such a row as restricted. Neither reads the related value. This
is the pre-existing declared-join form on each strategy, and this PR
does not change it.
## Mechanism readings (E1 to E3)
- **E1, the set** (`analytics-service.ts` at HEAD: admission `:1449`,
`queryObjects` `:1575`, `cubeObjects` `:1594`, `resolveReadScopes`
`:1683`). At base `1571aedce`, `queryObjects` returned
`cubeObjects(cube)`: the base object plus every declared join.
- An inferred cube's relationship path: the base object only. The
admission asked for one object, and the scope pre-pass resolved one
object.
- An authored cube's declared join: base plus the join's object.
- A dataset's `include`: base plus each included object, since the
compiled dataset carries it as a declared join.
- After the fix, each case also carries every hop's object. Admission
and the scope pre-pass read the same value, and a unit pin asserts that
the two sets are equal.
- **E2, the native join.** `qualifyAndRegisterJoin`
(`native-sql-strategy.ts:740`) registers one join per path prefix,
aliased by the path with dots as `__`. The build loop (`:617`–`:624`)
already applied the read scope for the base object and for every
registered join, whether synthesized or declared, through
`applyReadScope`. It reads `getReadScope` for that join's object: the
same mechanism a declared join uses.
- Before the fix, the pre-resolved scope map had no entry for a path
object, so `getReadScope` answered nothing and no predicate was applied.
- The fix changes the set and leaves the application path alone. The one
strategy line that re-derived the object list, the cross-field decline
(`:340`), now reads the door's set.
- **E3, the object a path reads.** Hop by hop, through
`namedQueryFields` (PR #20931's resolution): the cube's join keyed by
the path with dots as `__`, falling back to the alias itself. Pinned on
a two-hop path whose first hop falls back and whose second is keyed: the
admitted set is exactly base, first hop and second hop. The route pin
serves the two-hop case on the native strategy. The ObjectQL strategy
serves a single hop only.
- **E5, containment.** The fix lands within this card on both
strategies, so routing restricted callers through the ObjectQL strategy
was not needed and is not proposed.
- **E6, `Clause-②`.** Measured in both directions.
- Widening: none.
- The public type surface is unchanged: `readScopedObjects` has 0 hits
in the built `dist/index.d.ts`, against 19 for `StrategyContext` as a
positive control, and the context type is not exported.
- No probed position moved from refused to answered.
- The native decline set is a superset of what it was: the same base and
declared-join derivation, plus the path objects.
- Narrowing: yes. Positions move from answered-unadmitted to `403`, and
from unscoped to scoped.
- Line 2 reads `declared · no · narrowing` through
`scripts/pm/clause2-line.mjs`. The changeset is `minor`, carries the
BREAKING banner, and carries its ADR-0087 marker (`not-required
(no-migration-prescription)`).
## Pins, red and green, and ablation
- **Unit pin**
`packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts`
(27 cases), on both strategies. It covers the seven positions above, and
for each one checks three things:
- an unreadable related object is refused by name, on the read and on
the echo, before anything runs;
- admission and the scope pre-pass ask for one set, and that set carries
the path's object;
- the related object's scope reaches what each strategy executes.
It also carries the two-hop resolution, the native decline, and the
control.
- **Route pin**
`packages/rest/src/analytics-relationship-path-admission.test.ts` (28
cases). It runs over the shipped composition with the real
`SecurityPlugin`, `ObjectQL` and `SqlDriver` (SQLite), once per
strategy, and every answer is compared with the same question through a
declared join. It covers:
- an unreadable related object is refused;
- related rows outside the caller's scope are not read;
- a filter on an out-of-scope related value counts nothing;
- the control;
- the dataset door.
- **Red before the fix.** Pins were committed at `9c12bad1c`, before the
fix at `b48c42e1a`, and pushed only together with it. On the pins tree:
unit 25 failed, 2 passed (the controls); route 20 failed, 8 passed (the
fixture checks, the controls, and the ObjectQL positions that were
already right). Ablation 1 below reproduces exactly that red set from
the committed HEAD.
- **Green at HEAD `fdbdc670d`.** Unit 27/27, route 28/28, read after a
rebuild with the marker present in `dist/`.
- **Ablation 1, the widened set.** The path-object loop in
`queryObjects` was deleted through `scripts/ablation-replace.mjs`, which
confirmed the anchor went from 1 to 0 and the blob changed. The package
was rebuilt, and `ablation-dist-preflight --absent` confirmed the marker
was gone from `dist/`.
- Result: unit 25 failed / 2 passed, route 20 failed / 8 passed.
Predicted in writing beforehand, and exactly the pre-fix red set.
- Restore: the blob equals HEAD (`7e80788d9873`), `git diff HEAD` is
empty, and porcelain is empty, re-proved by an outer trap by absolute
path. After a rebuild the marker is present in `dist/` again, and the
pins read unit 27/27, route 28/28.
- **Ablation 2, the decline reads the door's set.** The strategy was
made to ignore `readScopedObjects`. Result: unit 1 failed / 26 passed,
exactly the decline pin, as predicted. Restore: the blob equals HEAD
(`bc6e15abfc96`), `git diff HEAD` is empty, and the unit pin reads 27/27
afterwards. The unit pin imports source, so no build was involved.
## Tests and gates (HEAD `fdbdc670d`)
- **Gate union, one locked sequential run on HEAD `fdbdc670dc`** (`git
rev-parse --short HEAD`, printed by the run at start and end with an
empty `git diff HEAD`). The run covered the 62 commands `dispatch-gates
--commands` derives from this diff: 61 exited 0, and 1 is NOT MEASURED.
- `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET. The
gate reads every workspace package's built output, and 34 packages have
no `dist/` in this worktree. For the one package this diff changes, a
direct `require()` of its CommonJS entry loads, with 17 exports. CI runs
the gate itself.
- `dispatch-gates --ran` reconciliation: 62 derived, 61 run, 1 NOT
MEASURED, 0 unrun (exit 0).
- **The four roster families named at dispatch** are not printed by this
diff's derivation. They were run anyway, and all exited 0: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`.
- **Package suites at HEAD `fdbdc670d`.**
- `@objectstack/service-analytics`: 147 files, 3401 tests passed.
- `@objectstack/rest` (`--project local`): 244 files, 4893 passed, 114
skipped.
- `@objectstack/client`: 50 files, 641 passed.
- **The client census.** `envelope-caller-census.test.ts` is 20/20.
Neither pin adds a censused call site: the census's own pattern has 0
hits in each pin, against 6 in a positive-control file. No ledger row is
needed.
- **Typecheck.** `@objectstack/service-analytics` and
`@objectstack/rest` (its test-layer typecheck included) exit 0. Both pin
files are in their package's typecheck program, counted with
`--listFiles`.
- **eslint, narrowed.** The whole-repo run is CI's. The 5 changed
TypeScript files give 0 errors and 0 warnings, and eslint's own JSON
output reports all 5 and none as ignored. The config enables no
type-aware linting, so this diff cannot move an untouched file's
verdict.
- **Build.** The dependency closure and `@objectstack/service-analytics`
at HEAD built with exit 0.
## Acceptance notes
- **Native and ObjectQL differ in form on out-of-scope related rows**
(E4 above). Native drops the base row, and ObjectQL groups it as
restricted. This is pre-existing on declared joins, and this PR leaves
it unchanged.
- **A relationship name that is not itself an object name** was never
served: a 500 on the native strategy, and an engine refusal or a `400`
on the ObjectQL strategy. For a caller the object-level check applies
to, it now answers the door's `403` naming that relationship. That is
the resolution PR #20931's field gate already uses. The changeset states
it.
- **The native decline's fallback.** The decline re-derives base plus
declared joins only for a strategy context built without the door's set.
The door always passes the set whenever it resolves read scopes, so no
production path reaches the fallback. NOT MEASURED by a pin.
- **The draft-preview branch** keeps using `cubeObjects` alone, because
it evaluates the executor's queries over the drafted base rows in
memory. NOT MEASURED by a pin here.
- **A cube whose `sql` is not a bare object name** names no attributable
field (`namedQueryFields`), so its set is unchanged by this PR.
- **Drivers.** The route pin runs on SQLite only. The set is computed at
the door, before any driver.
- **`main` moved.** `origin/main` is at `f6ccca4a4`, 11 commits past
this branch's base `1571aedce` (read just before this PR opened). None
of them touches this claim's surface, so the branch is not merged, per
the dispatch order. This PR's CI on the merge ref reads the merged tree.
- `dispatch-gates` flagged three of its derivation inputs as changed on
`main`: two ADR-anchor files for other packages, and the doc-authoring
prose-id baseline. This branch adds tracker ids only in code comments,
and the derived family list is unchanged.
- **CI** is not awaited. The CI-only lanes `dispatch-gates` names (the
test shards, dogfood, temporal conformance, build core, the workspace
type-check lanes and the wide-population families) are CI's.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83480c6 commit 5f6b63a
6 files changed
Lines changed: 737 additions & 26 deletions
File tree
- .changeset
- packages
- rest/src
- services/service-analytics/src
- __tests__
- strategies
Lines changed: 53 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
0 commit comments