fix(service-analytics): queryDataset compiles into a request scope and never writes the shared registries - #20380
Conversation
…ever the shared registry A dataset query compiled its dataset and registered the cube and compiled dataset in the service-wide registries before running the selection, so the dataset's name replaced whatever cube the registry held under it for every later reader, whatever the request's admission answered. queryDataset now compiles through a pure compile step and runs the executor against a request-scoped cube lookup that overlays the shared registry read-only. Every name-keyed read on the query path (ensureCube, admission and read-scope object sets, the strategy context's getCube, join allowlist and dataset scope) resolves through that scope. registerDataset keeps registering for the configuration door. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…stries alone A second caller's getMeta and the exact driver call its queries produce are snapshotted before and after a refused, an admitted, a hidden-name and a fresh-name dataset request, on both strategy paths; the boot-registered dataset is the control. The i18n meta pin now registers through the configuration door before its zh-CN query, since queryDataset no longer registers anything. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…door Two sign-ups over bootStack on both drivers: member B's meta, authored-cube query and saved-dataset query are equal to B's baseline after member A's refused, admitted and hidden-name dataset requests; the app's saved dataset is the control. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…ion stack Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…queries Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…line-dataset-isolation
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 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 c40dfec5a567840f43e3dcb7fbf54c921a309344 && git checkout c40dfec5a567840f43e3dcb7fbf54c921a309344
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b1cbd9277719a7c524ac83bc183db1b3a77d4139 15e21b984cb0ac455cab80f57b1593451b96f125 && git checkout -B drift-repro b1cbd9277719a7c524ac83bc183db1b3a77d4139 && git merge --no-ff 15e21b984cb0ac455cab80f57b1593451b96f125
node scripts/docs-audit/affected-docs.mjs --json b1cbd9277719a7c524ac83bc183db1b3a77d4139
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…st scope, publishing an inferred cube only after admission (objectstack-ai#20407) Part of objectstack-ai#20381 — scope items 1 and 2 (required). Scope item 3 stays open on the card as a decision; see "What stays open" below. Clause-②: no ## What this changes `AnalyticsService.query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) ran `ensureCube` over the SHARED cube scope, before `callCtx` asked the object-level read admission. `ensureCube` records what it mints in the scope it is given, so: - a request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the service-wide registry, and so in every member's `getMeta()`; - a suffix measure a caller named on a registered cube (`FIELD_sum`, `FIELD_count_distinct`, …) was appended to that cube for every later reader, refused or admitted. Both doors now run in the request `CubeScope` that PR objectstack-ai#20380 introduced for `queryDataset` — the same `requestScope()`, generalised to take no compiled dataset (no second mechanism): - `ensureCube`'s inference and augmentation both land in the call's own scope, and the admission, read scope and strategy read them from there. - `ensureCube` now returns the cube it INFERRED (nothing on the augmentation or declared paths). The ad-hoc doors hand it to `publishInferredCube` AFTER `callCtx` has admitted the request: that is "CubeRegistry source 3", kept as triage ruled, now written only for an admitted request. First registration wins, so a name the registry gained while the request was being admitted is never overwritten by an inferred cube. - An augmented cube is never published. The dataset door (`scopedService` / `queryIn` without the flag) publishes nothing, as before. No refusal is added, and no code or status changes. Boot-time `cubes` / `datasets` registration is untouched. No `packages/spec` change. **What `getMeta()` lists changes:** it no longer lists a cube inferred for a refused request, and it no longer lists a suffix measure some caller named on a registered cube. A cube inferred for an ADMITTED request is still listed (source 3), which is the open question below. ## Measurements All of these were taken on `origin/main` `df3ba164` plus this branch. - **Premise: holds.** `queryIn` called `ensureCube(query, scope)` before `callCtx` (the admission is in `callCtx`), and `generateSql` called `ensureCube(query, this.sharedScope)` before `callCtx`. Pre-fix route measurement (dist built from `df3ba164`): the new route pins are 12 of 24 red, and every negative leg fails on the observer-equality line after its `403` envelope assertion passed. The new unit pins are 20 of 24 red. - **PM assumption 1 (order is the whole defect): the naive order fix is refuted; request-scope-then-publish is what works.** For a name with no cube, `queryObjects` resolves the cube through the scope and returns an EMPTY object set, so an admission asked before `ensureCube` admits vacuously and never asks about the object. Ablation M0 below moved `callCtx` ahead of `ensureCube` on `query()`. The refused request was then SERVED on both strategies (`promise resolved "{ rows: [ { count: 5 } ] }" instead of rejecting`), and the admission provider was never called for the object. - **PM assumption 2 (augmentation never needs to be shared): holds.** The full package suite is green with augmentation request-local (131 files, 3077 tests). No in-tree reader outside the call reads an augmented cube. No existing test pinned the shared augmentation, so nothing was rewritten. `dotted-measure-refusal.test.ts`'s warm-registry case, which asserts the registered cube keeps `['count']`, stays green. - **PM assumption 3 (what source 3 is used for).** In-tree readers of a registered inferred cube: - `getMeta()`, which lists it; - the next request's `ensureCube` and strategies, which resolve the name to it and take the augmentation branch instead of re-inferring. Re-inference would mint the same cube through the same gates; - the plugin's boot log (`plugin.ts:1287`, the count and names). The client SDK exposes `analytics.meta()`. NOT MEASURED: Studio/objectui consumption (no sibling checkout in this container). `getMeta()` has no caller context (`IAnalyticsService.getMeta(cubeName?)` in `packages/spec`; the runtime route passes none), so it lists every registered cube to every caller. - **PM assumption 4 (`generateSql` has the same admission): holds.** Measured through the route: `/analytics/sql` answers the same `403 {"success":false,"error":{"code":"PERMISSION_DENIED","httpStatus":403}}` as `/analytics/query`, from the shared `callCtx`. No refusal was added to that door. - **Scope item 3: it leaks.** Measured through the route on `sqlite-wasm` and `memory`, on this branch's build. After the administrator's ADMITTED ad-hoc query over the walled object, member B lists that object's inferred cube in `GET /analytics/meta`, with the administrator's measure and dimension member names. B's `GET /data` of that object answers 403. B's `GET /meta/object/...` of the same object answers 200 with its field list, so the object name and field names are already readable to B there. What the listing adds is that an admitted caller queried the object since boot, and which member names that caller used. NOT MEASURED: a cross-org boot (the registry is process-wide). ## Tests (HEAD `16fc9f3b`) - `packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts` has 24 tests: both strategies × both doors, a second caller as the observer, and whole-snapshot equality. It covers: - REFUSED inferred (403 envelope, driver never ran, no registry entry); - REFUSED appended (the configured cube is still the authored object); - ADMITTED appended (served with the caller's measure, the cube is still the authored object); - ADMITTED inferred (the ORDER pin: the admission provider reads the registry when asked, and the inferred cube is not in it yet; afterwards it is registered, source 3); - the admission race (a registration made while the request is admitted is kept); - CONTROL: an admitted scalar metric works on a second request through the published cube, and that request's suffix measure stays its own. - `packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts` has 24 tests through the real route (`bootStack`, two sign-ups plus admin), with one boot per driver × door so one door's leg cannot pre-pollute the other's. Every refusal asserts status 403 plus `error.code` `PERMISSION_DENIED` plus `error.httpStatus` 403. The legs: - REFUSED inferred and REFUSED appended: B's `meta` and B's configured-cube answer are unchanged; - ADMITTED appended: served with A's measure, and B is unchanged; - CONTROL: an admitted scalar metric twice. B's answer is unchanged, and every cube B listed before is listed unchanged; - CONTROL: after the admin's admitted query over the walled object, B is still refused on that door. - `pnpm --filter @objectstack/service-analytics test`: 131 files, 3077 passed. `typecheck`: clean, and `tsc --listFiles` includes the new test. - Dogfood analytics files (the new one, both PR objectstack-ai#20380 route pins, `analytics-rls`, `analytics-label-scope`, `analytics-timezone`): 6 files, 54 passed. `pnpm --filter @objectstack/dogfood typecheck`: clean, and it includes the new test. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` over the actual changed paths on `16fc9f3b` gives 66 commands, identical to the dispatch-time list. All 66 exit 0. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Two commands needed a second run: - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3), because eight packages outside the dogfood closure had no `dist/`. After a turbo build of those packages (41/41 cached), it exited 0. - `check:type-check-debt` was first killed by my own batch timeout. Run on its own, it exited 0. - Lint, narrowed: `eslint --no-inline-config --format json` over the 3 changed TS files gives 3 files, 0 errors, 0 warnings. The population is these 3 files; none is ignored by `eslint.config.mjs`. The invariance: `eslint.config.mjs` never enables type-aware linting (its own header states this), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Ablations Each ablation used `scripts/ablation-replace.mjs` in wrap mode, with the fix committed first. The route legs rebuilt `@objectstack/service-analytics` and ran `scripts/ablation-dist-preflight.mjs` for both the present and the `--absent` readings. | leg | mutation | unit (24) | route (24) | |---|---|---|---| | M0 | `callCtx` moved ahead of `ensureCube` in `queryIn` (the naive order fix) | 6 red: refused inferred SERVED; admission never asked | — | | M1 | inferred cube also written to the shared registry inside `ensureCube`, i.e. before admission | 8 red: refused inferred ×4, order pin ×4 | 4 red: refused inferred, every driver × door | | M2 | augmented cube also written to the shared registry | 10 red: refused/admitted appended ×8, CONTROL ×2 | 8 red: refused/admitted appended, every driver × door | | M3 | first-registration-wins guard removed | 4 red: race pin ×4 | — | - The dist marker was present in 2 built files for M1 and M2. - Every restore was proven the same way: blob `95f2ef9a` equals the HEAD blob, `git diff HEAD` is empty, and the rebuilt dist carries no marker (`--absent` ✓, tree clean). - M2's first attempt was refused by the tool: the replacement contained the anchor, so the anchor count moved 1 → 1. Nothing was measured on that attempt, and its restore was proven. M2 was re-run with a two-line anchor. ## Overlap with PR objectstack-ai#20348 This PR is textually disjoint from objectstack-ai#20348's hunks except for the head of `generateSql`, and it does not contradict objectstack-ai#20348: - objectstack-ai#20348's `assertCubePublic(name, scope)` at the head of `queryIn` asks the call's `scope`, which is now the request scope reading the shared registry through. The answer is the same. - Whoever lands second should make objectstack-ai#20348's `generateSql` gate ask the call's `scope` (hoist `const scope = this.requestScope()` above it), so the gate and the rest of the call ask one scope. The answer is identical today. - objectstack-ai#20348 mints inferred cubes `public: true` because an admitted inferred cube stays registered, and it still does here. ## What stays open on objectstack-ai#20381 Scope item 3. An admitted inferred cube is listed by `getMeta()` to every member, including members the object-level admission refuses for that object. Closing that needs a door-shape choice: - retire source 3; - register the cube but leave it out of the listing; - a caller-aware `getMeta` (a `packages/spec` contract change plus the runtime route); - rule it no leak. Triage reserved that choice, so it goes back as `needs_decision` with the four-axis analysis. Whichever option is chosen, it is a small follow-up on top of this PR. ## Acceptance notes - A request that is admitted and then refused by the STRATEGY (for example the ObjectQL decline of a cross-object filter) still publishes its inferred cube, as before. Publication follows admission, per triage item 1. `infer-cube-relation-traversal.test.ts` ("mints the identical cube for both spellings") reads that cube through `getMeta` and stays green. This falls inside the item-3 decision space. - `cube-registry.ts`'s class doc still describes source 3 without the admission ordering. It is accurate, but less specific than `analytics-service.ts` now is. It was left untouched to stay inside the card's file surface. - The inference branch still logs its `auto-inferred a minimal cube` line (at `warn` for grouped queries) before admission. This is a server-side log only, unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ble (objectstack-ai#20348) Part of objectstack-ai#20282 Clause-②: yes (narrowing) ## Rework round 2 The order of record is seat comment `5863061968` on objectstack-ai#20282. It responds to the at-tier contract review `5863057917` on this PR, which recorded FAIL at `f84cd184df`. Claim `5859909653` is unchanged. There are new commits on top only, with no rebase, amend or force-push. The final head is `5ddea9580d`. 1. **The declaration.** The changeset and this body now read `Clause-②: yes (narrowing)`, and the changeset carries a `**BREAKING**` sentence. - It names the refused class: a query or SQL dry run against a cube declared `public: false`, and every cube in an artifact built by `os compile` before this release. - It gives the remedy: delete `public: false` from any cube that is meant to be queried, and recompile. - It carries the ADR-0087 disposition `registered analytics-cube-public-default-visible-enforced`. The gate printed the six categories, and `registered` is the true one because this PR adds the entry. - `check-adr-0087-registration` passes: 1 declared-breaking changeset with a disposition. Levels stay `minor`. 2. **The D3 entry.** `packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts` is a structured TODO with no tracker numbers in the author-shown fields. - It covers both holdings: an authored `public: false` now hides the cube and refuses its queries, and a pre-release compiled artifact must be recompiled. - `registry.ts` is regenerated, and `check:migration-registry` passes. - `spec-changes.json` and the upgrade guide do not project major-18 entries yet, so they did not move. - **Probe.** Base is `origin/main` `862b6ce869`, which does not have this PR. - I compiled a one-object, one-cube app whose cube omits `public` with the base CLI's `compile` command. The command class was run directly, because the oclif dev dispatcher loads every command module and needs the whole CLI closure built. - The artifact carries `"public": false` on the cube. - I served those artifact cubes on head's built `AnalyticsService`: `getMeta()` returned `[]`, and `query()` and `generateSql()` returned `404 CUBE_NOT_FOUND`. - The same cube recompiled with head's schema has `public: true`: listed, and both doors answer. - **Narrowing, declared.** The head leg ran at the service seam that the `/analytics/*` routes call, not through an HTTP boot. The verify closure rebuild did not get the lock (queue timeouts). - **Observation.** The open-core `os serve` artifact boot never threads `analyticsCubes` into the analytics service (`standalone-stack.ts` does not return them), so on that path an artifact's cubes are absent either way. The entry is worded for a host that registers cubes from an artifact. 3. **Non-disclosure.** A hidden cube's refusal is now `cubeNotFoundError`, the same function the unknown-name path throws: the same `CUBE_NOT_FOUND` / 404 and the same message. - The one shared message names both possibilities and still contains "is not a registered object", which the dataset door's missing-source sniffer reads. - **Pinned.** For `query()` and `generateSql()`, the same name hidden in one service and absent from another gives equal status, code, message, `cube` and own keys. - **Ablation** at `ac5260a28b`, via `scripts/ablation-replace.mjs`. The mutation made the hidden-path message differ: anchor 1→0, blob `1a1763ac7d04`→`7e5bc6dd03ab`. The two identity pins went red and the other 13 stayed green (`Tests 2 failed | 13 passed`). After restore the blob is back to HEAD's and `git diff HEAD` is empty. - **Docs.** The `cube-visibility.ts` docblock and the changeset sentence no longer claim `public: false` withholds the definition. They say what the key does: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The definition stays readable on the metadata door. - The ledger evidence anchor was renamed to `#cubeNotFoundError`. 4. **Stale texts.** Each now says that cubes declared `public: false` are omitted: - `content/docs/api/data-api.mdx` (`GET /analytics/meta`); - `content/docs/api/client-sdk.mdx` (the `analytics.meta` comment); - the `IAnalyticsService.getMeta` TSDoc. 5. **Merges of `origin/main`.** Three merges went through `scripts/pm/os-regen-merge.sh`: `5a6267f486`, then `862b6ce869`, then `15bf186f50`. Each regenerated artifact was regenerated with its generator (`gen:schema`, `gen:docs`, `gen:liveness-counts`), never hand-resolved. - The last merge brought in objectstack-ai#20380, which moves `queryDataset` into a request scope. - Two joint fixes followed. The visibility gate now asks the call's own cube scope. Otherwise a dataset named like a hidden cube was refused while any other name ran, which is an oracle for hidden names. This is pinned. - Main's `query-dataset-request-scope.test.ts` observer baseline, written while nothing read `public`, now expects discovery to omit its `public: false` fixture. 6. **Verification.** Service-analytics, spec and ledger checks, then gates: - The service-analytics full suite at `036af03342` passed with one exception: 130 of 131 files and 3067 of 3069 tests. The two failures were main's observer baseline, fixed in `5ddea9580d`. - Pins at `5ddea9580d`: 5 files, 108 tests passed. They are `cube-public-visibility`, `query-dataset-request-scope`, `analytics-service`, `query-dataset` and `cube-inference-gate`. - Service-analytics typecheck passes. Spec `analytics`, `migrations` and `contracts/analytics-service` tests: 194 of 194. - The spec build is green, and its ratchet still prints `data/Cube:public: false → true`. `check:generated` (15 of 15 current), `check:liveness` (`analytics_cube` live 18 / dead 9) and `check:migration-registry` are green. - `dispatch-gates --commands` at `5ddea9580d` derived 117 commands. All ran and were reconciled with `--ran`: 117 run, 0 NOT MEASURED, 0 unrun. - `check:query-options-erasure` and `check:type-check-debt` hit the 420 s runner cap on the loaded box and were re-run with a longer cap; both exited 0. - CI at `5ddea9580d`: 35 check runs, 33 success, 2 skipped. Mergeable: clean. ## Rework round 1 The order of record is seat comment `5861384124` on objectstack-ai#20282; claim `5859909653` is unchanged. The open question was ruled **A** in-seat: no ADR-0087 semantic entry, and `Clause-②: yes` and `minor` stay as shipped. There is one new commit on top, `2cfa134c34`, with no rebase and no force-push. 1. **Checklist text: `docs/qa/platform-checklist/areas/dashboards.json`, item `dashboards.cube-query`.** It moves from revision 1 to 2 and gains a history entry. Three pieces of text were rewritten to what is true after this PR: - The meta clause's verify text: `getMeta` lists a cube only when its `public` is not `false`. A `public: false` cube is omitted and refused by `query()`/`generateSql()` with 404 `CUBE_NOT_FOUND`. `showcase_delivery` declares no `public`, so it is visible by default. - The showcase-cube source line no longer says `public:false`. - The `getMeta` source line no longer says it "returns all registry cubes". - The verdict is unchanged. Every clause, step and negative still holds, because `showcase_delivery` stays visible with the same four measures and four dimensions. - `pnpm check:platform-checklist` is OK (266 items; symbol anchors 624/642, 18 on the named residual). 2. **Measurement only, no fix: the inline-dataset name collision, at the public door.** It **reaches**. - Boot: `@objectstack/verify` `bootStack` (the real Hono app, in process), with the `analytics-admission-fixture` stack on sqlite-wasm. - Two authored cubes, parsed through `CubeSchema` and passed as `AnalyticsServicePlugin({ cubes })`: `open_summary` (visible) and `hidden_summary` (`public: false`), both over `admission_open`. - Callers: A and B are two separate plain-member sign-ups. - **Before**, B calls `GET /api/v1/analytics/meta` and gets 200 listing `open_summary` "Open Summary (authored)" with measure `open_summary.authored_total`. B's `POST /api/v1/analytics/query` for that measure answers 200, `authored_total: 2`. - **A is refused, and the cube is replaced anyway.** A sends `POST /api/v1/analytics/dataset/query` with an inline dataset named `open_summary` over `admission_walled`, an object A has no grant on. The answer is **403 `PERMISSION_DENIED`**. B's meta then answers 200 listing `open_summary` titled "inline by A" with only `open_summary.hijack_cnt`. B's query of `authored_total` answers 400 `INVALID_FIELD` ("…which object 'admission_walled' does not have. Valid measures: hijack_cnt"). `queryDataset` registers the compiled cube before its admission gate runs. - **A is admitted.** On a second boot, the same request over `admission_open` answers 200 and makes the same replacement. B's `open_summary.hijack_cnt` answers 200, and the count is B's own RLS scope. - **It persists.** 3 s later, after unrelated traffic, B still sees A's definition. - **The hidden cube.** B's query of `hidden_summary` answers 404 `CUBE_NOT_FOUND` (this PR's gate). A's inline dataset named `hidden_summary` then answers 200. B's meta now **lists** `hidden_summary`, with A's definition. The authored `secret_total` is gone (400 `INVALID_FIELD`); the hidden definition was replaced, never disclosed. - **A restart restores it.** A fresh kernel from the same config serves the authored cube again. - The replacement is process-wide: it crossed users. No response returned rows outside the caller's own RLS scope. A multi-tenant (cross-org) boot was not measured. - The scratch probe was not committed, and no process is left running. 3. **Gates re-derived at `2cfa134c34`.** `dispatch-gates --commands` derived the same 113-command set, and all of it was re-run on this head. - Reconciled with `--ran`: 112 run with exit 0, 1 NOT MEASURED, 0 unrun. - `check:skill-examples` and `check:type-check-debt` first exited 3 and passed once their build prerequisites were built. The type-check-debt re-measure is OK: 4 ledger entries, 53 raw errors, none above its record. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (34 workspace packages have no `dist/` here; a whole-tree build for CI). Stage 1 of the `analytics-cube-semantics` family: `analytics_cube.public` and its default. Triage verdict ENFORCE (`5859510828`, execution note 1), claim `5859909653`. objectstack-ai#20282 remains open for the later stages (`format`, `granularities`, `refreshKey`, descriptions, and the objectui picker sub-issue). This PR does not touch any of them. ## What changes - **Spec.** `CubeSchema.public` defaults to `true` (it was `false`) and gains a `.describe()`. `false` hides the cube from the analytics API. It is visibility, not row security. The default change is declared in `DEFAULT_CHANGES_BY_MAJOR` (`packages/spec/scripts/lib/default-changes.ts`), which the authorable-defaults ratchet requires. - **Reader.** New module `packages/services/service-analytics/src/cube-visibility.ts`. `isCubePublic` is the one reader: a cube is exposed unless it declares `public: false`. The registry holds the input shape, so an omitted key reads as the schema default. `cubeNotFoundError` is the refusal, and it is shared with the unknown-cube path (rework round 2). - **Discovery.** `AnalyticsService#getMeta` omits a hidden cube. `getMeta(name)` for a hidden cube answers `[]`, the same answer as a name no cube has. - **Query doors.** `AnalyticsService#assertCubePublic` runs first in `query()` and in `generateSql()`. It runs before token resolution, cube inference, admission and every strategy, so the refusal leaves the registry untouched. The refusal is `404 CUBE_NOT_FOUND`, byte-identical to the one an unknown cube name gets (rework round 2). Its one shared message names both possibilities. It is never an empty result. - **Internal producers.** `inferCubeFromQuery`, `compileDataset` and `CubeRegistry.inferFromObject` each wrote a literal `public: false`, the old default. They now write `true`. Without that, the ad-hoc KPI path and the dataset door would refuse the cubes they mint themselves (see the internal-caller census below). - **Showcase.** `examples/app-showcase/src/data/analytics/showcase.cube.ts` drops its `public: false` (evidence below). - **Ledger.** The `public` row in `packages/spec/liveness/analytics_cube.json` flips `dead` to `live`, citing `cube-visibility.ts#isCubePublic`, `analytics-service.ts#getMeta` and `analytics-service.ts#assertCubePublic`, with the CLI threading as `producer`. The README cell and `state-counts.md` (regenerated) now read `analytics_cube` live 18 / dead 9. - **Generated projections, regenerated by their generators:** `authorable-defaults/data.json` (by the build) and `content/docs/references/data/analytics.mdx` (`gen:docs`). `state-counts.md` comes from `gen:liveness-counts`. No file under `skills/**` or any other governed surface changed, so this PR is not Tier H. ## Present state, measured before the change (base `4e0f72e8d2`) - **Declared:** `packages/spec/src/data/analytics.zod.ts`, `public: z.boolean().default(false)` under an `/** Access Control */` comment, with no describe. - **Readers:** none. `git grep` for `cube.public` or `.public` found no reader in `packages/services/service-analytics/src` or `packages/drivers`. - **Doors that list or resolve an authored cube:** - `GET /api/v1/analytics/meta` goes to `getMeta`. - `POST /api/v1/analytics/query` goes to `query()`. - `POST /api/v1/analytics/sql` goes to `generateSql()`. - The three routes above are served by the runtime `domains/analytics.ts`. - `POST /api/v1/analytics/dataset/query` goes to `queryDataset`, which uses `DatasetExecutor` and then `query()`. - The strategies resolve only `ctx.getCube(query.cube)`, the root cube. Joins reach objects, not cubes, so no second cube is resolved per query. - **Authored cubes in the repo:** exactly one writes `public`, `examples/app-showcase/src/data/analytics/showcase.cube.ts:98` with `public: false`. No platform object or qa fixture authors a cube `public` value. Test fixtures restated `public: false` in 45 files: 44 in `service-analytics`, plus `packages/runtime/src/cross-field-refusal-operand-withhold.test.ts`. - **Lit control:** the new pin file run on the unfixed code (commit `99f0e9d296`, test only) gave `Tests 10 failed | 3 passed (13)`: - `expected [ 'hidden_cube', 'visible_cube', …(2) ] to not include 'hidden_cube'` (getMeta lists the hidden cube). - `query()`: `promise resolved "{ rows: [ {} ], fields: [ { …(2) } ] }" instead of rejecting`. - `generateSql()`: `promise resolved "{ …(2) }" instead of rejecting`. - The 3 passes are the controls: a visible cube, an omitted-key cube and a parsed cube are all answered. ## The showcase decision, with evidence The cube is meant to be seen and queried, so this PR drops the `false` rather than keeping the cube hidden: - `examples/app-showcase/src/coverage.ts` marks `analyticsCubes` `demonstrated`, "Served by the foundational analytics capability (/api/v1/analytics/*)". - The cube's own docblock says it exists "to show BOTH analytics surfaces … cubes feed the analytics service (`/api/v1/analytics/*`)". - The platform checklist item in `docs/qa/platform-checklist/areas/dashboards.json` runs `GET /api/v1/analytics/meta?cube=showcase_delivery` and `POST /api/v1/analytics/query { cube: 'showcase_delivery', … }`. A hidden showcase cube would demonstrate a 404. It is pinned in `examples/app-showcase/test/gap-fill.test.ts` (`DeliveryCube.public === true`). ## Internal callers of the same door Only `AnalyticsServicePlugin` registers the `analytics` service in this repo. In-repo consumers are the runtime REST domain, the REST dataset door and `service-analytics` itself. `packages/runtime/src/domains/mcp.ts` and `action-execution.ts` call a different `getMeta` (the metadata protocol's). Two internal paths reach `query()` with a cube they minted: - The ad-hoc inference path registers what it infers, so the next request resolves it from the registry. - `queryDataset` uses `DatasetExecutor`, then `query()`, on the dataset's compiled cube. Both producers wrote the old default as a literal. Neither literal meant "hidden": if it had, enforcement would refuse the producer's own query. So they now write `true`. That keeps their behavior (visible and queryable, as before) and does not widen the door. No internal caller needs to reach a non-public cube, so there is no `needs_decision`. ## Pins `packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts`, 13 cases: - `getMeta` omits a hidden cube; `getMeta(hidden)` answers `[]`. - `query()` and `generateSql()` refuse with `{ code: 'CUBE_NOT_FOUND', status: 404, cube }`, and no strategy ran. - The refusal happens before the registry is touched. - A refusal is a rejection, not an empty result. - Controls: a visible cube, an omitted-key cube and a `CubeSchema`-parsed cube are all answered. - The three internal mints produce visible cubes: the ad-hoc path answers twice and is listed, and `queryDataset` answers. `packages/spec/src/data/analytics.test.ts` pins the default (`true`) and an explicit `false` that parses and is kept. ## Ablation The reader was mutated at HEAD `33348d6ec3` so that it stops filtering. - **Mutation.** `node scripts/ablation-replace.mjs --file packages/services/service-analytics/src/cube-visibility.ts --anchor 'return cube.public !== false;' --replacement 'return true;'` (WRAP mode, with the lock-held test run as its child). - **On-disk proof, from the tool.** The anchor count went from 1 to 0, the replacement count from 0 to 1, and the blob from `44d9fcf712d5` to `e144bb908748`. - **Why no rebuild was needed.** The pin file imports the service through relative `src` paths (`../analytics-service.js`, `../cube-visibility.js`), so no package `exports` and no `dist/` sit on the subject's resolution path. - **Red leg.** `src/__tests__/cube-public-visibility.test.ts` gave `Tests 6 failed | 7 passed (13)`. The six reds are exactly the two `getMeta` pins and the four door-refusal pins. The controls and the internal-producer cases stay green. The direction was red, as expected. - **Restore.** The blob after restore is `44d9fcf712d5`, equal to the HEAD blob, and `git diff HEAD` is empty. Two earlier attempts timed out in the verify-lock queue (exit 99) and never ran the test; each of their restores was proven the same way. - **Green leg, at the committed state `33348d6ec3`.** `Tests 93 passed (93)` across the pin file, `analytics-service.test.ts`, `query-dataset.test.ts` and `cube-inference-gate.test.ts`. ## Changeset `.changeset/20282-analytics-cube-public-enforced.md` bumps `@objectstack/spec` and `@objectstack/service-analytics` at `minor`. The reasons: - `Clause-②: yes (narrowing)` is BREAKING, and ships as `minor` under the launch-window convention. - Both packages are in the same `fixed` group. - The service change is a new enforcement, not a fix to an existing behavior. Since rework round 2, the changeset carries a `**BREAKING**` sentence and the ADR-0087 disposition `registered analytics-cube-public-default-visible-enforced`. It also records the upgrade notes: omitted key (no change), explicit `false` (now hidden), `os compile` artifacts that carry a materialized `false` (recompile), and the platform-minted cubes. ## Local verification Every reading below was taken at HEAD `33348d6ec3`, a clean tree that includes a merge of `origin/main` at `eea8787aa7`, unless a line says otherwise. - **`@objectstack/service-analytics`, full `vitest run`, at `c9382ff256`.** `Test Files 1 failed | 129 passed (130)`, `Tests 1 failed | 3053 passed (3054)`. - The one failure was this PR's own new case, a dataset fixture with no `dimensions`. It is fixed in `33348d6ec3`, which changes only that test file. - The 44 re-spelled fixture files are among the 129 that passed. - At `33348d6ec3` the pin run is `93 passed (93)`. - **`@objectstack/service-analytics` `typecheck`** (`tsc --noEmit`, which reaches the tests): exit 0 at `c9382ff256`. - **`@objectstack/spec`, targeted.** - `src/data/analytics.test.ts`, `analytics-strictness-batchd.test.ts`, `src/api/analytics.test.ts`, `src/contracts/analytics-service.test.ts` and `src/kernel/metadata-type-schemas.test.ts` gave `Tests 227 passed (227)`. - The spec build is green, including the authorable-defaults ratchet, which now prints the declared `data/Cube:public: false → true`. - `check:generated` reports all 15 artifacts up to date. - `check:liveness` is green: `analytics_cube 27 classified (live 18, dead 9)`. - **`packages/runtime/src/cross-field-refusal-operand-withhold.test.ts`**, against a rebuilt `service-analytics` dist: `Tests 11 passed (11)`. - **Lit control, on the unfixed code at `99f0e9d296`.** `Tests 10 failed | 3 passed (13)` (the readings are quoted above). - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 113 commands. All were run and recorded, then reconciled with `--ran`: 111 exited 0, 2 are NOT MEASURED, 0 are unrun. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET, 64 workspace packages have no `dist/` in this worktree, so this is a whole-tree build for CI. - NOT MEASURED: `check:type-check-debt`, reason: PREREQUISITE NOT MET, `@objectstack/driver-turso` has no built types. - `check:skill-examples` first exited 3, because the client packages were not built. It was re-run after building them and exited 0. - NOT MEASURED: `examples/app-showcase/test/gap-fill.test.ts`, reason: the showcase's dependency closure is not built here (`Failed to resolve entry for package "@objectstack/connector-mcp"`, so the run never reached a test). The pinned fact itself was measured lock-free: evaluating `src/data/analytics/showcase.cube.ts` with `tsx` prints `DeliveryCube.public = true`. The file runs in CI. - NOT MEASURED locally, declared to CI: `@objectstack/spec`'s full `pnpm test` and `typecheck`. - Unit-level only: no dev server was booted. The door behaviour is pinned at the service seam that the runtime `/analytics/*` routes call. ## Acceptance notes Observations, not filed: - **`MemoryAnalyticsService` does not read `public`.** `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService#getMeta` and `#query` ignore the key. - It is not a door in any in-repo composition: only `AnalyticsServicePlugin` registers the `analytics` service. - Behind `AnalyticsService` it is reached only through the gated `query()` and `generateSql()`, and `AnalyticsService#getMeta` never consults it. - Carrier: the objectstack-ai#20282 descriptions stage, whose reader list already names both `getMeta` implementations. - **Stale checklist wording** in `docs/qa/platform-checklist/areas/dashboards.json`: fixed in rework round 1 (item 1 above). - **An inline dataset can replace an authored cube.** This was measured at the public door in rework round 1 and filed as objectstack-ai#20356. Main has since fixed it (objectstack-ai#20380), and that fix is merged into this branch in round 2, where the visibility gate was adapted to its request scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20356
Clause-②: no
What this changes
AnalyticsService.queryDatasetno longer writes the service-wideCubeRegistryor the compiled-dataset registry. It used to register the dataset's compiled cube under the dataset's name before running the selection and before any admission was asked, so that name then meant one request's definition for every later reader until restart.compile(dataset)binds this service's probes and registers nothing.registerDataset(the configuration door:AnalyticsServiceConfig.datasetsand embedders) iscompile+ register, unchanged in behaviour.queryDatasetcalls onlycompile.CubeScope(getCube,getCompiledDataset,register) is threaded through the query path. The shared scope reads and writes the registries. AqueryDatasetcall gets a request scope: its own compiled dataset answers its name, every other name reads the shared scope read-only, andregisterwrites only to the request scope.query()is nowqueryIn(sharedScope, …), and theDatasetExecutorof a dataset call queries through a face whosequery()isqueryIn(requestScope, …). Every gate is the same code; only the answer to "which cube does this name mean" differs.queryDatasetas a registration door are corrected incube-registry.tsanddataset-compiler.ts.No new refusal is added. A dataset whose name matches a configured cube is served from its own definition and no longer meets that cube (triage note 3).
Mechanism assumptions, measured
Other registry writes.
registerDatasetwas not the only request-time write reachable fromqueryDataset.ensureCube's measure augmentation also registered into the shared registry, throughDatasetExecutor→query(), when a selection named an undeclared suffix measure. It now writes into the request scope, which is pinned. The same twoensureCubewrites (inference and augmentation) remain request-time shared writes on the/analytics/queryand/analytics/sqldoors. They are not the dataset door, and they are reported, not changed (see Acceptance notes). The boot-time writes are unchanged:config.cubesandconfig.datasetsin the constructor.Name-keyed reads on the query path. Each of these reads the compiled cube by name, and each now resolves through the call's scope:
ensureCube's existence and source-field gates;queryObjects, which is both the object-level admission set and the read-scope set;getCube(both strategies'canHandle,executeandgenerateSql);getAllowedRelationships(the NativeSQL join allowlist) andgetDatasetScope(both strategies), which read the compiled-dataset registry.ObjectQL's
resolveFkAttrreads no registry, andqueryCapabilities(cube)is a config hook.DatasetExecutoritself readscompiled.cubedirectly, but it issuesquery({ cube: name }), which is why it needs the scoped face.Doors.
POST /api/v1/analytics/dataset/query(rest-server.ts) callsqueryDataset.GET /api/v1/analytics/meta(runtime/src/domains/analytics.ts) callsgetMeta(), which readscubeRegistry.getAll(), the shared registry.Hidden cube. On
mainnothing readsCube.public: three producers write it and no reader exists inservice-analytics,runtimeorrest. So "stays hidden from meta" is NOT MEASURABLE on this tree. What is pinned is that the registry entry keeps the author'spublic: falsedefinition (unit test) and that member B's wholemetaanswer equals B's baseline (route test). Onceanalytics_cube.publicenforcement lands, that same equality asserts that the cube stays omitted.Tests (all on
15e21b98)src/__tests__/query-dataset-request-scope.test.ts(new, both strategy paths, 12 cases). A second caller'sgetMeta()and the exact driver calls its queries of the configured cube and of the boot-registered dataset are snapshotted before and after each of these requests:PERMISSION_DENIED/ 403, and that the driver never saw the walled object);public: falsename;The control is the dataset registered at construction, which still serves and keeps its compiled filter.
packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts(new,bootStack, two sign-ups,sqlite-wasm+memory, 10 cases). Member B'smeta, B's authored-cube query and B's saved-dataset query equal B's baseline after member A's requests: refused (403PERMISSION_DENIED), admitted (200 from A's definition, A's own row count), hidden-name (200, with no new refusal) and saved-name. The app's saved dataset is the control.dataset-i18n-label-resolution.test.ts: the zh-CN meta pin relied onqueryDatasetregistering. It now registers throughregisterDatasetfirst, and its intent is kept: a zh-CN query leaves the published titles in the source language.pnpm --filter @objectstack/service-analytics typecheckpasses, andvitest rungives 130 files / 3053 tests, all passing.pnpm --filter @objectstack/dogfood typecheckpasses, and the four analytics dogfood files give 28 tests, all passing.Ablations (each run with the fix committed first, mutation landing proven on disk by
scripts/ablation-replace.mjs, and restore proven by blob equal to HEAD plus an emptygit diff HEAD):queryDatasetcallsregisterDatasetagain. 10 of 12 go red. The two baselines stay green.dist/. Mutate, rebuild, thenablation-dist-preflightfinds the marker present in 2 built files. 8 of 10 go red. In the first red leg, B's authored-cube query answers400 INVALID_FIELDand B'smetalists the request's definition. The restore leg rebuilds, the marker is absent from all 6 built files, the tree is clean, and all 10 cases pass again.Gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsover this branch's 7 changed paths gives 66 commands. All 66 exited 0, and the--ranreconciliation reports 66/66 with 0 NOT MEASURED.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 packages had nodist/). It passed after those packages were built, all from the turbo cache.eslint --no-inline-configover the 6 changed TS files, all in the config's**/*.{ts,…}population, gives 6 files, 0 errors and 0 warnings. The config enables no type-aware linting (noparserOptions.projectorprojectService) and no cross-file rules, so this diff cannot change the verdict on an untouched file. The repo-widepnpm lintis left to CI.Merge note for the later lander (PR #20348,
analytics_cube.public)PR #20348 adds
assertCubePublic(queryInput.cube)at the top ofquery(). It readsthis.cubeRegistry.get(name). Here,query()'s body lives inqueryIn(scope, …), so a textual merge lands that line inqueryIn, still reading the shared registry. It should readscope.getCube(name). Read from the shared registry, a dataset request named like a hidden cube would be refused404 CUBE_NOT_FOUNDwith the hidden-cube message, which is a new refusal on this door and an existence signal for hidden names. The dogfood HIDDEN leg asserts 200, so a merge that leaves it on the shared registry goes red there.getMeta's visibility filter correctly stays on the shared registry.Acceptance notes
/analytics/queryand/analytics/sqldoors still write the shared registry at request time, before admission (ensureCubeinference and augmentation). This is reported in the dev report as a separate finding. TheCubeScopeseam added here is the natural place to scope them, but that changes the ad-hoc door's documented registry source and overlaps PR feat(analytics): enforce analytics_cube.public and default it to visible #20348'sinferCubeFromQueryedit, so it is not done here.strategies/native-sql-strategy.ts(the join-allowlist comment near thegetAllowedRelationshipsrefusal) still saysqueryDatasetregisters the compiled dataset first. The invariant it states still holds, through the request scope: the allowlist answers from the compiled dataset and never falls through to the hook. The file is outside this card's declared surface, so the wording is left for whoever next touches it.queryDatasetcall compiled is no longer listed bygetMeta()or queryable by name afterwards. No in-repo caller relies on that. A search for runtime code querying a dataset name as a cube found none.Generated by Claude Code