Skip to content

Commit f257223

Browse files
docs(changeset): scope two pending service-analytics BREAKING banners to the SQL echo on either strategy, and anchor the nested-relation note to its measured base (#21012)
Part of #20917 Clause-②: no Part of #20933 · Part of #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 #20639 consumes them. The order is this lane's dispatch note on #20917. The reasons are the `domain:spec` seat's pointer `5922364600` on #6021 (two banners) and the named follow-up in section ② of the delta review `5922352898` on PR #20916 (one paragraph). PR #20991 is the precedent: it makes the same banner correction to #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 (#20917) and the relationship-path admission (#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 #20917 note **Before, by source, at `95555e71` (the parent of #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 #20933 note **Before, by source, at `83480c6a` (the parent of #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 #20991 scoped #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 #20887 **Why** paragraph The dev measured on the branch point of PR #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 #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 #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 #20917 note's banner, now scoped to include `POST /api/v1/analytics/sql` on either strategy; 2. the #20933 note's banner, scoped the same way; 3. the first sentence of the #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 #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 #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:** #20935's note. PR #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 #20917; the dev writes a body only once) ### Patch round 1 At `db64c37690`, on the seat's note `5923016038` on #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:** - **#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. - **#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 #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 #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>
1 parent 173b68e commit f257223

3 files changed

Lines changed: 9 additions & 5 deletions

File tree

‎.changeset/20887-analytics-nested-relation-engine-answer.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Clause-②: yes (narrowing)
1717
- At a measure's own `filter` the form is refused with `400 INVALID_FILTER`, as the engine refuses it at an aggregation's own `filter`: put the condition in the query's `where`.
1818
- `POST /api/v1/analytics/sql` refuses a `where` carrying the form with `400 INVALID_FILTER`: no statement it could print reproduces a read of the related object as the caller. The query itself is answered by `POST /api/v1/analytics/query`.
1919

20-
**Why.** Measured on the base over one fixture with the real security layer (a related field the caller may not read, a related row scope, 1,001 matching related records). The native-SQL strategy flattened the form to a dotted member and joined the related table: through a dataset that `include`d the relationship it answered rows for a condition on a field the caller cannot read, answered a match past the engine's cap, and counted a measure filter carrying the form; without the declared join it named a table that does not exist (500), and a multi-valued relation was refused. The engine-aggregate strategy refused the form as a cross-object filter (400). The engine serves the form since the nested-relation filter landed in `where`.
20+
**Why.** Measured on the base before the field-level gate (#20917) and the relationship-path admission (#20933) landed, over one fixture with the real security layer (a related field the caller may not read, a related row scope, 1,001 matching related records). The native-SQL strategy flattened the form to a dotted member and joined the related table: through a dataset that `include`d the relationship it answered rows for a condition on a field the caller cannot read, answered a match past the engine's cap, and counted a measure filter carrying the form; without the declared join it named a table that does not exist (500), and a multi-valued relation was refused. The engine-aggregate strategy refused the form as a cross-object filter (400). The engine serves the form since the nested-relation filter landed in `where`.
2121

2222
**How.** The native-SQL strategy declines a query in which the form appears in the `where`, the dataset's own `filter` or a requested measure's `filter`, so the query runs on the engine-aggregate path, which hands the form to the engine as written.
2323

‎.changeset/20917-analytics-field-permission-gate.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Clause-②: yes (narrowing)
88

99
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that read a field the caller's field-level permissions hide, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->
1010

11-
**BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.**
11+
**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.**
1212

1313
**What changed.** `POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`
1414
and `POST /api/v1/analytics/dataset/query` now judge every field a query reads
@@ -19,7 +19,9 @@ filters. A member of an authored cube is judged by the field it resolves to,
1919
not by its name in the cube. A field the caller may not read answers
2020
`403 PERMISSION_DENIED`, in the words the engine uses for the same field. The
2121
native-SQL strategy, the one a SQL driver serves first, answered such queries;
22-
the ObjectQL strategy and the data API already refused them.
22+
the ObjectQL strategy already refused them on `POST /api/v1/analytics/query`
23+
and `POST /api/v1/analytics/dataset/query`, as the data API did, but printed
24+
the statement on `POST /api/v1/analytics/sql`.
2325

2426
**What is not affected.** A query that reads only fields the caller may read
2527
answers as before. A system context, and a caller with no permission sets, are

‎.changeset/20933-analytics-relationship-path-admission.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Clause-②: no (narrowing)
88

99
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses, or row-scopes, what the native-SQL strategy read from an object reached through an undeclared relationship path, the way a declared join to the same object already was; there is nothing for `objectstack migrate meta` to rewrite. -->
1010

11-
**BREAKING for analytics queries on a SQL deployment that read a related object through a relationship path the cube does not declare.**
11+
**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.**
1212

1313
**What changed.** The analytics door admits and row-scopes one object set
1414
before either strategy runs. It held the cube's base object and the joins the
@@ -40,7 +40,9 @@ deployment with no security service applies no object-level check, as on the
4040
data API.
4141

4242
**Refusals that change form.** On the ObjectQL strategy a related object the
43-
caller may not read was already refused; it now answers the analytics door's
43+
caller may not read was already refused on `POST /api/v1/analytics/query` and
44+
`POST /api/v1/analytics/dataset/query`, though `POST /api/v1/analytics/sql`
45+
printed the statement; on those two doors it now answers the analytics door's
4446
refusal rather than the engine's, the same one a declared join gets. A filter,
4547
a time window or a two-hop path through such an object moves from
4648
`400 INVALID_FIELD` to that `403`. A relationship path whose relationship name

0 commit comments

Comments
 (0)