Repository navigation
Commit f257223
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
23 | 25 | | |
24 | 26 | | |
25 | 27 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | | - | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
44 | 46 | | |
45 | 47 | | |
46 | 48 | | |
| |||
0 commit comments