Repository navigation
fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) - #20931
Conversation
…cs door (#20917) Every member an analytics query names, judged against the caller's field-level read permissions before either strategy runs, answering the engine's own refusal: the cube read and the SQL echo over the inferred and an authored cube, and the dataset door, on both strategies, with the real SecurityPlugin, ObjectQL and SqlDriver; plus the service-level gate and the plugin's bridge to the security service. A readable member is the control. Committed ahead of the change that satisfies them. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…oor, before either strategy (#20917) The analytics admission step now resolves every member a query names -- dimensions, measures, time dimensions, where members, order keys, joined members, and a compiled dataset's own and its requested measures' filters -- to the field it reads, and judges each against the caller's readable fields before a strategy is selected. A member the caller may not read answers PERMISSION_DENIED / 403 in the engine's words. The native-SQL strategy held no field permissions and served such members; it now inherits the verdict by construction, as does the SQL echo and any strategy added later. The permission rule stays the security service's: the plugin bridges the new getReadableFields hook to its getReadableFields reader, and the analytics layer contributes only the member-to-field resolution. A host read scope is policy and is not judged, as the engine's own field guard does not judge it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ce's; changeset (#20917) The plugin no longer offers its own option for the field-level reader: it always bridges AnalyticsServiceConfig.getReadableFields to the security service, and a host composing its own reader constructs AnalyticsService with it. The changeset records the narrowing, the new optional service hook, and the ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 324831b54be9dce95f9535222950dab3c2b8599a && git checkout 324831b54be9dce95f9535222950dab3c2b8599a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aaad682dbcb36bd00635a40530aca826062b9903 992a592dbbeaf200e16fd48051546f1d981b6295 && git checkout -B drift-repro aaad682dbcb36bd00635a40530aca826062b9903 && git merge --no-ff 992a592dbbeaf200e16fd48051546f1d981b6295
node scripts/docs-audit/affected-docs.mjs --json aaad682dbcb36bd00635a40530aca826062b9903
|
Contract reviewServed-tier: Inputs: card #20917 (body, triage ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
main's #20931 (the field-read admission gate), #20955 (the queryable-field gate), #20954 (plugin-security's comparand guard) and #20962 (relationship path objects in the admitted and scoped set) touched packages/services/service-analytics. The merge is clean at the text level; both sides' additions to analytics-service.ts and native-sql-strategy.ts are kept whole. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
… cross-field comparand exactly as it is as a filter key (objectstack-ai#20932) (objectstack-ai#20954) Fixes objectstack-ai#20932 Clause-②: no (narrowing) ## What this changes The security layer's field predicate guard refuses a query that filters, sorts, groups or aggregates by a field the caller's field-level permissions hide (`403 PERMISSION_DENIED`), because which rows answer would disclose the value the field mask withholds. It collected the fields a condition names from the condition's **keys** only, so a hidden field named only as a **cross-field comparand** was never judged by that rule. `collectConditionFields` now also collects the field every cross-field comparand inside a field constraint names, into the same set, and the existing field rule judges it. One collection, one rule, no second guard. A hidden field as a comparand now answers the same refusal (code, status and body) as the same hidden field written as a key. Dispatched by the `domain:services` seat (seat post objectstack-ai#6021), session `session_01XY5uCwTjZj7884yYtyur4H`; claim comment 5919655256, triage direction 5919556782. ## Per position (disclosure-safe: classes, no request shapes) | Comparand position the filter grammar admits | Door | Before (class) | After | |---|---|---|---| | The whole comparand of a scalar comparison (each of `$eq` `$ne` `$gt` `$gte` `$lt` `$lte`) | data read | served: the comparison was answered | `403 PERMISSION_DENIED`, body equal to the key form's | | Under a logical group (`$and`, `$or`, `$not`, and nested groups) | data read | served | same refusal as the key form | | The whole-day offset wrapper: its base reference | data read | served | same refusal as the key form | | The whole-day offset wrapper: its offset column | data read | served | same refusal as the key form | | The query condition of a grouped query (plain, and under `$or`) | aggregate path | served | same refusal as the key form | | A per-aggregation filter | aggregate path | served | same refusal as the key form | | `having` | guard (unit pin) | not collected | collected, same refusal as the key form at the guard; not pinned through a route (see Acceptance notes) | | A member of a list operator, an endpoint of a range operator | none | not a comparand position: the grammar refuses a reference there | unchanged, not pinned | A comparand naming a field the caller may read is served as before (the control, in every position above). ## Mechanism readings - **D1, the collector and its callers.** `collectConditionFields` is called by itself (logical groups) and by `collectQueryFields` (`where`, `having`, each aggregation's `filter`). `collectQueryFields` is called only by `assertReadableQueryFields`, which has one call site: the security plugin's engine middleware, for every verb that carries a predicate (the read, `findOne`, `count` and `aggregate` on the caller's query; bulk `update` / `delete` on the caller's own condition). The package index re-exports all three; no in-repo consumer outside tests. So the aggregate path is not a separate caller: it gets the widened collection through the same call. - **D2, the comparand reader.** `FieldReferenceSchema` from `@objectstack/spec/data`, the grammar's exported declaration of a cross-field reference (including the whole-day offset's own nested reference). No function that yields the referenced field is exported: the filter module's and the driver readers' reference predicates are module-private. So each node of a field constraint is asked of the schema itself (`safeParse`), the same reading `objectql`'s having filter already takes. The walk carries no operator list and no position list: it visits every node of the constraint, so a position the grammar admits is covered without being named. `packages/spec/src`, `packages/objectql/src` and `service-analytics` are untouched. - **D3, positions.** The grammar admits a reference as the whole comparand of the six scalar comparisons, with or without the whole-day offset (whose offset may itself be a reference), under any logical nesting, in each clause that carries a condition. It refuses one as a list member or a range endpoint. Every admitted position is pinned. - **D4, the refusal.** The key form is the reference: the route pins read it first, per hidden field and per path, and require the comparand form's status and whole body to equal it; the unit pins require equal `code`, `status`, message and details. - **D6, `Clause-②`.** Widening measured absent: no export added or removed (the package index and manifest are untouched, the new helper is module-private), and the collected set only grows, so nothing refused before is served now. Narrowing measured present: on the pre-fix source, all 15 route refusal pins answered 200 where the fix answers 403. Line 2 read by `scripts/pm/clause2-line.mjs` (`readClause2Line`): declared, value `no`, arm `narrowing`. The changeset is `minor` with the BREAKING banner and its ADR-0087 marker. ## Pins, red/green and ablation Commits: the pins (`9e602fe84`, red on that commit by design), the fix (`3b34899fc`), the changeset wording (`215bc699b`, HEAD). Pushed together only after the fix was committed. - `packages/plugins/plugin-security/src/predicate-guard.test.ts`: the collection and the verdict, per position, with the key form as the reference and a readable-comparand control. - `packages/rest/src/data-field-comparand-permission.test.ts`: the real `SecurityPlugin` on a real `ObjectQL` over a real `SqlDriver`, through the data query route, on the data read and the aggregate path, each refusal compared with the key form's, each position with its readable control. All readings below were taken on HEAD `215bc699b`. - **Red/green on the committed tree** (`predicate-guard.ts` restored from the pins commit, pins at HEAD, package rebuilt and the built output proven to carry no comparand collection): unit 23 failed / 12 passed (35), route 15 failed / 15 passed (30). Every refusal pin red, every control green. Green leg (restored from HEAD by absolute path, blob equal to the HEAD blob, `git diff HEAD` 0 bytes, rebuilt, fix proven present in the built output): unit 35/35, route 30/30. - **Ablation of the widened collection** (the collector's call replaced by a no-op reference so the package still compiles; the mutation proven on disk by anchor count 1 to 0 and blob change; rebuilt; built output proven without the call): predicted unit 23 failed / 12 passed and route 15 failed / 15 passed; observed exactly that. Restored: blob equal to the HEAD blob, `git diff HEAD` empty, rebuilt, the call present in the built output again. A first attempt that deleted the call outright did not build (the helper became unused), so the suite would have read a stale build; it was discarded and is not counted. ## Tests and gates - `pnpm --filter @objectstack/plugin-security test`: 149 files passed; 3252 passed, 23 skipped. Exit 0. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (source, scripts, and the test layer under `tsconfig.test.json`, 0 debt entries). - `pnpm --filter @objectstack/rest test`: 244 files passed; 4895 passed, 114 skipped. Exit 0. - `pnpm --filter @objectstack/rest typecheck`: exit 0; the new route pin is in the program. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `215bc699b` derives 64 commands; those plus the 4 roster families named at dispatch (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) were run one at a time, each exit code captured before any pipe: 68 of 68 exit 0. A full build (72 tasks) preceded the gates that read built output. Reconciliation: `✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. - Lint, narrowed and proven: the population is the three changed TypeScript files (eslint's own config applies to each; it ignores the changeset); `eslint --no-inline-config --format json` reports 3 files, 0 errors, 0 warnings; this repo's config enables no type-aware linting, so the diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's. - The client envelope-caller census needs no row: the pins call no censused client method. ## Acceptance notes - **`having`**: a comparand there is collected and pinned at the guard (unit); it is NOT MEASURED through a route. - **`findOne`, `count`, bulk `update` / `delete`**: covered by construction (the guard's single call site serves every verb that carries a predicate) and by the collector pins; not pinned per verb in the committed suite. - **Analytics**: inherits the fix through the engine path; not touched and not pinned here. Its own gate is objectstack-ai#20917's (PR objectstack-ai#20931). - **A comparand inside a nested-relation condition** is collected by the same walk and judged against the queried object's own field permissions (the refusing direction). NOT MEASURED. - **Drivers**: the route pin runs on SQLite only; the guard runs before any driver. - **`main` moved**: `origin/main` is at `013f97df9`, 8 commits past this branch's base `1571aedce` (read just before this PR opened). None 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); the derived family list is unchanged. - **CI**: not awaited; the CI-only lanes `dispatch-gates` names (the test shards, temporal conformance, dogfood, build core, the workspace type-check lanes, the artifact-roster and wide-population families) are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…oins the one admitted and scoped object set (objectstack-ai#20933) (objectstack-ai#20962) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
… answer on every analytics face — the related object read as the caller, capped (objectstack-ai#20887) (objectstack-ai#20916) Fixes objectstack-ai#20887 Clause-②: yes (narrowing) The analytics half of ruling 5907789183, whose parent card is objectstack-ai#20802 (its engine half landed as objectstack-ai#20872, `ca5408c62`). The nested-relation filter `{ relation: { field: value } }` now gets ONE answer on every analytics face, and it is the engine's: the related object read as the caller (its row scope and field permissions), capped at `RELATION_FILTER_ID_CAP`, a multi-valued relation matching on any member. The analytics layer holds no copy of that rule. The native-SQL strategy declines a query carrying the form, and the engine-aggregate strategy hands the form to the engine as written. ## Per face The engine's answer for the same filter, computed in the same test over the same rows, is the reference for every cell. Fixture: a ledger with `owner` (lookup) and `owners` (multiple lookup) to an owner object; the member cannot read `owner.secret`, and its row scope hides owners in region `HIDDEN`. Past the cap means 1,001 matching owners. Measured with the real `SecurityPlugin`, `ObjectQL` and `SqlDriver` (SQLite). | face | strategy | engine rows equal | caller permissions | cap | |---|---|---|---|---| | cube read, `POST /api/v1/analytics/query` (`AnalyticsService.query`) | NativeSQL composition (declines, so the engine answers) | yes: single, multi, related row scope, `$not`, `$or` (was 500 `DATABASE_ERROR`: the join named a table `owner` that does not exist) | 403 `PERMISSION_DENIED`, as the engine (was 500) | 400 `INVALID_FILTER`, as the engine (was 500) | | cube read | ObjectQL | yes (was 400 `INVALID_FIELD`, "cannot evaluate a cross-object filter") | 403 (was 400) | 400 (was 400 cross-object) | | dataset door, `POST /api/v1/analytics/dataset/query`, the dataset `include`s `owner` | NativeSQL composition | yes (was: single-valued rows via the JOIN; multi-valued 400 `DATASET_INVALID`; under `$not` the member got `b` where the engine answers `b, d`) | 403 (was **200 with rows a, c**: filtered by a field the caller cannot read) | 400 (was 200 with no rows) | | dataset door | ObjectQL | yes (was 400) | 403 (was 400) | 400 (was 400) | | a measure's own `filter` carrying the form | both | refused 400 `INVALID_FILTER`, as the engine refuses it at an aggregation's `filter` (was: native counted it through the JOIN; ObjectQL 400 `INVALID_FIELD`) | n/a | n/a | | SQL echo, `POST /api/v1/analytics/sql` | both | refused 400 `INVALID_FILTER`, naming the served route (was: native printed the JOIN; ObjectQL 400) | n/a | n/a | | a read scope carrying the form (a host `getReadScope`) | NativeSQL | refused 500 `READ_SCOPE_COMPILE_FAILED`, policy withheld, words now naming the route (outcome unchanged) | n/a | n/a | | a read scope carrying the form | ObjectQL | served, the engine's rows (unchanged: the scope reaches the engine as written) | as the caller | the engine's | ## Mechanism assumptions, measured - **B1 held.** The engine's answer for the fixture, as the member: `{ owner: { region: 'NA' } }` is d1, d3; the multi-valued form is d1, d3; `{ owner: { secret: 's1' } }` is 403 `PERMISSION_DENIED` naming `secret` (a system caller gets d1, d3); region `HIDDEN` gives no rows (a system caller gets d4); past the cap is 400 `INVALID_FILTER` for both spellings; `$not` gives d2, d4; the `$or` gives d1, d2, d3; `{ owner: {} }` and a second level are 400. `RELATION_FILTER_ID_CAP` is exported (`packages/objectql/src/index.ts:148`), and nothing here imports it: the analytics layer never counts ids, the engine does. - **B2: no on every axis**, per the table. The native path joined the related table itself: the related row scope rode in as a `WHERE` conjunct, the field permissions did not, nothing bounded the match, and a multi-valued relation or an undeclared join failed. The ObjectQL path refused the form outright. - **B3: call the engine.** `@objectstack/objectql` exports only the cap. The lowering (`admitRelationCondition`, `lowerRelationSite`) is module-internal, and this package has `@objectstack/objectql` as a dev dependency only. The route that needs no export: the engine-aggregate strategy hands the condition to `engine.aggregate` through `executeAggregate`, with the caller's context. No export was needed, and there is no second permission rule. - **B4: the read scope keeps its refusal, and the words name the served route.** `compileScopedFilterToSql` is a synchronous string builder. It holds the caller's `ExecutionContext` for placeholders only, and no data engine, so its compile cannot run the inner read as the caller. Routing a read scope carrying the form to the engine instead was built and measured, then withdrawn: on a native-only host it traded the declared `READ_SCOPE_COMPILE_FAILED` (policy withheld) for a generic no-strategy fault (`packages/rest/src/analytics-read-scope-refusal-envelope.test.ts` went red). No in-repo producer emits the form in a scope: the RLS compiler refuses a relation traversal when it compiles the policy. On the ObjectQL path the scope reaches the engine as before, and the engine serves it as the caller. - **B5: `yes (narrowing)`.** Widening: the cube read (both strategies), the ObjectQL dataset door, a multi-valued relation and a dataset without the declared join on the native path, and a dataset's own `filter` on the ObjectQL path all now serve the form (they answered 500 or 400). Narrowing: on the native path the dataset door now refuses a condition on a related field the caller cannot read (was rows), a match past the cap (was an empty 200), and a measure filter carrying the form (was a count). The SQL echo refuses the form. And a query combining the form with something only the native strategy serves (a cross-object measure, a multi-hop dimension) is refused by the engine-aggregate path. `@objectstack/service-analytics` ships `minor` with the BREAKING banner and an ADR-0087 `not-required (no-migration-prescription)` disposition; `check-adr-0087-registration` and `check-changeset-no-major` pass. - **B6: no page to update.** No hand-written `content/docs/**` page states how the analytics read or the read scope treats the nested form. `data-engine.mdx`, and `query-syntax.mdx` (objectstack-ai#20906, which landed during this work), describe the engine only. ## What changed - `strategies/filter-normalizer.ts`: a nested-relation condition becomes a `relation` node carrying the condition as written. It is no longer flattened to the dotted member. `shieldNestedRelations` holds it out of the shared lowering, because under `$not` the lowering guarded the relation column, and this package's engine hand-off spells that guard `$ne: null`, which `driver-sql` refuses over a multi-valued JSON column. Measured: the multi-valued `$not` pin went red before the shield, and the engine guards what it lowers the condition to itself. `findNestedRelationCondition` is the routing detector. - `strategies/native-sql-strategy.ts`: `canHandle` declines when the `where`, the dataset's own `filter` or a requested measure's `filter` carries the form. This is the mechanism of the cross-field decline (maintainer ruling 2026-08-12, Q1 = B). Its compiler refuses a `relation` node bare, as routing drift. - `strategies/objectql-strategy.ts`: the condition goes to the engine as its own conjunct, under the key the author wrote. The display-SQL echo declines it. - `read-scope-sql.ts`: the nested-relation form's refusal has its own words, naming the route. An empty or mixed value object keeps the old words. - `analytics-service.ts`: the no-strategy error names the nested-relation decline. - The mixed-wrapper refusal no longer says a nested member "compiles to the dotted member". ## Pins, red first (`568727629`) - `packages/rest/src/analytics-nested-relation-filter.test.ts`: both compositions, the cube read and the dataset door through its route, against the engine's answer. It was red 10 of 10 on the base, and is 10 of 10 green now. - `packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts`: the native decline per producer, the ObjectQL hand-off as written, the compile backstop and the read-scope words. It was 7 red with 2 controls green on the base, and is 9 of 9 green now. ## Ablations, predicted before running, at `5bb764181` Each ablation mutated the committed file through `scripts/ablation-replace.mjs` (anchor hit once, blob moved), rebuilt `@objectstack/service-analytics`, and passed `ablation-dist-preflight` (the marker present in 2 built files). It then ran both pin files, plus `where-door-shared-lowering-seam.test.ts` in the unit run. The restore leg proved the blob equal to HEAD and `git diff HEAD` empty, rebuilt, and found the marker absent from all 6 built files. Every observed count equals its prediction. | ablation | face it guards | unit (27) | route pins (10) | |---|---|---|---| | A1 the native decline removed | cube read and dataset door, native | 4 red | 4 red (native: rows, refusals, measure filter, echo) | | A2 the hand-off drops the condition | both strategies' rows, permission, cap | 2 red | 6 red | | A3 the aggregate call forwards no caller context | caller permissions | 0 | 4 red: the member then saw `d` (a hidden owner's row), and the unreadable field answered rows | | A4 the read-scope route words | read scope, native | 1 red | 1 red | | A6 the lowering shield removed | multi-valued `$not` | 1 red | 2 red | | A7 the echo refusal removed | SQL echo | 0 | 2 red | A first round at `dca1af7cb` matched its own predictions too, including A5, the read-scope decline arm, which B4's correction removed from the code. ## Pins re-judged These pins recorded the flattening this change removes, so each was re-judged: - respelled to the dotted cube member where the pin was about the traversal: `filter-normalizer-not-null-safe`, `icontains-text-comparand-refusal`; - re-expected as a `relation` node where the pin was about acceptance: `where-equality-slot-list-refusal`, `where-face-arms-refusal`, `where-type-face-refusal`, `filter-normalizer-mixed-wrapper`'s pure-shape block; - replaced where the pin held the removed branch: mixed-wrapper row 6 and its `guardFieldEntry` recursion row (the engine refuses that inner wrapper, `INVALID_FILTER` / 400, measured), `where-door-shared-lowering-seam`, `infer-cube-relation-traversal`, `infer-cube-where-spelling-parity`, and `where-source-field-gate`, which now judges the relation field `owner` as a column of the queried object; - re-worded to the read-scope refusal's new words: `read-scope-sql`, `read-scope-not-null-safe`, `read-scope-undefined-comparand`, and `read-scope-refusal-envelope`, which gains row 17 because the nested-relation form now has a throw site of its own. ## Verification - `@objectstack/service-analytics`: `test` 146 files, 3334 passed; `typecheck` exit 0, with 146 of 146 test files in the tsc program (`--listFiles`). Both at `4d383dac0`, after merging `main`. - `@objectstack/rest`: the full `local` project, 239 files, 4662 passed and 106 skipped, at `5bb764181`. The merge brought no rest or analytics change. At `4d383dac0`, the new pin, the read-scope envelope pin and the engine half's permission pin: 3 files, 21 passed. `typecheck` passes, including the test layer (`check:test-typecheck` OK). - Consumer sweep, narrowed to the files that load this package: `@objectstack/runtime` `analytics-*` plus `cross-field-refusal-operand-withhold`, 5 files, 38 passed and 4 skipped; `@objectstack/client` `analytics-automation-json-erasure`, 7 passed. - Gates at `4d383dac0`: `dispatch-gates --commands` derived 62. All 62 were run, plus the 4 roster families (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), all exit 0. `dispatch-gates --ran`: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (prerequisite not met) and were re-run green after `turbo run build` over `./packages/*`. - Lint, narrowed and proved at `4d383dac0`. The population is the 21 changed `.ts` files, none ignored by eslint's own config (`isPathIgnored` false for all 21). `eslint --no-inline-config --format json` over them gives 21 files, 0 errors, 0 warnings. `parserOptions.project` and `projectService` are unset for all 21, so no type-aware lint runs and no untouched file's verdict can move. - NOT MEASURED: a live PostgreSQL cell (the dialect axis of the lowering is the engine's, pinned by objectstack-ai#20872's `data-nested-object-door.test.ts`; this file's axis is the analytics faces), the dogfood and integration lanes, and the whole-workspace typecheck. All are left to CI. ## Acceptance notes - The ObjectQL path's cross-object refusal still says "Run this query on a native-SQL driver". A query the form routed away from the native strategy can meet those words on a SQL deployment. - The engine's cap refusal names the position the engine received. The strategy ANDs the condition in, so the words read `where.$and[0].owner` where the author wrote `where.owner`. - `packages/types/src/error-leak.test.ts` keeps a hand-written stand-in of the read-scope refusal shapes. Its nested-relation line is the old wording. It is a heuristic fixture, not a pin of this module, and it stays green. - `MemoryAnalyticsService` (driver-memory's cube face, objectstack-ai#20859's position) is not touched, and its answer for the form is not measured here. The draft preview refuses the form as an operator it cannot evaluate, unchanged. ## Patch rounds (the seat's append from the dev's reports `5917211340`, `5917807320` and `5918233769`; the dev writes a body only once) ### Patch round 1 `Test Core (3/6)` went red on `4d383dac0`, in `packages/client`'s `envelope-caller-census.test.ts`: 2 of its tests failed. Reproduced locally: the client suite fails 1 file and 2 tests at `4d383dac0`, and passes 50 files and 641 tests at the merge base `9509ea106`. **Root cause.** The census walks the whole workspace for call sites of `analytics.query(` and requires a hand-ledger row for each one. This PR's new pin, `packages/rest/src/analytics-nested-relation-filter.test.ts`, calls the real `AnalyticsService`'s `analytics.query` five times. Those are producer reads, the census's `NOT_SDK` class, and the ledger has no row for them. The failing assertions are the §3 key comparison (the one extra key is that file, `analytics.query`, `service`, 5) and the §2 producer-receiver count (1 expected, 6 found). **What it is not.** It is not a product defect. It is not a client pin of the nested-relation form or of the read-scope wording either. **The fix, pending the seat.** It lives in `packages/client/src/envelope-caller-census.test.ts`, which is outside this card's claim surface. It adds one `NOT_SDK` ledger row with a count of 5, and moves the two producer-read counts from 1 to 6. Measured on a scratch copy of that file: 20 of 20 tests passed. The copy was restored byte-identical, and nothing was committed. ### Patch round 2 The seat authorised the census remedy, round 1's option A, for one file: `packages/client/src/envelope-caller-census.test.ts`. - **main moved.** `975b2481c` (objectstack-ai#20808) touched `packages/services/service-analytics`, so `origin/main` was merged into the branch as `1fdaff7e5` (no rebase). The merge was clean, with no regeneration pending. - **The census change is its own commit, `7eb2ecf20`.** - It adds one `NOT_SDK` ledger row for `packages/rest/src/analytics-nested-relation-filter.test.ts` (`analytics.query`, `service`, count 5). - The producer-receiver count goes from 1 to 6, and the set of two files is asserted. - `verdictTotal('NOT_SDK')` goes from 1 to 6, and its test title changes with it. - Nothing else in that file changed. - **Measured at `7eb2ecf20`.** Each exit code was captured before any pipe. - `pnpm --filter @objectstack/client test`: exit 0, 50 files and 641 tests passed. The census file run alone passed 20 of 20. - `pnpm --filter @objectstack/client typecheck`: exit 0. `tsc --noEmit` passed, and `check:test-typecheck` answered OK. - `@objectstack/service-analytics` test: exit 0, 146 files and 3335 tests passed. Its typecheck: exit 0. - The three rest files (`analytics-nested-relation-filter`, `analytics-read-scope-refusal-envelope`, `data-nested-relation-permission`): exit 0, 3 files and 21 tests passed. - ESLint over the 22 changed `.ts` files: 0 errors and 0 warnings. The config ignores none of them and lints none type-aware, so this diff cannot move a verdict on an untouched file. - Gates, re-derived: 63 derived and 63 run, 0 not measured, plus the 4 roster families. All exit 0 except one. - **The one red is `check:cross-package-test-inputs`.** - Cause: the new ledger row spells the rest pin's path as a literal, and `@objectstack/client`'s declared cross-package input globs do not cover it. The gate is green at `1fdaff7e5`, the commit before. - The gate's own remedy: declare that one file in `scripts/cross-package-test-inputs.mjs`, and mirror it in `turbo.json`'s `@objectstack/client#test` inputs. - Measured on the working tree: that gate and `check-ci-filter-parity` both exit 0. The two files were then restored byte-identical. - Both files lie outside the authorised surface, so the remedy waits for the seat. ### Patch round 3 The seat authorised the gate's own remedy for `check:cross-package-test-inputs`, in two files. - **main.** No commit since `975b2481c` touched this card's surface, the census or either of the two files, so there was no merge. The commits checked were `def279a39`, `4d0b9cd54` and `d78a0bda0`. - **The declaration is its own commit, `2881f478c`.** - `scripts/cross-package-test-inputs.mjs`: in `@objectstack/client`'s entry, one per-file glob, `packages/rest/src/analytics-nested-relation-filter.test.ts`, with a 3-line comment. - `turbo.json`: `$TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts` in `@objectstack/client#test`'s inputs. The line before it gains the comma JSON requires. - Nothing else changed in either file. - **Measured at `2881f478c`.** - Gates, re-derived: 81 derived and 81 run, 0 not measured. Also run: the 11 roster families whose roster lies under a path this diff touches, `check-ci-filter-parity --self-test` and `check:select-shard-packages`. All 93 commands exit 0. - `check:cross-package-test-inputs` (with `--self-test`) is green: "OK: 29 package(s) read outside themselves, all declared". - `check-ci-filter-parity` is green: "all 188 declared cross-package glob(s) (135 unique) are covered". - `check:turbo-task-graph` is green. - `pnpm --filter @objectstack/client test`, as the control: exit 0, 50 files and 641 tests passed, the same as at `7eb2ecf20`. The declaration moved no verdict. - Layer A at work: `--union-into`, given a diff of the rest pin alone, now pulls `@objectstack/client` into the run (8 packages). At `7eb2ecf20` it did not (7 packages). No other package changed. - Turbo hashes, from `--dry=json` before and after, over build, test, test:repo and typecheck (303 tasks): - The global hash is unchanged, and no build or typecheck hash moved. - 7 test hashes moved. `@objectstack/client#test` moved through `turbo.json`: its task definition changed, and the rest pin is a new input. - The other 6 moved only because the content of `scripts/cross-package-test-inputs.mjs`, an input they declare, changed. They are `cli#test`, `plugin-auth#test`, `vitest-filter-preflight#test`, `objectql#test:repo`, `runtime#test:repo` and `spec#test:repo`. - ESLint over the 23 changed `.ts` and `.mjs` files: 0 errors and 0 warnings. The config ignores none of them and lints none type-aware. ### Patch round 4 (the seat's append from the dev's report `5922062971`) `main` was merged (no rebase) to take in four landings in `service-analytics`: - PR objectstack-ai#20931: the field-read gate at the door. - PR objectstack-ai#20955: the queryable-field gate. - PR objectstack-ai#20954: `plugin-security`'s comparand guard. - PR objectstack-ai#20962: relationship-path objects in the admitted and scoped set. **The merge, `6b6bffb3e`.** It is clean at the text level, in `analytics-service.ts` and in `native-sql-strategy.ts`. Every line either side added is present in the merged files, checked line by line. **What the landed gate and object set do with the `relation` node.** This was measured on the merged tree, in the shipped composition (the real `SecurityPlugin` over `ObjectQL` on SQLite), under both strategies. - **The gate judges the relation field.** `collectFilterLeaves` yields the nested form's relation field as its member: `{ owner: { region: 'NA' } }` gives `owner`, with operator `relation`. It does the same under `$not` and inside `$or`. So the field gate judges the relation field on the base object. - A caller who may not read `owner` is refused by the gate: 403 `PERMISSION_DENIED`, in the engine's own words, with no engine call made. - **The related object does not enter `queryObjects`.** The security service is asked only about the base object. - **The engine guards the related object instead.** The nested form is served on the ObjectQL path, where the engine reads the related object as the caller. Each of these is refused with the same code, status and words as `engine.find`, and never answered: - a related field the caller cannot read: 403; - a masked related field (objectstack-ai#20935): 403; - a related object the caller cannot read at all: 403. - **Before this branch, the answer was a refusal.** On `main` alone, even a readable nested condition was refused 403, "reading "owner" is not permitted", because the flattened `owner.region` named the relation field as an object to admit. With this branch, the answer is the engine's. **Measured at `6b6bffb3e`.** Every run was under the shared lock, with each exit code captured before any pipe. - `@objectstack/service-analytics`: tests exit 0 (149 files, 3434 tests), and typecheck exits 0. - The five rest route pins pass 61 of 61: - `analytics-nested-relation-filter`: 10 - `analytics-read-scope-refusal-envelope`: 8 - `data-nested-relation-permission`: 3 - `analytics-field-permission-gate`: 12 - `analytics-relationship-path-admission`: 28 - The client census passes 20 of 20. - Gates, re-derived and run as one locked sequential script: 81 derived, 81 run, 0 not measured. Also run: the 11 roster families and 2 extras. All 93 commands exit 0. - ESLint over the 23 changed `.ts` and `.mjs` files: 0 errors and 0 warnings. **Acceptance notes.** - **A dotted path on an inferred cube is still refused.** `{ 'owner.region': 'NA' }` is refused 403, "reading "owner" is not permitted". The hop object is taken from the alias, because an inferred cube declares no join. This is the same on `origin/main`, and it is outside this card; it went to the seat as a finding. - **A host read scope does not reach the related object.** The related object is not in `queryObjects`, so a host-supplied `getReadScope` is not asked about it. In the shipped composition that provider is the security service's `getReadFilter`, the same row scope the engine applies when it reads the related object as the caller. That case is pinned in `analytics-nested-relation-filter` ("the related row scope"). --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… to the SQL echo on either strategy, and anchor the nested-relation note to its measured base (objectstack-ai#21012) Part of objectstack-ai#20917 Clause-②: no Part of objectstack-ai#20933 · Part of objectstack-ai#20887 A follow-up on three landed cards. It corrects one sentence in each of three release notes of `@objectstack/service-analytics` that are still pending on `main`, before release PR objectstack-ai#20639 consumes them. The order is this lane's dispatch note on objectstack-ai#20917. The reasons are the `domain:spec` seat's pointer `5922364600` on objectstack-ai#6021 (two banners) and the named follow-up in section ② of the delta review `5922352898` on PR objectstack-ai#20916 (one paragraph). PR objectstack-ai#20991 is the precedent: it makes the same banner correction to objectstack-ai#20935's note, and that note is not touched here. No code, test or other file changes. ## The three sentences | Note | Before | After | |:---|:---|:---| | `.changeset/20917-analytics-field-permission-gate.md`, the BREAKING banner | "BREAKING for analytics queries on a SQL deployment that read a field the caller may not read." | "BREAKING for analytics queries that read a field the caller may not read: on a SQL deployment, and on `POST /api/v1/analytics/sql` whichever strategy serves the cube." | | `.changeset/20933-analytics-relationship-path-admission.md`, the BREAKING banner | "BREAKING for analytics queries on a SQL deployment that read a related object through a relationship path the cube does not declare." | "BREAKING for analytics queries that read a related object through a relationship path the cube does not declare: on a SQL deployment, and on `POST /api/v1/analytics/sql` whichever strategy serves the cube." | | `.changeset/20887-analytics-nested-relation-engine-answer.md`, the first sentence of **Why** | "Measured on the base over one fixture with the real security layer (…)." | "Measured on the base before the field-level gate (objectstack-ai#20917) and the relationship-path admission (objectstack-ai#20933) landed, over one fixture with the real security layer (…)." | The parenthesis in the third row is unchanged and elided here. Both banners keep their bold. Everything else in the three files is byte-identical: front matter, summary line, `Clause-②` line, ADR-0087 disposition marker and every other paragraph. Each file's diff is one line (+1/−1). ## The readings behind each sentence ### Banner of the objectstack-ai#20917 note **Before, by source, at `95555e71` (the parent of objectstack-ai#20931's landing `1571aedc`):** - `packages/services/service-analytics/src/analytics-service.ts:2305-2333`: `generateSql()` runs `callCtx` (2329), resolves a strategy (2330) and returns that strategy's `generateSql` (2333). - `analytics-service.ts:1283-1327`: `callCtx` runs the object-level admission (1308) and the read-scope pre-pass. It has no field-level gate. No non-test source in the package names `getReadableFields` at that commit (`git grep` exit 1). The control is the landing `1571aedc`, where it is named in three files. - `strategies/objectql-strategy.ts:372-638`: the ObjectQL strategy's `generateSql` renders the statement from the cube definition and returns it (638). It makes no engine call, so it never reaches the engine's field guards. That strategy's `execute()` reaches `engine.aggregate`, which is why the query door already refused on it. - So for a field the caller may not read, the echo printed a statement on the ObjectQL strategy as well. **After, on `main` at `a5bce40888`:** - `analytics-service.ts:1453-1515`: `callCtx` runs the field-level gate (1487) after the object admission (1481). - `generateSql()` calls `callCtx` before it resolves a strategy (2580). ### Banner of the objectstack-ai#20933 note **Before, by source, at `83480c6a` (the parent of objectstack-ai#20962's landing `5f6b63a6`):** - `analytics-service.ts:1452-1507`: `callCtx` admits objects over `queryObjects` (1477). That set is `cubeObjects` (1584-1588, 1596-1608): the base object and the declared joins only. An object reached through an undeclared relationship path is not in it. - The field-level gate (1483) asks the security service's `getReadableFields`. That answer is field-level only and never asks about object-level read (`packages/plugins/plugin-security/src/security-plugin.ts:5410-5412`, `computeReadableFields` 5440-5461 over `resolveProjectionFieldMask` 5517 ff.). Object-level read is the separate `canReadObject` (5641). So the field gate does not refuse a readable field on an unreadable related object. - `objectql-strategy.ts` is byte-identical at `95555e71`, `83480c6a` and `5f6b63a6` (`git diff --quiet`, exit 0 for both ranges). - Its `generateSql` passes a one-hop cross-object dimension through `planCrossObject` (895 ff.). That function throws only for the out-of-envelope shapes: a cross-object time dimension, measure or filter, a multi-hop dimension, a non-recombinable measure. The one-hop dimension renders a LEFT JOIN (461-466), and the statement is returned (638). - So on the ObjectQL strategy the echo printed a statement for a one-hop dimension through a related object the caller may not read. **After, on `main` at `a5bce40888`:** - `queryObjects` (1607-1616) adds every object a named member reads. - The admission at 1481 covers that set before `generateSql()` resolves a strategy (2580). ### Measured after, on both strategies The measurement was a scratch probe at `a5bce40888`, copied into `packages/rest/src` for one run and removed by an EXIT trap. It was never committed; `git status --porcelain` was empty afterwards. - **Build:** the probe's dependency closure was built under `os-verify-lock.sh` first: `turbo run build --concurrency=1` over `@objectstack/service-analytics...`, `@objectstack/plugin-security...`, `@objectstack/objectql...` and `@objectstack/driver-sql...`. That is 19 tasks, `VERDICT command-exit 0`. - **Run:** `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2` on the one file gave 1 file / 2 tests passed, `VERDICT command-exit 0`. - **Fixture:** the composition is the shipped one, with the real security layer: `SecurityPlugin` over `ObjectQL` on `SqlDriver` (SQLite), and `AnalyticsServicePlugin` over the same engine. There are two compositions: `native` (the plugin's own capabilities) and `objectql` (narrowed to the engine-aggregate path). - **What was asked:** each query body first passed the door's own body schema (`AnalyticsQueryRequestSchema`). It then went to `generateSql`, the call `POST /api/v1/analytics/sql` makes (`packages/runtime/src/domains/analytics.ts:141-159`). The thrown `status` is the HTTP status (`packages/runtime/src/dispatcher-plugin.ts:646-649`). | Question, inferred cube | Member caller, `native` | Member caller, `objectql` | System caller, either | |:---|:---|:---|:---| | A field the member may not read, grouped | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` | statement printed, by the composition's strategy | | The same field, filtered | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` | statement printed | | A one-hop dimension through a related object the member may not read, path not declared | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` | statement printed | | Control: a readable field | statement printed (`NativeSQLStrategy`) | statement printed (`ObjectQLStrategy`) | statement printed | | Control: a readable related object, path not declared | statement printed (`NativeSQLStrategy`) | statement printed (`ObjectQLStrategy`) | statement printed | Before and after both hold for both banners. So each banner is scoped the way PR objectstack-ai#20991 scoped objectstack-ai#20935's. The note's own pins in `packages/rest/src/analytics-field-permission-gate.test.ts` and `analytics-relationship-path-admission.test.ts` assert the same echo refusals on both compositions. The probe adds the system-caller and readable controls, and it ran the bodies through the door's schema. ### The objectstack-ai#20887 **Why** paragraph The dev measured on the branch point of PR objectstack-ai#20916, `00a92e18da`. That is the parent of its first commit, `5687276296`. **The base predates both gates.** `git merge-base --is-ancestor 00a92e1 X` exits 0 for X = `95555e71`, `1571aedc`, `83480c6a` and `5f6b63a6`, so both landings descend from it. The reverse, `1571aedc` against `00a92e18da`, exits 1. Its control leg, `00a92e18da~200` against `00a92e18da`, exits 0, and the repository is not shallow. **Clause one, "answered rows for a condition on a field the caller cannot read", held on that base:** - No non-test source in the package names `getReadableFields` (`git grep` exit 1; control: six hits in `analytics-service.ts` on `main`). - `callCtx` (`analytics-service.ts:1283` ff. at `00a92e18da`) runs only the object admission (1308) and the read-scope pre-pass. - The base filter normalizer flattened the form to a dotted member (`strategies/filter-normalizer.ts:1296-1299`). The native strategy joined the declared include. - The dev's first report (`5916988260` on objectstack-ai#20887) records the measured rows. **Clause two, "without the declared join it named a table that does not exist (500)", held on that base:** - `strategies/native-sql-strategy.ts:771-783` falls back to the relationship name as the joined table when no join is declared. - The join allowlist applies to a compiled dataset only. An inferred cube has none (`analytics-service.ts:1266-1268`; `native-sql-strategy.ts:575-576`). - The object admission covered the base object and the declared joins only (`analytics-service.ts:1404`, `1416` ff.). So nothing refused before the statement ran. The paragraph is anchored to that base in the delta review's own words; the clauses are kept. ### The ADR-0087 gate's reading `breakingDeclaration` and `readDisposition` were re-run on each note at `a5bce40888` and at this head. All three notes read the same on both: - `breaking: true`; - the signals `BREAKING`, `bang` and `clause-②-narrowing`; - the disposition `not-required (no-migration-prescription)`. The gate itself counts the three as inherited, not introduced: it skips a changeset already breaking at the branch point. ## Changeset gate: no `skip-changeset`, and `Check Changeset` stays red This PR edits three pending changesets and adds none, so `Check Changeset` goes red by design. `check-empty-changeset.mjs --base origin/main` exits 1 and refuses all three files as the DELIBERATE CORRECTION class. Ruling D on objectstack-ai#18375 says `skip-changeset` is never applied to a PR that edits an existing changeset, so no label is applied. `Check Changeset` is not a required context. **Confirmation:** this lane's at-tier contract review record on this PR's head judges each rewritten sentence above. The sentences are: 1. the objectstack-ai#20917 note's banner, now scoped to include `POST /api/v1/analytics/sql` on either strategy; 2. the objectstack-ai#20933 note's banner, scoped the same way; 3. the first sentence of the objectstack-ai#20887 note's **Why**, now anchored to the base before the field-level gate and the relationship-path admission landed. `Clause-②: no` was checked against `scripts/pm/clause2-line.mjs`. A wording edit to pending notes widens no accept set and adds no public surface, so the value is `no`. With no arm, the line declares no direction. ## Verification at `56fc0e77e3` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (exit 0) derived 19 commands from the three changed paths. Each ran on this head, with its exit code captured before any pipe. - **18 exit 0:** - `check-adr-0087-registration.mjs --base origin/main` and `--self-test` - `check-changeset-no-major.mjs --base origin/main` and `--self-test` - `check-closing-keyword-parity.mjs` and `--self-test` - `check-comment-mask-corpus.mjs` - `check-empty-changeset.mjs --self-test` - `pm/release-rehearsal-clone.mjs --self-test` - `check:changeset-gate-self-tests`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:published-files`, `check:refd-timer-probe`, `check:watch-hint-literal` - **1 exit 1, by design:** `check-empty-changeset.mjs --base origin/main`, the DELIBERATE CORRECTION class above. - **Roster family:** `check-changeset-fixed.mjs` (its roster lives under `.changeset/`) exited 0. - **Reconciliation:** `dispatch-gates.mjs --ran` with the recorded exit codes exited 0. It read "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN". - **Main moved:** `origin/main` gained `f8178ffece` after the branch point. It touches none of the three notes, and all three are still present there. - **NOT MEASURED, by design:** the type-check lanes and the package test suites. The diff touches no TypeScript and no package source. ## Acceptance notes - **Wording noted, not changed (corrections only, no new claims).** - The objectstack-ai#20917 note's **What changed** ends: "the ObjectQL strategy and the data API already refused them". Its ADR-0087 marker says the engine "already refuses" such queries "on the ObjectQL strategy". Both hold for the query and dataset doors, not for the SQL echo, which printed on that strategy (reading above). The corrected banner carries the echo's scope. - The objectstack-ai#20933 note's **Refusals that change form** says: "On the ObjectQL strategy a related object the caller may not read was already refused". That holds for the query door. On the echo, a one-hop dimension through such an object printed (reading above). The corrected banner carries the echo's scope. - **Where the probe measured.** It measured the service call the door relays, not the mounted HTTP route: identity resolution needs an auth composition the probe did not boot. The relay and the status mapping are cited at file:line above. - **Not in this PR:** objectstack-ai#20935's note. PR objectstack-ai#20991 holds it. - **Disclosure discipline holds.** No request recipe, field spelling or returned value appears here. ## Patch rounds (the seat's append from the dev's report on objectstack-ai#20917; the dev writes a body only once) ### Patch round 1 At `db64c37690`, on the seat's note `5923016038` on objectstack-ai#20917, which takes the dev's class (a) finding into this PR. Two sentences beside the corrected banners are qualified to the doors they hold for. Nothing else changes: each is still one sentence, rewrapped in place, and both ADR-0087 disposition markers are byte-identical. | Note | Before | After | |:---|:---|:---| | `.changeset/20917-analytics-field-permission-gate.md`, **What changed**, last sentence | "The native-SQL strategy, the one a SQL driver serves first, answered such queries; the ObjectQL strategy and the data API already refused them." | "The native-SQL strategy, the one a SQL driver serves first, answered such queries; the ObjectQL strategy already refused them on `POST /api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query`, as the data API did, but printed the statement on `POST /api/v1/analytics/sql`." | | `.changeset/20933-analytics-relationship-path-admission.md`, **Refusals that change form**, first sentence | "On the ObjectQL strategy a related object the caller may not read was already refused; it now answers the analytics door's refusal rather than the engine's, the same one a declared join gets." | "On the ObjectQL strategy a related object the caller may not read was already refused on `POST /api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query`, though `POST /api/v1/analytics/sql` printed the statement; on those two doors it now answers the analytics door's refusal rather than the engine's, the same one a declared join gets." | **Readings:** - **objectstack-ai#20917's sentence, at `95555e71`.** On the query door, the ObjectQL strategy's `execute()` reaches the engine (`objectql-strategy.ts:301`) before it renders its own echo (350-354). The dataset door runs the same `execute()`, so both refused through the engine. The SQL echo's `generateSql` (372-638) renders with no engine call, and `callCtx` had no field gate (`analytics-service.ts:1283-1327`). So `POST /api/v1/analytics/sql` printed the statement. - **objectstack-ai#20933's sentence, at `83480c6a`.** On the query and dataset doors, a one-hop dimension through a related object goes through `executeCrossObject`. Its `resolveFkAttr` (1176 ff.) reads that object through the engine as the caller (1211-1215). The echo renders the join (461-466) and returns the statement (638), with no engine call, and the object was outside the admitted set (`analytics-service.ts:1584-1608`). After the change, on `main`, the echo answers `403 PERMISSION_DENIED` on both strategies (round 0's probe). **ADR-0087 reading at `db64c37690`:** for both notes, `breakingDeclaration` reads `breaking: true` with the signals `BREAKING`, `bang` and `clause-②-narrowing`, and `readDisposition` reads `not-required (no-migration-prescription)`. Both are unchanged from `a5bce40888`, and the objectstack-ai#20887 note reads the same. **Gates at `db64c37690`:** - `dispatch-gates.mjs --commands` derived the same 19 commands. 18 exited 0. `check-empty-changeset.mjs --base origin/main` exited 1 by design: the DELIBERATE CORRECTION class, with all three notes named. - The roster gate `check-changeset-fixed.mjs` exited 0. - `--ran`: 19 derived, 19 run, 0 not measured. - The derivation was repeated at `origin/main` `8055ff2279`, which had changed one roster file, and gave the same 19 commands. None of the three notes changed on `main` over that range. **Acceptance note, wording kept:** both ADR-0087 disposition markers are HTML comments, and they stay byte-identical. The objectstack-ai#20917 marker still says the engine "already refuses" such queries "on the ObjectQL strategy". Like the two sentences above, that wording holds for the query and dataset doors, not for the SQL echo. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20917
Clause-②: yes (narrowing)
One admission gate now judges every member an analytics query names against the caller's field-level read permissions, before either strategy runs. A member the caller may not read answers the engine's own refusal:
403 PERMISSION_DENIED, in the engine's words. The gate sits at the analytics door (AnalyticsService.callCtx, right after the object-level admission and before strategy selection), soNativeSQLStrategy,ObjectQLStrategy, the SQL echo and any strategy added later inherit it by construction. There is no second copy of the permission rule: which fields are readable is thesecurityservice's answer, and the analytics layer contributes only the resolution from member to field.Why line 2 is
yes (narrowing), not the claim's expectedno (narrowing)Both directions measured:
400 INVALID_FIELDto the engine's refusal, and the SQL echo printed the statement and now refuses.AnalyticsServiceConfiggains one optional member,getReadableFields, the hookAnalyticsServicePluginfills. It is a new member of a published config type, so the public surface grows by one hook. No query accept set widens. (There is no matching plugin option: nothing in the tree passes the object-level siblingadmitObjectReadeither, so the plugin always bridges to thesecurityservice.)@objectstack/service-analyticsshipsminorwith the BREAKING banner and an ADR-0087not-required (no-migration-prescription)disposition.check-adr-0087-registrationandcheck-changeset-no-majorpass.Per position
Measured on SQLite with the real
SecurityPlugin,ObjectQLandSqlDriver, as a member whose permission set hides fields on the queried object and on a related one. "Refusal" means403 PERMISSION_DENIEDwhose code, status and message equal what the engine answers for the same field as the same caller:engine.aggregatefor a member the query groups or aggregates,engine.findfor one it filters or sorts by. Each face is the cube read (AnalyticsService.query, whatPOST /api/v1/analytics/queryrelays) and, per row, the dataset door (POST /api/v1/analytics/dataset/query) where the position exists there.$or/$notinclude, an authored join, an inferred cube's relationship path400 INVALID_FIELD(cross-object filter) → refusalfilter; a requested measure's ownfilter400) → refusalAnalyticsService.generateSql, whatPOST /api/v1/analytics/sqlrelays), every row above400) → refusalThe dataset door answers every refusal as
403with{ code, message }and no rows beside it.The reader, and how the gate reaches it
engine.findandengine.aggregaterefuse a hidden field inplugin-security's middleware: the aggregate-input guard for a grouped or aggregated field, the predicate guard for a filtered or sorted one. Both build their mask from the caller's permission sets throughpermissionEvaluator.getFieldPermissions, therequiredPermissionsfold and the on-behalf-of delegator intersection.ISecurityService.getReadableFields(object, context)(resolveProjectionFieldMask). It is called, not changed: no export or signature change inobjectql,specorplugin-security.AnalyticsServicePluginbridgesAnalyticsServiceConfig.getReadableFieldsto the registeredsecurityservice at call time, with the three resolutions its object-level and row-scope bridges keep apart. No security service: no field-level gate, as on/data. A service that throws on resolution or carries nogetReadableFields: the query is refused, fail-closed, logged aterror. Otherwise: ask it, once per object the query names a field of, with the caller's context.namedQueryFieldsinanalytics-service.ts, the judgement in the newfield-read-admission.ts). Each member is resolved as the strategies resolve it (declaredMemberEntry). A joined member is resolved through the cube's join at each hop, and its relationship fields are judged on the object before them, as the engine judges a path's first segment. Filter members are read through the strategies' own lowering (normalizeAnalyticsFilterTree+collectFilterLeaves).What is judged, and what is not
sqlresolves to, never by its name in the cube. Both are measured above and pinned.filterand a requested measure's ownfilter: judged. The nearest engine analogue, measured: the ObjectQL strategy hands both to the engine, which refuses a hidden field in either. On the inline dataset door the caller writes both./data; pinned.sqlis an expression names no field the gate can attribute (flagged for a decision in the dev report); an object the reader answers "no answer" for has none of its fields judged, since an object the security service cannot resolve is one the engine serves nothing from; a name the object's declared field list does not carry is not judged, and with no field list available every name is.Pins, committed red ahead of the fix, pushed together with it
77f73705apins, then40c4979d8the gate, then992a592dbthe changeset and the plugin tidy.packages/rest/src/analytics-field-permission-gate.test.ts: both compositions over the realSecurityPlugin,ObjectQLandSqlDriver; the cube read and the SQL echo over the inferred cube and an authored cube, and the dataset door through this package's route; every refusal compared with the engine's live answer for the same field, computed in the same test. Readable members and a system caller are the controls. Against the base tree: 6 of 12 red (the three position tests per strategy), 6 green (the references and the controls). Now 12 of 12.packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts: every position through both strategy paths from one table with nothing executed; the words and their order; the stand-downs; a throwing reader refused fail-closed; no reader wired; the draft-preview branch; and the plugin's bridge. With the three source files restored to the pins commit (by absolute path, the restore proven byte-identical to HEAD andgit diff HEADempty): 39 red, 9 green, the 9 being the stand-down and no-reader controls. Now 48 of 48.envelope-caller-census.test.ts) counts no new call site: the pins call the producer through a receiver namedservice. Its suite passes unchanged.Ablation, predicted before running, at
40c4979d8The door's gate call was replaced by a no-op through
scripts/ablation-replace.mjs(anchor hit once, blob moved),@objectstack/service-analyticsrebuilt, andablation-dist-preflightfound the marker in 2 built files. Predicted: route pin 6 red and 6 green; unit pin 38 red and 10 green (the draft-preview branch keeps its own call, so it stays green). Observed: exactly that. The restore leg proved the blob equal to HEAD,git diff HEADempty and the whole tree clean, rebuilt, and found the marker absent from all 6 built files.Verification, at
992a592db@objectstack/service-analytics:test146 files, 3374 passed;typecheckexit 0, with 144 of 144__tests__files in the tsc program (--listFiles).@objectstack/rest: the 12analytics-*route files pluserror-response-structured-arm-door-parity, 13 files, 215 passed and 3 skipped;typecheckpasses, including the test layer (check:test-typecheckOK).@objectstack/runtimeanalytics-*(3) pluscross-field-refusal-operand-withhold, 28 passed and 4 skipped;@objectstack/clientanalytics-automation-json-erasureplus the census, 27 passed;@objectstack/dogfoodanalytics-*(6 files), 54 passed.dispatch-gates --commandsderived 62. All 62 were run, plus the 4 roster families (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity), all exit 0.dispatch-gates --ran, fed each command with its exit code: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero.check:dual-build-cjs-loadsfirst exited 3 (prerequisite not met) andcheck:type-check-debtwas cut by my own runner's time limit; both were re-run green afterturbo run buildover./packages/*..tsfiles, none ignored by eslint's own config (isPathIgnoredfalse for all 5).eslint --no-inline-configover them gives 5 files, 0 errors, 0 warnings.parserOptions.projectandprojectServiceare unset for all 5, so no type-aware rule runs and no untouched file's verdict can move.Docs
No hand-written
content/docs/**page states how the analytics routes treat field permissions.permissions/authorization.mdxstates the engine's field guard in general terms and stays true;permissions/index.mdxnames the analytics row-scope bridge only.Acceptance notes
@objectstack/restlocalproject and the whole-workspace typecheck. Left to CI.getReadableFields, the published reader. Where that reader and the engine's own field guard differ, the gate follows the reader; the difference is reported separately, as a class, in the dev report. (Reworded by thedomain:servicesseat under the lane's disclosure discipline.)MemoryAnalyticsService(driver-memory's own cube face) is not touched and not measured.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887, the nested-relation form) holdsanalytics-service.tsand both strategies. It mergesmainafter this lands, and the gate runs ahead of its nested-relation decline.Generated by Claude Code