Repository navigation
fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) - #20916
Conversation
…lytics faces to the engine's answer (red)
The cube read (both strategies), the dataset door, the SQL echo and a read
scope carrying `{ relation: { field: value } }` are pinned against the
engine's own answer over one fixture with the real security layer: the
engine's rows for single- and multi-valued relations, the related row
scope, $not and $or; its 403 for a related field the caller cannot read;
its 400 past the cap and at an aggregation's filter. Plus the analytics
seams: the native strategy declines the form from every producer it
compiles, the ObjectQL strategy hands the engine the form as written, and
the read-scope compiler's refusal names the route that serves it.
Red on the base: 10 of 10 route pins, 7 of 9 unit pins (the 2 controls
pass).
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…engine on every analytics face
`{ relation: { field: value } }` is the engine's form (served in `where`
at the #5930 seam: the related object read as the caller, capped). The
analytics door used to flatten it to a dotted member: the native strategy
LEFT JOINed the related table (its row scope applied, its field
permissions and the cap did not, a multi-valued relation and an undeclared
join missed), and the ObjectQL strategy refused it as a cross-object
filter.
- filter-normalizer: a nested-relation condition becomes a `relation`
node carried as written (no dotted flattening, no local NULL guard);
`findNestedRelationCondition` is the routing detector.
- NativeSQLStrategy.canHandle declines a query whose where, dataset
filter, requested measure filter, or base/joined read scope carries the
form (the mechanism of the cross-field decline); its compiler refuses a
relation node bare, as routing drift.
- ObjectQLStrategy hands the engine the condition as written, as its own
conjunct; the display-SQL echo declines it in the where-door envelope.
- read-scope-sql keeps its fail-closed refusal of the form (a synchronous
SQL compile that reads no other object), in words naming the engine
route; the empty/mixed value object keeps its old words.
- The no-strategy error names the nested-relation decline.
Pins that recorded the flattening are re-judged (respelled to the dotted
cube member where they were about the traversal, replaced where they
pinned the removed branch).
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
… shared lowering
Under $not the shared lowering read the relation key as a column of the
queried object and guarded it; the engine hand-off spells that guard
`$ne: null`, which the SQL driver refuses over a multi-valued relation's
JSON column, so `{ $not: { owners: { region: 'NA' } } }` answered
INVALID_FILTER where the engine answers its rows. Each nested-relation
condition now stands in the lowered condition as a total sentinel and is
restored as the relation node; the engine guards what it lowers the
condition to itself.
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ine answer Clause-② measured as yes (narrowing): served where the analytics faces refused or failed, refused where the native join answered without the caller's field permissions or the cap. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…m keeps its declared refusal on the native path B4 measured: the read scope's SQL compile is a synchronous string builder holding the caller's context for placeholders and no data engine, so it cannot read the related object as the caller. The native strategy no longer declines on a read scope carrying the form: routing it away traded the declared READ_SCOPE_COMPILE_FAILED (policy withheld) for whatever the next strategy answered, and a generic fault on a host without one. The compile keeps its fail-closed refusal, in words that name the engine route; the engine-aggregate path still hands the scope to the engine, which serves it as the caller. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Brings the gate derivation onto a fresh tree (a derived-from gate script changed on main); no commit on main touched this branch's surface. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cfc9af67dce65396e7d9bbff98aa8a49c093ba48 && git checkout cfc9af67dce65396e7d9bbff98aa8a49c093ba48
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 212d613ca03d2dd3de7147ebd0eb759667e2ffa9 6b6bffb3eaacd348a491cc185fcaf506f38c0106 && git checkout -B drift-repro 212d613ca03d2dd3de7147ebd0eb759667e2ffa9 && git merge --no-ff 6b6bffb3eaacd348a491cc185fcaf506f38c0106
node scripts/docs-audit/affected-docs.mjs --json 212d613ca03d2dd3de7147ebd0eb759667e2ffa9
|
main's 975b248 touched packages/services/service-analytics (the dataset compiler and one of its pins), this card's surface, so it is merged before patch round 2. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…census The rest pin packages/rest/src/analytics-nested-relation-filter.test.ts calls the real AnalyticsService (the cube read) five times, to compare its answer for a nested-relation filter with the engine's. Those are producer reads, not SDK callers: one NOT_SDK ledger row, with the service-receiver count and the NOT_SDK total moved from 1 to 6, and the two-file set asserted. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…nput The census ledger row added for the rest pin names packages/rest/src/analytics-nested-relation-filter.test.ts: it pins that file's five producer reads of analytics.query. That makes it a real input of @objectstack/client's tests, so it is declared per-file in the client's cross-package input globs and mirrored into client#test's turbo inputs. A changed call count in the rest pin now re-runs the client suite. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: PR #20916 (card #20887), reviewed at 2026-09-30T19:46Z by an isolated contract-review subagent of the ① Derived judgmentsPublic surface
Accept set, per face (the ruling's line: one answer on every face, the engine's)
② Semver level
③ Boundary flagsEvery dev flag and every
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>
Contract reviewServed-tier: Delta review of PR #20916 (card #20887) on the merge head, rendered at 2026-10-01T00:38Z by an isolated contract-review subagent of the ① Derived judgmentsThe merge,
The interaction with what
Docs
② Semver level
③ Boundary flagsRound 4's report (
Implemented-by: VERDICT: PASS |
|
Heads-up from PR #20979 (#20897) edits the same three places this PR does: |
…oor, before either strategy (objectstack-ai#20917) (objectstack-ai#20931) Fixes objectstack-ai#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), so `NativeSQLStrategy`, `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 the `security` service's answer, and the analytics layer contributes only the resolution from member to field. ## Why line 2 is `yes (narrowing)`, not the claim's expected `no (narrowing)` Both directions measured: - **Narrowing.** On the native-SQL strategy every position in the table below was answered and is now refused. On the ObjectQL strategy an order key naming a hidden field was ignored and is now refused, a joined filtered member moves from `400 INVALID_FIELD` to the engine's refusal, and the SQL echo printed the statement and now refuses. - **Widening.** `AnalyticsServiceConfig` gains one optional member, `getReadableFields`, the hook `AnalyticsServicePlugin` fills. 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 sibling `admitObjectRead` either, so the plugin always bridges to the `security` service.) `@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. ## Per position Measured on SQLite with the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`, as a member whose permission set hides fields on the queried object and on a related one. "Refusal" means `403 PERMISSION_DENIED` whose code, status and message equal what the engine answers for the same field as the same caller: `engine.aggregate` for a member the query groups or aggregates, `engine.find` for one it filters or sorts by. Each face is the cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` relays) and, per row, the dataset door (`POST /api/v1/analytics/dataset/query`) where the position exists there. | position | native-SQL strategy: before → after | ObjectQL strategy: before → after | |:--|:--|:--| | grouped member (dimension) | answered, the hidden values as group keys → refusal | refusal → refusal | | aggregated member (measure) | answered, an aggregate over the hidden field → refusal | refusal → refusal | | bucketed time dimension | refusal (the strategy declines, the engine refuses) → refusal | refusal → refusal | | time-dimension window | answered, rows selected by the hidden field → refusal | refusal → refusal | | filtered member, including under `$or` / `$not` | answered, rows selected by the hidden field → refusal | refusal → refusal | | order key | answered, groups ordered by the hidden field → refusal | answered, the key ignored → refusal | | joined member, grouped: a dataset `include`, an authored join, an inferred cube's relationship path | answered, the related hidden values → refusal on the related object | refusal → refusal | | joined member, filtered | answered, rows selected by the related hidden field → refusal on the related object | `400 INVALID_FIELD` (cross-object filter) → refusal | | a dataset's own `filter`; a requested measure's own `filter` | answered → refusal | refusal → refusal | | authored cube alias over a hidden field: grouped, aggregated, filtered, joined | answered → refusal naming the field the alias resolves to | refusal (joined filtered: `400`) → refusal | | SQL echo (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` relays), every row above | printed the statement → refusal | printed the statement (joined filtered: `400`) → refusal | | readable members (the control) | answered → answered, unchanged | answered → answered, unchanged | The dataset door answers every refusal as `403` with `{ code, message }` and no rows beside it. ## The reader, and how the gate reaches it - `engine.find` and `engine.aggregate` refuse a hidden field in `plugin-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 through `permissionEvaluator.getFieldPermissions`, the `requiredPermissions` fold and the on-behalf-of delegator intersection. - The published cross-package reader of that same derivation is `ISecurityService.getReadableFields(object, context)` (`resolveProjectionFieldMask`). It is called, not changed: no export or signature change in `objectql`, `spec` or `plugin-security`. - `AnalyticsServicePlugin` bridges `AnalyticsServiceConfig.getReadableFields` to the registered `security` service 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 no `getReadableFields`: the query is refused, fail-closed, logged at `error`. Otherwise: ask it, once per object the query names a field of, with the caller's context. - The analytics layer adds the member resolution (`namedQueryFields` in `analytics-service.ts`, the judgement in the new `field-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`). - The words are the engine's two refusals, verbatim: the aggregate refusal for a grouped or aggregated member, the predicate refusal for a filtered or sorted one. On one object the aggregate refusal speaks first, and the base object before a joined one, as on the engine path. The draft-preview branch of the dataset door asks the same gate before it evaluates drafted rows. ## What is judged, and what is not - **Triage's floor:** dimensions, measures, filter members, joined members and time-dimension members. **Also order keys:** the native statement ordered by the named column. - **Authored versus inferred cubes:** a member is judged by the field its `sql` resolves to, never by its name in the cube. Both are measured above and pinned. - **A dataset's own `filter` and a requested measure's own `filter`: 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. - **A host read scope: not judged.** The engine's field guard runs on the caller's own predicate before row-level policy is composed, and exempts policy predicates by design: they may name fields the caller cannot read. A read scope naming a hidden field is served, as on `/data`; pinned. - **Stand-downs, each pinned:** a member of an authored cube whose `sql` is 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 - `77f73705a` pins, then `40c4979d8` the gate, then `992a592db` the changeset and the plugin tidy. - `packages/rest/src/analytics-field-permission-gate.test.ts`: both compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`; 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 and `git diff HEAD` empty): 39 red, 9 green, the 9 being the stand-down and no-reader controls. Now 48 of 48. - The client census (`envelope-caller-census.test.ts`) counts no new call site: the pins call the producer through a receiver named `service`. Its suite passes unchanged. ## Ablation, predicted before running, at `40c4979d8` The door's gate call was replaced by a no-op through `scripts/ablation-replace.mjs` (anchor hit once, blob moved), `@objectstack/service-analytics` rebuilt, and `ablation-dist-preflight` found 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 HEAD` empty and the whole tree clean, rebuilt, and found the marker absent from all 6 built files. ## Verification, at `992a592db` - `@objectstack/service-analytics`: `test` 146 files, 3374 passed; `typecheck` exit 0, with 144 of 144 `__tests__` files in the tsc program (`--listFiles`). - `@objectstack/rest`: the 12 `analytics-*` route files plus `error-response-structured-arm-door-parity`, 13 files, 215 passed and 3 skipped; `typecheck` passes, including the test layer (`check:test-typecheck` OK). - Consumer sweep, narrowed to the test files that load this package: `@objectstack/runtime` `analytics-*` (3) plus `cross-field-refusal-operand-withhold`, 28 passed and 4 skipped; `@objectstack/client` `analytics-automation-json-erasure` plus the census, 27 passed; `@objectstack/dogfood` `analytics-*` (6 files), 54 passed. - Gates: `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`, fed each command with its exit code: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero. `check:dual-build-cjs-loads` first exited 3 (prerequisite not met) and `check:type-check-debt` was cut by my own runner's time limit; both were re-run green after `turbo run build` over `./packages/*`. - Lint, narrowed and proved: the population is the 5 changed `.ts` files, none ignored by eslint's own config (`isPathIgnored` false for all 5). `eslint --no-inline-config` over them gives 5 files, 0 errors, 0 warnings. `parserOptions.project` and `projectService` are 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.mdx` states the engine's field guard in general terms and stays true; `permissions/index.mdx` names the analytics row-scope bridge only. ## Acceptance notes - NOT MEASURED: a live PostgreSQL cell. This package's test matrix runs no live SQL server, and the gate answers before a strategy is chosen and before any statement is compiled, so the dialect does not enter the verdict. - NOT MEASURED locally: the full `@objectstack/rest` `local` project and the whole-workspace typecheck. Left to CI. - The gate is exactly as wide as `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 the `domain:services` seat under the lane's disclosure discipline.) - A refusal names the field an authored alias resolves to, as the engine's refusal on the ObjectQL strategy already did for the same query. - An inferred cube's relationship path whose relationship field is not named after its target object names no object the security service knows, so none of its fields is judged. Neither strategy reads the related object there: the native statement fails and the ObjectQL strategy refuses, both unchanged. - `MemoryAnalyticsService` (driver-memory's own cube face) is not touched and not measured. - PR objectstack-ai#20916 (objectstack-ai#20887, the nested-relation form) holds `analytics-service.ts` and both strategies. It merges `main` after this lands, and the gate runs ahead of its nested-relation decline. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ch fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (objectstack-ai#20935) (objectstack-ai#20955) Closes objectstack-ai#20935 Clause-②: yes (narrowing) A field whose `maskingRule` applies to the caller is SERVED, with its value replaced, so the published read projection `ISecurityService.getReadableFields` counts it readable. It is not queryable: the engine refuses it as a group key, an aggregate input, a filter or a sort key, because each of those gives back what the mask hides. The analytics field gate from objectstack-ai#20917 was exactly as wide as the read projection. So a masked-for-this-caller field could be grouped or filtered by on the native-SQL strategy, which compiles its own statement. The engine and the ObjectQL strategy refuse the same query. This PR publishes the missing answer on the security contract, implements it from the one decision the engine already makes, and has the analytics gate ask it. The masking rule itself is unchanged, and the analytics layer does not re-derive it. ## What changed, per surface - **Contract (`packages/spec/src/contracts/security-service.ts`).** `ISecurityService` gains an optional `getQueryableFields(object, context)`. It answers the fields the caller may filter, sort, group or aggregate by: the exact complement of what the engine's field guards refuse. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked. It has the same two empty answers as its siblings: `undefined` is no answer, `[]` is none. A system context gets every field. - Its absence is **not** soft. A consumer that cannot get this answer (the method is missing, or it answered `undefined`) must treat every field that declares a `maskingRule` as not queryable, whoever the caller is. Falling back to the read projection alone would admit exactly the masked fields. - **Implementation (`packages/plugins/plugin-security/src/security-plugin.ts`).** - The engine's predicate guard (step 2.9) and aggregate-input guard (step 2.5b) each built their "not queryable" map inline. They now call one private derivation, `computeQueryGuardFieldPerms`: the evaluator map, the `requiredPermissions` fold, the on-behalf-of delegator intersection, then every masked-for-this-caller field folded in as non-queryable. - `getQueryableFields` reads that same map, so a field is in the answer if and only if a query naming it passes both guards. The guards refuse exactly what they refused before. - It is registered on the `security` service beside `getReadableFields`. - **Gate (`packages/services/service-analytics/src/field-read-admission.ts` + minimal wiring in `analytics-service.ts` and `plugin.ts`).** - `AnalyticsServiceConfig` gains `getQueryableFields`. The door's one field gate asks it beside `getReadableFields` for the same objects. A member is admitted only when both answers carry its field. There is no second gate and no per-strategy copy. - A refused member answers `403 PERMISSION_DENIED` in the engine's own words for that field. Those are the words the engine answers a masked field with. - A queryable reader that throws refuses the query, fail-closed and logged at `error`, the same way the read reader does. - `AnalyticsServicePlugin` bridges the hook to the `security` service with the same absent / unusable / usable resolutions as the read half, plus one more state. When a usable service predates the method, or answers `undefined`, the bridge **fails closed**: the read projection less every field whose declaration carries a `maskingRule`, for every caller. That over-refuses a caller for whom the rule is lifted, which is the safe direction. It is also the only one available, because deciding for whom a rule is lifted would be a second copy of the masking rule. - A host that constructs `AnalyticsService` itself with `getReadableFields` and without `getQueryableFields` is warned once at construction. ## Why the PR line reads `yes (widening)`, and the analytics changeset declares a narrowing The line above is the claim's, as corrected under the seat's review adoption `5921385686` (`scripts/pm/clause2-line.mjs:93`: a diff that widens one surface and narrows another is spelled `yes (narrowing)`). The diff does widen the public surface: one optional contract member, its implementation on the service, and one optional config hook. It also narrows one accept set. Analytics queries that grouped, aggregated, filtered or sorted by a field the caller sees masked were answered on the native-SQL strategy (and printed by the SQL echo on both strategies), and they are now refused. That narrowing is declared where the level and ADR-0087 gates read it: in `.changeset/20935-analytics-masked-field-not-queryable.md`, which carries the `**BREAKING**` banner, its own `Clause-②: yes (narrowing)` line and an ADR-0087 `not-required (no-migration-prescription)` disposition, as objectstack-ai#20917's changeset did. `@objectstack/spec` and `@objectstack/plugin-security` ship `minor` (widening). Every bumped package is `minor`, which is what `yes` requires. `check-changeset-no-major` and `check-adr-0087-registration` both exit 0. ## Pins - `packages/rest/src/analytics-masked-field-gate.test.ts` covers both compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver` (native-SQL first, and ObjectQL only), with synthetic fixtures. - Two callers read the same fields: a member for whom each field's masking rule applies, and an unmasker who holds the capability that lifts it. - For the member, a masked field as a group member and as a filter member answers the engine's live refusal for the same field and caller (code, status and message equal). This holds on the base object, through an authored alias and through a joined object, on the cube read, the SQL echo and the dataset door. Neither the engine's aggregate nor its raw statement runs. - Controls: the unmasker is answered exactly as the system caller is, and the member is answered for fields it may query on. - `packages/plugins/plugin-security/src/get-queryable-fields.test.ts` is an equivalence. For every field and four positions (filter, sort key, group key, aggregate input), "the real middleware admitted it" equals "the field is in `getQueryableFields`". The cases cover a member, the capability holder, an agent alone and the same agent on behalf of a delegator. It also pins the contract's answers: masked means readable and not queryable, the system and no-sets answers, the unresolvable object, the dangling delegator, and registration on the service. - `packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts` runs the masked positions its cases name (six) through both strategy paths with nothing executed. It also covers the throwing reader, each reader judged on its own, the construction warning, and the bridge: service answer, rule lifted, the fail-closed fallback for a service that predates the method and for an `undefined` answer, and no security service. - `packages/spec/src/contracts/security-service.test.ts` pins the member's optionality (an unguarded call does not compile), the masked-readable-not-queryable relation and the two empty answers. ## Ablations, predicted before running, at `20c56c9810` Each ablation followed the same legs: 1. Mutate through `scripts/ablation-replace.mjs`: the anchor hits once and the blob moves. 2. Where the suite reads `dist/`, rebuild the package and confirm with `ablation-dist-preflight` that the marker is in 2 built files. 3. Run the suites. 4. Restore, and prove the blob equals HEAD with an empty `git diff HEAD`. 5. Rebuild, and confirm with the `--absent` preflight that the marker is gone from all 6 built files and the tree is clean. - **A: the gate ignores the queryable answer** (`field-read-admission.ts`, service-analytics rebuilt). - Predicted and observed: route pins 4 red and 18 green. - Red, on each composition: the cube read and SQL echo, and the dataset door. Native answered the rows. ObjectQL printed the statement on the echo and reached the engine on the dataset door. - Green: the 6 references and controls, and all 12 of objectstack-ai#20917's route pins. - Unit pins: 19 red and 53 green. The 5 greens are the "no answer", construction-warning, rule-lifted and no-security cases, plus all 48 of objectstack-ai#20917's unit pins. - **B: plugin-security stops registering `getQueryableFields` on the service** (plugin-security rebuilt). - Predicted and observed: route pins 2 red and 8 green. Every member refusal stays green through the bridge's fail-closed fallback. The unmasker control goes red on both compositions, because the fallback over-refuses by design. - plugin-security: 1 red (registration on the service) and 9 green. - **C: the shared derivation stops folding masked fields in** (src-resolved suite, so no build leg). Observed 8 red and 38 green. - The 6 predicted `get-queryable-fields` reds. - The two pre-existing engine pins in `field-masking-rule.test.ts` (a filter and an aggregate over a masked field). This shows both engine guards now read the one derivation. Commits after `20c56c9810` touched only the bridge's fallback. It no longer exempts a system caller, which kept a new elevation read site out of the system-context census. None of the three ablations reaches that branch. ## Verification, at `fc844c433e` (main merged at `95fed33a20`) - `@objectstack/service-analytics`: `test` 147 files, 3398 passed, and `typecheck` 0 (the new test file is in the tsc program: it reported a tuple error before its fix). This run was taken at `e84882a09f`. The later edits were re-run: the two gate unit files, 72 passed, plus `typecheck` 0. - `@objectstack/plugin-security`: `test` 150 files, 3237 passed and 23 skipped, and `typecheck` 0 including the test layer. - `@objectstack/spec`: `typecheck` 0, `src/contracts` 45 files and 434 passed, and `check:generated` shows all 15 artifacts up to date. - `@objectstack/rest`: every `analytics-*` route file (13 files, 175 passed and 3 skipped), and `typecheck` 0 including the test layer. Both field-gate route files re-ran at `03153e00f1` after the second merge of main: 22 passed. - `dispatch-gates --commands` derived 87 commands at `fc844c433e`, with no stale tree. - Commands 1 to 59 exit 0. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 44 packages have no `dist/` in this worktree). That is NOT MEASURED, not a pass. - The remaining commands, the lint narrowing and the `--ran` reconciliation are recorded in the os-dev-report on objectstack-ai#20935, because this body is written once. ## Acceptance notes - NOT MEASURED: a live PostgreSQL cell. The gate answers before a strategy is chosen and before any statement is compiled. - The ObjectQL strategy serves no cross-object filter to anyone. So in the unmasker control, that one case is answered by the strategy's own refusal, identically for the system caller. That limitation is unchanged here. - `MemoryAnalyticsService` (driver-memory's cube face) is neither touched nor measured. - The bridge's fallback reads masking declarations from the data engine's registered schema, the registry the security service reads first. An object only the metadata service knows contributes no declarations to the fallback. This affects only a security service that predates the member. - Other consumers of `getReadableFields` are not changed here. Only the analytics door uses it to admit query positions. - objectstack-ai#20933 (relationship-path object admission) is not addressed here. - PR objectstack-ai#20916 also edits `analytics-service.ts`. The wiring here is kept to the config member, the stored provider, the construction warning and one extra argument at the one gate call site. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- 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 #20887
Clause-②: yes (narrowing)
The analytics half of ruling 5907789183, whose parent card is #20802 (its engine half landed as #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 atRELATION_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) andowners(multiple lookup) to an owner object; the member cannot readowner.secret, and its row scope hides owners in regionHIDDEN. Past the cap means 1,001 matching owners. Measured with the realSecurityPlugin,ObjectQLandSqlDriver(SQLite).POST /api/v1/analytics/query(AnalyticsService.query)$not,$or(was 500DATABASE_ERROR: the join named a tableownerthat does not exist)PERMISSION_DENIED, as the engine (was 500)INVALID_FILTER, as the engine (was 500)INVALID_FIELD, "cannot evaluate a cross-object filter")POST /api/v1/analytics/dataset/query, the datasetincludesownerDATASET_INVALID; under$notthe member gotbwhere the engine answersb, d)filtercarrying the formINVALID_FILTER, as the engine refuses it at an aggregation'sfilter(was: native counted it through the JOIN; ObjectQL 400INVALID_FIELD)POST /api/v1/analytics/sqlINVALID_FILTER, naming the served route (was: native printed the JOIN; ObjectQL 400)getReadScope)READ_SCOPE_COMPILE_FAILED, policy withheld, words now naming the route (outcome unchanged)Mechanism assumptions, measured
{ owner: { region: 'NA' } }is d1, d3; the multi-valued form is d1, d3;{ owner: { secret: 's1' } }is 403PERMISSION_DENIEDnamingsecret(a system caller gets d1, d3); regionHIDDENgives no rows (a system caller gets d4); past the cap is 400INVALID_FILTERfor both spellings;$notgives d2, d4; the$orgives d1, d2, d3;{ owner: {} }and a second level are 400.RELATION_FILTER_ID_CAPis exported (packages/objectql/src/index.ts:148), and nothing here imports it: the analytics layer never counts ids, the engine does.WHEREconjunct, 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.@objectstack/objectqlexports only the cap. The lowering (admitRelationCondition,lowerRelationSite) is module-internal, and this package has@objectstack/objectqlas a dev dependency only. The route that needs no export: the engine-aggregate strategy hands the condition toengine.aggregatethroughexecuteAggregate, with the caller's context. No export was needed, and there is no second permission rule.compileScopedFilterToSqlis a synchronous string builder. It holds the caller'sExecutionContextfor 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 declaredREAD_SCOPE_COMPILE_FAILED(policy withheld) for a generic no-strategy fault (packages/rest/src/analytics-read-scope-refusal-envelope.test.tswent 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.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 ownfilteron 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-analyticsshipsminorwith the BREAKING banner and an ADR-0087not-required (no-migration-prescription)disposition;check-adr-0087-registrationandcheck-changeset-no-majorpass.content/docs/**page states how the analytics read or the read scope treats the nested form.data-engine.mdx, andquery-syntax.mdx(docs(query-syntax): Filtering Across Relationships states the served nested-relation form #20906, which landed during this work), describe the engine only.What changed
strategies/filter-normalizer.ts: a nested-relation condition becomes arelationnode carrying the condition as written. It is no longer flattened to the dotted member.shieldNestedRelationsholds it out of the shared lowering, because under$notthe lowering guarded the relation column, and this package's engine hand-off spells that guard$ne: null, whichdriver-sqlrefuses over a multi-valued JSON column. Measured: the multi-valued$notpin went red before the shield, and the engine guards what it lowers the condition to itself.findNestedRelationConditionis the routing detector.strategies/native-sql-strategy.ts:canHandledeclines when thewhere, the dataset's ownfilteror a requested measure'sfiltercarries the form. This is the mechanism of the cross-field decline (maintainer ruling 2026-08-12, Q1 = B). Its compiler refuses arelationnode 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.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
5bb764181Each ablation mutated the committed file through
scripts/ablation-replace.mjs(anchor hit once, blob moved), rebuilt@objectstack/service-analytics, and passedablation-dist-preflight(the marker present in 2 built files). It then ran both pin files, pluswhere-door-shared-lowering-seam.test.tsin the unit run. The restore leg proved the blob equal to HEAD andgit diff HEADempty, rebuilt, and found the marker absent from all 6 built files. Every observed count equals its prediction.d(a hidden owner's row), and the unreadable field answered rows$notA first round at
dca1af7cbmatched 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:
filter-normalizer-not-null-safe,icontains-text-comparand-refusal;relationnode 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;guardFieldEntryrecursion 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, andwhere-source-field-gate, which now judges the relation fieldowneras a column of the queried object;read-scope-sql,read-scope-not-null-safe,read-scope-undefined-comparand, andread-scope-refusal-envelope, which gains row 17 because the nested-relation form now has a throw site of its own.Verification
@objectstack/service-analytics:test146 files, 3334 passed;typecheckexit 0, with 146 of 146 test files in the tsc program (--listFiles). Both at4d383dac0, after mergingmain.@objectstack/rest: the fulllocalproject, 239 files, 4662 passed and 106 skipped, at5bb764181. The merge brought no rest or analytics change. At4d383dac0, the new pin, the read-scope envelope pin and the engine half's permission pin: 3 files, 21 passed.typecheckpasses, including the test layer (check:test-typecheckOK).@objectstack/runtimeanalytics-*pluscross-field-refusal-operand-withhold, 5 files, 38 passed and 4 skipped;@objectstack/clientanalytics-automation-json-erasure, 7 passed.4d383dac0: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: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (prerequisite not met) and were re-run green afterturbo run buildover./packages/*.4d383dac0. The population is the 21 changed.tsfiles, none ignored by eslint's own config (isPathIgnoredfalse for all 21).eslint --no-inline-config --format jsonover them gives 21 files, 0 errors, 0 warnings.parserOptions.projectandprojectServiceare unset for all 21, so no type-aware lint runs and no untouched file's verdict can move.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
where.$and[0].ownerwhere the author wrotewhere.owner.packages/types/src/error-leak.test.tskeeps 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, [finding] driver-memory analytics: MemoryAnalyticsService drops an array (FilterArray)whereand answers every row, where the object spelling filters and the engine refuses 400 #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,5917807320and5918233769; the dev writes a body only once)Patch round 1
Test Core (3/6)went red on4d383dac0, inpackages/client'senvelope-caller-census.test.ts: 2 of its tests failed. Reproduced locally: the client suite fails 1 file and 2 tests at4d383dac0, and passes 50 files and 641 tests at the merge base9509ea106.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 realAnalyticsService'sanalytics.queryfive times. Those are producer reads, the census'sNOT_SDKclass, 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 oneNOT_SDKledger 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.975b2481c([finding] two more JSON-stored columns as a group or distinct key answer 500 on PostgreSQL:groupByon amultiple: trueselect, andcount_distincton ajsonfield #20808) touchedpackages/services/service-analytics, soorigin/mainwas merged into the branch as1fdaff7e5(no rebase). The merge was clean, with no regeneration pending.7eb2ecf20.NOT_SDKledger row forpackages/rest/src/analytics-nested-relation-filter.test.ts(analytics.query,service, count 5).verdictTotal('NOT_SDK')goes from 1 to 6, and its test title changes with it.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 --noEmitpassed, andcheck:test-typecheckanswered OK.@objectstack/service-analyticstest: exit 0, 146 files and 3335 tests passed. Its typecheck: exit 0.analytics-nested-relation-filter,analytics-read-scope-refusal-envelope,data-nested-relation-permission): exit 0, 3 files and 21 tests passed..tsfiles: 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.check:cross-package-test-inputs.@objectstack/client's declared cross-package input globs do not cover it. The gate is green at1fdaff7e5, the commit before.scripts/cross-package-test-inputs.mjs, and mirror it inturbo.json's@objectstack/client#testinputs.check-ci-filter-parityboth exit 0. The two files were then restored byte-identical.Patch round 3
The seat authorised the gate's own remedy for
check:cross-package-test-inputs, in two files.975b2481ctouched this card's surface, the census or either of the two files, so there was no merge. The commits checked weredef279a39,4d0b9cd54andd78a0bda0.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.tsin@objectstack/client#test's inputs. The line before it gains the comma JSON requires.2881f478c.check-ci-filter-parity --self-testandcheck: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-parityis green: "all 188 declared cross-package glob(s) (135 unique) are covered".check:turbo-task-graphis green.pnpm --filter @objectstack/client test, as the control: exit 0, 50 files and 641 tests passed, the same as at7eb2ecf20. The declaration moved no verdict.--union-into, given a diff of the rest pin alone, now pulls@objectstack/clientinto the run (8 packages). At7eb2ecf20it did not (7 packages). No other package changed.--dry=jsonbefore and after, over build, test, test:repo and typecheck (303 tasks):@objectstack/client#testmoved throughturbo.json: its task definition changed, and the rest pin is a new input.scripts/cross-package-test-inputs.mjs, an input they declare, changed. They arecli#test,plugin-auth#test,vitest-filter-preflight#test,objectql#test:repo,runtime#test:repoandspec#test:repo..tsand.mjsfiles: 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)mainwas merged (no rebase) to take in four landings inservice-analytics:plugin-security's comparand guard.The merge,
6b6bffb3e. It is clean at the text level, inanalytics-service.tsand innative-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
relationnode. This was measured on the merged tree, in the shipped composition (the realSecurityPluginoverObjectQLon SQLite), under both strategies.collectFilterLeavesyields the nested form's relation field as its member:{ owner: { region: 'NA' } }givesowner, with operatorrelation. It does the same under$notand inside$or. So the field gate judges the relation field on the base object.owneris refused by the gate: 403PERMISSION_DENIED, in the engine's own words, with no engine call made.queryObjects. The security service is asked only about the base object.engine.find, and never answered:mainalone, even a readable nested condition was refused 403, "reading "owner" is not permitted", because the flattenedowner.regionnamed 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.analytics-nested-relation-filter: 10analytics-read-scope-refusal-envelope: 8data-nested-relation-permission: 3analytics-field-permission-gate: 12analytics-relationship-path-admission: 28.tsand.mjsfiles: 0 errors and 0 warnings.Acceptance notes.
{ '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 onorigin/main, and it is outside this card; it went to the seat as a finding.queryObjects, so a host-suppliedgetReadScopeis not asked about it. In the shipped composition that provider is the security service'sgetReadFilter, the same row scope the engine applies when it reads the related object as the caller. That case is pinned inanalytics-nested-relation-filter("the related row scope").Generated by Claude Code