feat(analytics): enforce analytics_cube.public and default it to visible - #20348
objectstack-fleet[bot] merged 22 commits into
Conversation
…eta and every query door Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
CubeSchema.public defaults to true (it was false while nothing read it). service-analytics reads it: getMeta omits a cube declared public: false, and query() / generateSql() refuse it with CUBE_NOT_FOUND / 404 before any strategy runs. The three internal mints (inferCubeFromQuery, compileDataset, CubeRegistry.inferFromObject) write the visible default, so the ad-hoc KPI path and the dataset door keep answering. Test fixtures that restated the old default are re-spelled public: true; the showcase cube, which is the app's /analytics/* demonstration, drops its public: false. The liveness row for public flips to live. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…s projections data/Cube:public false -> true is declared in DEFAULT_CHANGES_BY_MAJOR (the authorable-defaults ratchet refuses an undeclared default move); the reference page and the liveness state counts are regenerated by their generators. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…alytics-cube-public-enforced
…clared dimension Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 d1ac2513985ce7435c9a875702a3ed658611fdda && git checkout d1ac2513985ce7435c9a875702a3ed658611fdda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40b315b03345e334069dd454aecaf7016adbea4f 93376b42076d6f4708fd00ba7725434c4deb109f && git checkout -B drift-repro 40b315b03345e334069dd454aecaf7016adbea4f && git merge --no-ff 93376b42076d6f4708fd00ba7725434c4deb109f
node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f
|
…ced cube visibility getMeta no longer returns every registry cube: it omits one declaring public: false, and query()/generateSql() refuse it with 404 CUBE_NOT_FOUND. The showcase cube no longer declares public: false. Text only; the item's clauses and verdict are unchanged. Revision 2 with its history entry. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…alytics-cube-public-enforced
The os-regen driver kept the branch's side of state-counts.md in the merge of origin/main; os-regen-merge step 2 took main's side and this commit regenerates it with gen:liveness-counts over the merged ledger (analytics_cube live 18 / dead 9; total live 937, dead 165, 1117 rows). Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 105/105 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
…alytics-cube-public-enforced
… merged tree os-regen-merge took main's side of content/docs/references/data/analytics.mdx and packages/spec/liveness/state-counts.md (both sides changed them); this regenerates both from the merged source (gen:schema + gen:docs, gen:liveness-counts). Against main each differs only by this PR's rows. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…fusal, byte for byte A cube declared public: false is now refused by query() and generateSql() with cubeNotFoundError, the same CUBE_NOT_FOUND / 404 and the same message a name that is neither a cube nor an object gets, so the refusal does not confirm that a hidden cube exists. The one shared message names both possibilities. The identity is pinned for both doors. The visibility docblock, the ledger row and the two hand-written API pages now say only what the key does: left out of /analytics/meta, queries and SQL refused. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…alytics-cube-public-enforced
…ister its D3 entry The changeset now reads Clause-②: yes (narrowing), with a BREAKING sentence naming the refused class (queries and SQL against a cube declared public: false; pre-release os compile artifacts, which materialize the old default) and its remedy. The ADR-0087 D3 semantic entry analytics-cube-public-default-visible-enforced carries both holdings for an upgrading author; the registry is regenerated (the major-18 entries are not projected into spec-changes.json or the upgrade guide yet). The IAnalyticsService.getMeta TSDoc says hidden cubes are omitted. Levels stay minor. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…ity narrowing registered analytics-cube-public-default-visible-enforced, the D3 entry this PR adds. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…alytics-cube-public-enforced
os-regen-merge took main's side of packages/spec/liveness/state-counts.md (both sides changed the shared total row); gen:liveness-counts re-derives it from the merged ledger. Against main it differs only by this PR's analytics_cube flip (live 18 / dead 9; total live 940 / dead 162). Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…d never writes the shared registries (objectstack-ai#20380) Fixes objectstack-ai#20356 Clause-②: no ## What this changes `AnalyticsService.queryDataset` no longer writes the service-wide `CubeRegistry` or 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. - **Pure compile.** A new private `compile(dataset)` binds this service's probes and registers nothing. `registerDataset` (the configuration door: `AnalyticsServiceConfig.datasets` and embedders) is `compile` + register, unchanged in behaviour. `queryDataset` calls only `compile`. - **A request scope for every name-keyed read.** A small internal `CubeScope` (`getCube`, `getCompiledDataset`, `register`) is threaded through the query path. The shared scope reads and writes the registries. A `queryDataset` call gets a request scope: its own compiled dataset answers its name, every other name reads the shared scope read-only, and `register` writes only to the request scope. - **One body for both.** `query()` is now `queryIn(sharedScope, …)`, and the `DatasetExecutor` of a dataset call queries through a face whose `query()` is `queryIn(requestScope, …)`. Every gate is the same code; only the answer to "which cube does this name mean" differs. - Comments that described `queryDataset` as a registration door are corrected in `cube-registry.ts` and `dataset-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 1. **Other registry writes.** `registerDataset` was not the only request-time write reachable from `queryDataset`. `ensureCube`'s measure augmentation also registered into the shared registry, through `DatasetExecutor` → `query()`, when a selection named an undeclared suffix measure. It now writes into the request scope, which is pinned. The same two `ensureCube` writes (inference and augmentation) remain request-time shared writes on the `/analytics/query` and `/analytics/sql` doors. They are not the dataset door, and they are reported, not changed (see Acceptance notes). The boot-time writes are unchanged: `config.cubes` and `config.datasets` in the constructor. 2. **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; - the strategy context's `getCube` (both strategies' `canHandle`, `execute` and `generateSql`); - `getAllowedRelationships` (the NativeSQL join allowlist) and `getDatasetScope` (both strategies), which read the compiled-dataset registry. ObjectQL's `resolveFkAttr` reads no registry, and `queryCapabilities(cube)` is a config hook. `DatasetExecutor` itself reads `compiled.cube` directly, but it issues `query({ cube: name })`, which is why it needs the scoped face. 3. **Doors.** `POST /api/v1/analytics/dataset/query` (`rest-server.ts`) calls `queryDataset`. `GET /api/v1/analytics/meta` (`runtime/src/domains/analytics.ts`) calls `getMeta()`, which reads `cubeRegistry.getAll()`, the shared registry. 4. **Hidden cube.** On `main` nothing reads `Cube.public`: three producers write it and no reader exists in `service-analytics`, `runtime` or `rest`. So "stays hidden from meta" is **NOT MEASURABLE on this tree**. What is pinned is that the registry entry keeps the author's `public: false` definition (unit test) and that member B's whole `meta` answer equals B's baseline (route test). Once `analytics_cube.public` enforcement 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's `getMeta()` 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: - a refused dataset under a configured cube's name (asserts `PERMISSION_DENIED` / 403, and that the driver never saw the walled object); - an admitted one, served from its own object; - a `public: false` name; - fresh names, one refused and one admitted with an augmented measure. 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's `meta`, B's authored-cube query and B's saved-dataset query equal B's baseline after member A's requests: refused (403 `PERMISSION_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 on `queryDataset` registering. It now registers through `registerDataset` first, and its intent is kept: a zh-CN query leaves the published titles in the source language. - `pnpm --filter @objectstack/service-analytics typecheck` passes, and `vitest run` gives 130 files / 3053 tests, all passing. `pnpm --filter @objectstack/dogfood typecheck` passes, 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 empty `git diff HEAD`): - **A, request-path registry write restored (unit).** `queryDataset` calls `registerDataset` again. 10 of 12 go red. The two baselines stay green. - **B, admission set read from the shared registry (unit).** The refused leg and the fresh-name leg go red, because the request resolved instead of rejecting. This shows that a scope applied to the strategy but not to the admission set would admit a read of the walled object. - **A, route level through `dist/`.** Mutate, rebuild, then `ablation-dist-preflight` finds the marker present in 2 built files. 8 of 10 go red. In the first red leg, B's authored-cube query answers `400 INVALID_FIELD` and B's `meta` lists 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 --commands` over this branch's 7 changed paths gives 66 commands. All 66 exited 0, and the `--ran` reconciliation reports 66/66 with 0 NOT MEASURED. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist/`). It passed after those packages were built, all from the turbo cache. `eslint --no-inline-config` over 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 (no `parserOptions.project` or `projectService`) and no cross-file rules, so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Merge note for the later lander (PR objectstack-ai#20348, `analytics_cube.public`) PR objectstack-ai#20348 adds `assertCubePublic(queryInput.cube)` at the top of `query()`. It reads `this.cubeRegistry.get(name)`. Here, `query()`'s body lives in `queryIn(scope, …)`, so a textual merge lands that line in `queryIn`, still reading the shared registry. It should read `scope.getCube(name)`. Read from the shared registry, a dataset request named like a hidden cube would be refused `404 CUBE_NOT_FOUND` with 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 - The `/analytics/query` and `/analytics/sql` doors still write the shared registry at request time, before admission (`ensureCube` inference and augmentation). This is reported in the dev report as a separate finding. The `CubeScope` seam added here is the natural place to scope them, but that changes the ad-hoc door's documented registry source and overlaps PR objectstack-ai#20348's `inferCubeFromQuery` edit, so it is not done here. - `strategies/native-sql-strategy.ts` (the join-allowlist comment near the `getAllowedRelationships` refusal) still says `queryDataset` registers 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. - The draft-preview branch still reads the pending seed rows before its own admission check. This is a read, and nothing is returned on refusal. It is unchanged. - The one observable difference is stated in the changeset: a cube that only a `queryDataset` call compiled is no longer listed by `getMeta()` 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](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…alytics-cube-public-enforced # Conflicts: # packages/spec/liveness/README.md
os-regen-merge took main's side of packages/spec/liveness/state-counts.md (both sides changed the shared total row); gen:liveness-counts re-derives it from the merged ledger. Against main it differs only by this PR's analytics_cube flip (live 18 / dead 9; total live 940 / dead 148). The liveness README conflict in the merge kept main's connector row and this PR's analytics_cube row. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…s the retired registration `cube-public-visibility.test.ts` asserted that an inferred cube is registered with `public: true` and listed by `getMeta`. Under the retirement it is answered on every request and never registered or listed; the case now pins that. `inferCubeFromQuery` keeps its `public: true` literal (moot, and the pending #20282 release note says it is written) with a comment that no longer claims the cube is registered. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
…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>
…no request writes the shared cube registry (objectstack-ai#20381) (objectstack-ai#20433) Fixes objectstack-ai#20381 Clause-②: no Item 3 of objectstack-ai#20381, under director ruling `5866558247` (letter A, maintainer 「同意」): registry source 3 is retired. Items 1–2 landed in PR objectstack-ai#20407 (`50e273fd`), so this round completes the card. ## What changes - The ad-hoc `query` and `sql` doors (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) no longer publish the cube `ensureCube` infers for an ADMITTED request. That cube stays in the call's request scope, the one PR objectstack-ai#20407 gave these doors, and it is dropped with the call, like a measure appended to a configured cube. The next request for the same name infers the cube again, through the same existence and source-field gates, and gets the same answer. - The shared `CubeRegistry`, and therefore `getMeta()` and `GET /api/v1/analytics/meta`, is now written by configuration only: manifest cubes (`AnalyticsServiceConfig.cubes`) and `registerDataset` datasets. `/analytics/meta` lists the authored vocabulary, whatever traffic the server has seen since boot. - `publishInferredCube` had no caller left and is removed, and `ensureCube` returns `void` again. The `CubeRegistry` class docblock now lists the two configuration sources and states that no request writes the registry. The `CubeScope`, `queryIn`, `requestScope`, `ensureCube` and objectstack-ai#5918 comments in `analytics-service.ts` no longer describe the publication. - No refusal, code or status changes. There is no `packages/spec` change, no visibility marker and no caller-aware `getMeta`; options B, C and D are not taken. Landing point, as dispatched: `packages/services/service-analytics/src/analytics-service.ts` (the producer of the write) and `cube-registry.ts` (docblock only). ## Tests: re-observed, not deleted On the fix commit, 36 cases in six service-analytics test files went red; each of those files read an inferred cube back through `getMeta` or the shared registry. PR objectstack-ai#20348, which landed while this round ran, added a seventh such case. Each case is re-observed through a window that still exists after A: the cube the request's own strategies are handed. A probe strategy placed ahead of the built-in ones records `ctx.getCube(query.cube)` and always declines, so the chain runs as it would without the probe. Where an assertion's subject was the retired registration itself, the assertion now pins its absence. | File | Was | Now | |---|---|---| | `infer-cube-where-spelling-parity.test.ts` | dimension keys via `getMeta('deal')` | the same keys, read from the request's cube | | `infer-cube-relation-traversal.test.ts` | `run()` members via `getMeta` | the request's cube | | `dotted-measure-refusal.test.ts` | `run()` measures via `getMeta`; block 2 case 1 asserted that the first query warmed the registry | the request's cube; case 1 now pins that the first query warms nothing and that the second, cold again, is still refused (the augmentation site stays covered by the block's authored-cube case) | | `analytics-service.test.ts` 'auto-infer' | `cubeRegistry.has('case')` is true | the request was handed a cube named `case` and backed by `case`; `has('case')` is false | | `cube-inference-gate.test.ts` KPI case | `cubeRegistry.get('crm_account')` is truthy | it is undefined; a second request is served the same way and asks the existence gate again | | `adhoc-query-request-scope.test.ts` (PR objectstack-ai#20407) | the admitted inference "publishes after admission (source 3)"; the CONTROL case runs "through the published cube" | the admitted inference is served from its own cube, and both the registry and the observer's view are exactly unchanged (the order pin is kept); the CONTROL case is now "a second same-name request infers again and gets the same answer" | | `cube-public-visibility.test.ts` (PR objectstack-ai#20348) | the ad-hoc KPI path's inferred cube is registered `public: true` and listed | it is answered on every request, and never registered or listed | The route pin is `packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts`: `bootStack` with two sign-ups plus the administrator, on sqlite-wasm and memory, through both doors. - Both CONTROL legs are tightened from `arrayContaining` to exact equality on member B's cube list. B's `meta` is also kept as the raw response bytes. - After the administrator's admitted ad-hoc query over the walled object, B's `meta` is byte-identical and B's query of the configured cube is unchanged. B's own query of that object is still refused with the ADR-0112 envelope. - An admitted scalar metric is re-inferred on a second request and answers identically. Between the two requests, not even the asker's own `meta` lists the name. - A configured cube still serves: the baseline leg, and every observation. ## Evidence (head `8214a5b6` unless stated) - `pnpm --filter @objectstack/service-analytics typecheck` is clean. `vitest run`: 132 files, 3093 passed. `tsc --listFiles` includes every changed test file. - `pnpm --filter @objectstack/dogfood typecheck` is clean, and `--listFiles` includes the route pin. The six analytics dogfood files: 54 passed, on a `dist` rebuilt after merging `main`. - **Ablation M1** puts the publication back at both ad-hoc sites, after `callCtx`, as in `50e273fd`. It was applied with `scripts/ablation-replace.mjs` (anchor 2 → 0) and predicted before running. - Unit, 7 files: 10 red / 132 green. The red cases are the admitted-inference and CONTROL cases (4 + 2), auto-infer, the KPI gate case, the objectstack-ai#20348 KPI case and the dotted warm case. - Route, after rebuilding `service-analytics`, with `ablation-dist-preflight` finding the marker in 2 dist files: 8 red / 16 green, both CONTROL legs on all four boots. For example: `expected [ 'open_summary', …(3) ] to deeply equal [ 'open_summary', …(2) ]`, with `+ "admission_walled"`. - Restore: blob equals `HEAD`, `git diff HEAD` is empty, rebuilt, and `--absent` preflight is green with a clean tree. - On the pre-merge head `fccfc3e5` the same ablation gave 9/117 and 8/16. - **Ablation M2**, on `fccfc3e5`, proves the probe window can fail. It makes an array `where` seed no dimension, the pre-objectstack-ai#5353 shape. Across parity and traversal: 16 red / 24 green. In parity, the 11 conjunction table cases, ALONGSIDE and the two dotted array-versus-object cases went red; both `$or` cases stayed green, as the file predicts. In traversal, the two array-spelling mint cases went red. The restore was proven the same way. - **Gates**: `dispatch-gates --commands` derives 66 commands over the 12 changed paths. `--ran` reconciles 66 derived, 66 run, 0 NOT MEASURED and 0 UNRUN. 65 exit 0; `check:empty-changeset` exits 1 by design (see Changesets). - **Lint, narrowed**: eslint `--no-inline-config --format json` over the 10 changed `.ts` files reports 10 files, 0 errors and 0 warnings. The population is those 10 files, none ignored. Invariance: `eslint.config.mjs` never enables type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is left to CI. ## Changesets - New: `.changeset/20381-retire-inferred-cube-source.md`, `@objectstack/service-analytics` `patch`, `Clause-②: no`. - **A deliberate correction of a pending release note, for confirmation:** `.changeset/20381-adhoc-cube-request-scope.md` was added by PR objectstack-ai#20407 and is not yet released. It said that an admitted request's inferred cube "still registers the cube it inferred, as before" and that it "is still listed". This PR makes both sentences false, so they are removed and replaced by a pointer to the new entry. `check:empty-changeset` refuses any PR that modifies a changeset it did not add. For this DELIBERATE CORRECTION class it stays red by design (ruling D on objectstack-ai#17712), and it needs a person's confirmation here. Restoring the file from `50e273fd` would clear the gate, but the release would then ship both statements in one CHANGELOG. ## Overlap with PR objectstack-ai#20348 PR objectstack-ai#20348 landed first (`f2c7eef5`), and `main` is merged here (`dfd185d7`) with no textual conflict. - Its `public: true` on the inferred cube is moot under ruling A, because no visibility verdict ever reads that cube. The literal is **kept**: the pending note `.changeset/20282-analytics-cube-public-enforced.md` says the inferred cube "now writes `true`", and dropping the key would falsify a second foreign changeset. Only its comment, which said the cube is registered, is corrected. - Its `generateSql` gate still asks `this.sharedScope` rather than the call's scope. The answer is identical, because a fresh request scope with no dataset reads through to the shared registry, so this is noted, not changed. - Its other changes are untouched. ## Acceptance notes - Log frequency: for a GROUPED ad-hoc query over an object with no configured cube, `ensureCube`'s `warn` ("No cube registered …; auto-inferred a minimal cube …") used to fire once per name per process, because the second request found the published cube. It now fires on every such request; scalar metrics stay at `debug`. This was not measured against real dashboard traffic, and it is noted, not changed: the ruling adds no state. - `content/docs/api/data-api.mdx`, in its `GET /analytics/meta` section, says that a cube a query references "is lazily auto-inferred from that query's shape". It does not claim the cube gets listed, but it could now say that it does not. That file is outside this card's file surface. - Per the ruling, the two unmeasured cases (a cross-org boot, and an FLS-hidden field used as a dimension) cannot leak through `meta` for inferred cubes once this lands. They stay as notes for the ADR-0106 D5 audit. - The refusal tests that assert `cubeRegistry.get(...)` is undefined after a rejected query (the three source-field gate files, `cube-inference-gate`, `dotted-measure-refusal`) now hold by construction for every request, not only for refused ones. They are left as they are. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…the record key is the member's name (objectstack-ai#20300) (objectstack-ai#20458) Fixes objectstack-ai#20300 Clause-②: no (narrowing) Retires the inner `name` on analytics cube measures and dimensions (`MetricSchema.name`, `DimensionSchema.name`). `measures` and `dimensions` are records, and the record key was always the member's identity: `GET /api/v1/analytics/meta` publishes every member as `CUBE.KEY`, and every consumer resolves a member by indexing the bag with its key. The inner copy was REQUIRED, read by nothing, and silently ignored when it disagreed with its key. ADR-0049 enforce-or-remove, by triage's verdict `5859547666` (RETIRE) under the maintainer's criterion, verbatim: 「每族该问的是:主流平台有没有这个能力 —— 有 ⇒ 补消费端(一次做对);没有 ⇒ 退役,而不是看仓里有没有人读」. Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. **Tier H.** The diff touches `skills/objectstack-ui/rules/dashboards.md` (a deletion only; see Deviations 1). It lands on the maintainer's word, then the seat lands it. 103 files, +1446 / -423 (1869 changed lines, under the 5,000 line class). ## Patch round after objectstack-ai#20390 (head `f639af5f3`) The sections below describe `c4771e604`. This head adds two commits and nothing else: - **`5ce26b0b2`** merges `origin/main` `75b2169243` through `os-regen-merge.sh`. The one hand conflict, `packages/spec/vitest.repo-tests.json`, was resolved by stacking both entries. Main’s step-18 siblings and this PR’s entries are all present in the four registries. - **`f639af5f3`** stamps `retiredAfter: 17.4.0` on `cube-member-inner-name-removed`. objectstack-ai#20390 (`e956924e1`) made the stamp required on every `retiredFromLoadPath: true` conversion. This entry is unpublished, so it takes the package label, as `view-list-tabs-removed` and `action-aria-removed` do on `main`. The net diff is 103 files, +1447 / −423: the stamp is the one added line. CI is green on this head. At-tier record `5875291969`: PASS. ## What this head carries (`c4771e604`) | surface | change | | --- | --- | | schema | `retiredKey()` tombstones on `MetricSchema.name` and `DimensionSchema.name` (both `strictObject`s, the `action.aria` posture). `tsc` types the key `never`, and the parse raises the prescription at `measures.KEY.name` / `dimensions.KEY.name`. The `measures` / `dimensions` describes now state that the record key IS the member's name. | | D2 | `cube-member-inner-name-removed` (protocol 18, `retiredFromLoadPath`), chained into `step18.conversionIds` with a rationale paragraph. It strips the inner `name` from every member of every `analyticsCubes[]` entry, and its notice names the cube. | | D3 | semantic entry `cube-member-inner-name-retired`: the judgement a DISAGREEING value still owes its author (which spelling was meant). | | registration | `RETIRED_KEYS_BY_MAJOR[18]` gains `data/Metric:name` and `data/Dimension:name` (per-file entries, generated region). | | ledger | both `analytics_cube.json` rows STAY `dead` (the tombstone keeps the key in the walked shape) with a `REMOVED 2026-09-28` note and a re-measured `verifiedAt`. The README row is updated; the counts do not move. | | producers | `dataset-compiler.ts` stops writing it (the triage line), and so do the two untyped internal mints tsc cannot see (`CubeRegistry.inferFromObject`, and `inferCubeFromQuery` / `inferMeasure` in `analytics-service.ts`). | | authors | the showcase cube (8 members), the `service-analytics` README example (3), and the published `objectstack-ui` skill example (6) | | fixtures | about 300 member literals and map-built members across 84 test and fixture files in eight packages; the three existing step-18 cube conversion fixtures are trimmed so the whole-table replay stays disjoint | | pins | `packages/spec/src/data/cube-member-inner-name-retirement.test.ts` covers every door (schema, `/meta` binding, `defineCube`, `defineStack` with its `STACK_SCHEMA_INVALID`/422 envelope, and a `@ts-expect-error` tsc leg), the D2 legs (stored row, boot door with a lit control, a disagreeing value, idempotence, load-path retirement), the registration, and a tree-scoped structural absence pin over the declared five-root radius. The flipped `analytics.test.ts` blocks (the snake_case pins on a value nothing read) are now tombstone pins. | | changeset | `@objectstack/spec` minor (BREAKING banner, FROM → TO, the one-line fix, what an author sees, and the ADR-0087 `registered` marker); `@objectstack/service-analytics` patch | What an author who still writes it sees: `tsc` fails at the authoring site. The parse refuses it with: "`measures.METRIC.name` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an effect: the record key is the metric's name. … Delete the key. To rename a metric, rename its key in `measures` — and every query, dashboard and report that names `CUBE.KEY`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand." A stored or built cube heals at rehydration and at the artifact door. ## Zone 2, measured - **A1 holds.** Zero reads of a member's inner `name` in non-test source (`analytics-service.ts#getMeta` and `memory-analytics.ts#getMeta` publish `CUBE.KEY`; `native-sql-strategy.ts#lookupMember` and `memory-analytics.ts#resolveMeasure` / `#resolveDimension` index the bag by key). Lit control: four reads of `measure.label` / `dimension.label` in the same two projections. The one `measure.name` hit (`dataset-compiler.ts:383`) is a `DatasetMeasure`, not a cube member. objectui at pin `f8a9d0fb05`: no cube-member authoring. `CubeSchema` is used only in `clientValidation.ts` (control: that hit resolves at the same sha). - **A2 holds, and is wider than the card.** Non-test producers: `dataset-compiler.ts` (2 sites), `CubeRegistry.inferFromObject` (3) and the ad-hoc mint in `analytics-service.ts` (4, plus `inferMeasure`'s 3 returns), the showcase, the README and the skill. Every one wrote the name EQUAL to its key, so no producer writes a disagreeing value. Test fixtures: 21 disagreed, all in `driver-memory` (e.g. `totalAmount: { name: 'total_amount' }`), and every one was queried by its key (`orders.totalAmount`). That is the trap, live in-repo. No `platform-objects` or template authors any cube. Stored rows: `analytics_cube` wraps as `analyticsCubes` at `applyConversionsToStoredItem`, and the pin's stored-row leg replays it. - **A3.** Worked on the merged cube contract (`public` enforced, objectstack-ai#20348 landed). Line numbers are from the merged tree. - **A4.** Merged `origin/main` `6e3e5462c` (which carries objectstack-ai#20357's step-18 appends) with `bash scripts/pm/os-regen-merge.sh`. - The driverless merge-tree (a bare shared clone with no `merge.os-regen.driver` registered) answered exit 0 with no conflicted paths. - The script's step 2 took main's side of `content/docs/references/data/analytics.mdx`, which was regenerated from the merged tree in its own commit (`e19628132`). - After the merge: both sides' ids are present in both step-18 lists (`view-list-tabs-removed` and `cube-member-inner-name-removed`) and in the rationale. - After the merge: `check:generated` reported all 15 artifacts current, measured right after a spec build of the merged tree. ## Deviations 1. **The `skills/**` split was ordered, then withdrawn.** The seat ordered the skill hunk split into a companion PR, and withdrew that on this measurement: - The example is an `os:check` block that `check:skill-examples` type-checks inside `typecheck-consumers`, a member of the required `TypeScript Type Check` aggregator. - Putting one inner `name` back into the example with `ablation-replace` (restored to the HEAD blob, `git diff HEAD` empty) gave exit 1: `dashboards.md:450:15 error TS2322: Type 'string' is not assignable to type 'undefined'`. So this PR without the hunk is red. - A companion PR alone on `main` would be red too: at base `dbddf02c1`, `MetricSchema.name` is a REQUIRED `z.string()`. That half is derived from the schema, not built. - No landing order is green, so the hunk stays here, as a pure deletion. 2. **File surface wider than the claim, same package and same defect class.** `CubeRegistry.inferFromObject` and the `analytics-service.ts` mints are untyped (a `Record` of `any`) producers that tsc cannot see; A2 put every producer in scope. Fixture edits span `service-analytics`, `driver-memory`, `spec`, `client`, `objectql`, `runtime`, `qa/dogfood` and `qa/downstream-contract`. 3. **Route.** The `spec-property-retirement` skill's route table maps `.strict()` to deletion plus a guidance map. I took the triage's `retiredKey()` route instead, which `shared/retired-key.ts` documents for closed shapes (strictly stronger than a guidance entry) and which `action.aria` used this week. As a result the ledger rows stay, per the card's acceptance. The `CubeJoinSchema` docblock line that said cube shapes never take a tombstone is corrected. 4. **D2 strips a disagreeing value too.** Triage: "lossless when it equals the key; a disagreeing value gets a D3 entry". The D3 entry exists. The strip still removes a disagreeing value because the key already won everywhere, so no answer changes, and leaving it would stop the cube loading at the boot door. The notice prints both spellings (`from: name "total_amount"`, `to: (removed; the record key "totalAmount" is the name)`). 5. **`service-analytics` is graded `patch`.** Its members are filed under the same keys, and every `/analytics/*` answer is unchanged. `@objectstack/spec` carries `minor`: a published narrowing ships `minor` in the launch window, and the changeset declares it as `Clause-②: no (narrowing)` under its BREAKING banner. ## Tests (head `c4771e604` unless stated) - **`@objectstack/spec`:** - `test` 564 files / 16641 tests green (merged tree `e19628132`; spec `src/` is unchanged since). - `test:repo` 37 / 675 green (pre-merge `c1cae40df`). Post-merge, its three tree-reading legs this diff owns were re-run green: the retirement pin, `retired-key-migrate-sentence`, and `build-schemas-check-mode` (107 tests together with the pin). - `typecheck` green (src, scripts, and the test layer under its shrink-only ledger). `tsc -p tsconfig.test.json --listFilesOnly` lists the new pin, so its `@ts-expect-error` legs are live. - **`@objectstack/service-analytics`:** typecheck green, 132 files / 3093 tests green (`14cac89f4`). - **`@objectstack/driver-memory`:** typecheck (which reaches the test layer) green, 57 / 1374 green (`14cac89f4`). tsc found the two map-built members there; the same shape was then swept in service-analytics and runtime. - **Touched test files in other packages:** `client` 7/7, `objectql` `protocol-meta` 95/95, `runtime` `cross-field-refusal-operand-withhold` 11/11, `downstream-contract` `contract.test.ts` 13/13 plus typecheck exit 0. - **Reverse verification.** Putting `name: m.name` back in `dataset-compiler.ts` via `ablation-replace` gave `src/dataset-compiler.ts(673,7): error TS2322: Type 'string' is not assignable to type 'undefined'` against the rebuilt spec `.d.ts`. Restored: blob equals HEAD, `git diff HEAD` empty. The direction was the predicted one (red). - **Gates.** `dispatch-gates.mjs --commands` at `c4771e604` derives 126 families. All 126 were run and recorded, and `--ran` reports 0 UNRUN. - 123 exit 0. - 2 exit 3, PREREQUISITE NOT MET: `check:dual-build-cjs-loads` and `check:type-check-debt` (both need the whole-repo build). - 1 exit 1: `check:platform-checklist`, inherited from `main` (see Acceptance notes). Its inputs are byte-identical to `main`, and it is not a per-PR CI gate. - **Lint (a proven narrowing).** - Scope: `eslint --no-inline-config --format json` over exactly the 95 changed code files returned 0 errors and 0 warnings. - Population: read from `eslint.config.mjs` (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`); the file count comes from the JSON output. - Invariance: the config itself records that it never enables type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. **NOT MEASURED** (CI runs these): - `qa/dogfood`: the two touched dogfood tests and `expression-conformance`. The package does not resolve `@objectstack/verify` unbuilt, so no test ran. Static reading: 0 `analytics.zod` references in that ledger, against 11 `.zod.ts` references as the control. - The showcase `typecheck`: 7 TS2307 for unbuilt connectors and plugins, and 0 diagnostics in `showcase.cube.ts`. - `downstream-contract`'s `consumer-specifier-ledger` needs `@objectstack/cli` built. - The two exit-3 gates above. ## Skills readings `skills/objectstack-ui/rules/dashboards.md` goes 468 → 468 lines. The whole package (every `SKILL.md`) goes 4404 → 4404. Against base the hunk is 6 lines modified and nothing added: each change deletes a `name: 'KEY',` fragment. ## Acceptance notes (noted, not filed) - `check:platform-checklist` is red on `main` at `3cf644938`: - The failure: `areas/identity-auth.json` anchors `plugin-auth/src/auth-plugin.ts#twoFactor`, and `7d6308895` (objectstack-ai#20429) turned that line-start key into an inline nested object key (`plugins: { twoFactor: true }`), which the shared resolver reads as absent by design. - It is independent of this diff: both files are byte-identical to `main`. - The gate is run by hand, not per PR. Carrier: the next PR to touch `identity-auth.json` or `auth-plugin.ts`, or the checklist owner. - The inner `name` carried a snake_case regex. The record key never had one, and it legitimately takes camelCase and dotted spellings in-repo (`driver-memory` fixtures; `'owner.amount_sum'` in `dotted-measure-refusal.test.ts`). Nothing is enforced on the key today; this is an observation, not a change here. - The absence pin states its blind spot: members built under computed keys (`Object.fromEntries(… { name: n, … })`). tsc found the typed ones in `driver-memory`, and the rest of that shape was swept by an AST scan (object literals holding `name`, `sql` and `type`). What remains is only this pin's own refusal specimens and the schema shape. ## 维护者速读(草稿) **改了什么**:分析立方体(cube)的度量与维度不再接受内部 `name` 字段;成员的名字就是它在 `measures` / `dimensions` 里的键。写了 `name` 会在编译期和解析期被明确拒绝,并给出迁移提示。已存储的立方体在加载时自动去掉该字段,照常可用。 **为什么改**:这个字段从来没有任何代码读取,系统一律按键识别成员;当 `name` 与键不一致时,作者写的值被静默忽略。Cube.dev、LookML 等主流方案也只有一个名字。按您「主流平台有没有这个能力」的判据,判定退役。 **风险与代价(含回滚)**:对外行为不变 —— `/analytics/meta` 与查询接口的成员名仍是 `立方体.键`。仍写 `name` 的作者源码需要删除该字段(`os migrate meta --from 17` 列出改动)。回滚:还原本 PR 即可,无数据迁移需要撤销。本 PR 同时改了一个对外发布的 skill 示例(仅删除 `name`),因此需要您的批准。 **席位意见**: **你要做的**:审阅后批准(Approve)本 PR。 Line 3 and Deviation 5 were amended by the `domain:spec` seat 1 (`session_01B3TqpoQbTAfG7G74GMDWNW`) before the at-tier review: this diff widens no accept set and adds no export, so `Clause-②` is `no (narrowing)`, as most retirements of this family on `main` declare. `20323-action-aria-removed.md` declared `yes`; the definition in `clause2-line.mjs` decides, not the precedent. The changeset line moved with it in `2252e5728`, and the claim on objectstack-ai#20300 was amended in place. The patch-round section above was added by the same seat after the at-tier record `5875291969`. --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20282
Clause-②: yes (narrowing)
Rework round 2
The order of record is seat comment
5863061968on #20282. It responds to the at-tier contract review5863057917on this PR, which recorded FAIL atf84cd184df. Claim5859909653is unchanged. There are new commits on top only, with no rebase, amend or force-push. The final head is5ddea9580d.Clause-②: yes (narrowing), and the changeset carries a**BREAKING**sentence.public: false, and every cube in an artifact built byos compilebefore this release.public: falsefrom any cube that is meant to be queried, and recompile.registered analytics-cube-public-default-visible-enforced. The gate printed the six categories, andregisteredis the true one because this PR adds the entry.check-adr-0087-registrationpasses: 1 declared-breaking changeset with a disposition. Levels stayminor.packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.tsis a structured TODO with no tracker numbers in the author-shown fields.public: falsenow hides the cube and refuses its queries, and a pre-release compiled artifact must be recompiled.registry.tsis regenerated, andcheck:migration-registrypasses.spec-changes.jsonand the upgrade guide do not project major-18 entries yet, so they did not move.origin/main862b6ce869, which does not have this PR.publicwith the base CLI'scompilecommand. The command class was run directly, because the oclif dev dispatcher loads every command module and needs the whole CLI closure built."public": falseon the cube.AnalyticsService:getMeta()returned[], andquery()andgenerateSql()returned404 CUBE_NOT_FOUND.public: true: listed, and both doors answer./analytics/*routes call, not through an HTTP boot. The verify closure rebuild did not get the lock (queue timeouts).os serveartifact boot never threadsanalyticsCubesinto the analytics service (standalone-stack.tsdoes 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.cubeNotFoundError, the same function the unknown-name path throws: the sameCUBE_NOT_FOUND/ 404 and the same message.query()andgenerateSql(), the same name hidden in one service and absent from another gives equal status, code, message,cubeand own keys.ac5260a28b, viascripts/ablation-replace.mjs. The mutation made the hidden-path message differ: anchor 1→0, blob1a1763ac7d04→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 andgit diff HEADis empty.cube-visibility.tsdocblock and the changeset sentence no longer claimpublic: falsewithholds 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.#cubeNotFoundError.public: falseare omitted:content/docs/api/data-api.mdx(GET /analytics/meta);content/docs/api/client-sdk.mdx(theanalytics.metacomment);IAnalyticsService.getMetaTSDoc.origin/main. Three merges went throughscripts/pm/os-regen-merge.sh:5a6267f486, then862b6ce869, then15bf186f50. Each regenerated artifact was regenerated with its generator (gen:schema,gen:docs,gen:liveness-counts), never hand-resolved.queryDatasetinto a request scope.query-dataset-request-scope.test.tsobserver baseline, written while nothing readpublic, now expects discovery to omit itspublic: falsefixture.036af03342passed with one exception: 130 of 131 files and 3067 of 3069 tests. The two failures were main's observer baseline, fixed in5ddea9580d.5ddea9580d: 5 files, 108 tests passed. They arecube-public-visibility,query-dataset-request-scope,analytics-service,query-datasetandcube-inference-gate.analytics,migrationsandcontracts/analytics-servicetests: 194 of 194.data/Cube:public: false → true.check:generated(15 of 15 current),check:liveness(analytics_cubelive 18 / dead 9) andcheck:migration-registryare green.dispatch-gates --commandsat5ddea9580dderived 117 commands. All ran and were reconciled with--ran: 117 run, 0 NOT MEASURED, 0 unrun.check:query-options-erasureandcheck:type-check-debthit the 420 s runner cap on the loaded box and were re-run with a longer cap; both exited 0.5ddea9580d: 35 check runs, 33 success, 2 skipped. Mergeable: clean.Rework round 1
The order of record is seat comment
5861384124on #20282; claim5859909653is unchanged. The open question was ruled A in-seat: no ADR-0087 semantic entry, andClause-②: yesandminorstay as shipped. There is one new commit on top,2cfa134c34, with no rebase and no force-push.docs/qa/platform-checklist/areas/dashboards.json, itemdashboards.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:getMetalists a cube only when itspublicis notfalse. Apublic: falsecube is omitted and refused byquery()/generateSql()with 404CUBE_NOT_FOUND.showcase_deliverydeclares nopublic, so it is visible by default.public:false.getMetasource line no longer says it "returns all registry cubes".showcase_deliverystays visible with the same four measures and four dimensions.pnpm check:platform-checklistis OK (266 items; symbol anchors 624/642, 18 on the named residual).@objectstack/verifybootStack(the real Hono app, in process), with theanalytics-admission-fixturestack on sqlite-wasm.CubeSchemaand passed asAnalyticsServicePlugin({ cubes }):open_summary(visible) andhidden_summary(public: false), both overadmission_open.GET /api/v1/analytics/metaand gets 200 listingopen_summary"Open Summary (authored)" with measureopen_summary.authored_total. B'sPOST /api/v1/analytics/queryfor that measure answers 200,authored_total: 2.POST /api/v1/analytics/dataset/querywith an inline dataset namedopen_summaryoveradmission_walled, an object A has no grant on. The answer is 403PERMISSION_DENIED. B's meta then answers 200 listingopen_summarytitled "inline by A" with onlyopen_summary.hijack_cnt. B's query ofauthored_totalanswers 400INVALID_FIELD("…which object 'admission_walled' does not have. Valid measures: hijack_cnt").queryDatasetregisters the compiled cube before its admission gate runs.admission_openanswers 200 and makes the same replacement. B'sopen_summary.hijack_cntanswers 200, and the count is B's own RLS scope.hidden_summaryanswers 404CUBE_NOT_FOUND(this PR's gate). A's inline dataset namedhidden_summarythen answers 200. B's meta now listshidden_summary, with A's definition. The authoredsecret_totalis gone (400INVALID_FIELD); the hidden definition was replaced, never disclosed.2cfa134c34.dispatch-gates --commandsderived the same 113-command set, and all of it was re-run on this head.--ran: 112 run with exit 0, 1 NOT MEASURED, 0 unrun.check:skill-examplesandcheck:type-check-debtfirst 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.check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (34 workspace packages have nodist/here; a whole-tree build for CI).Stage 1 of the
analytics-cube-semanticsfamily:analytics_cube.publicand its default. Triage verdict ENFORCE (5859510828, execution note 1), claim5859909653. #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
CubeSchema.publicdefaults totrue(it wasfalse) and gains a.describe().falsehides the cube from the analytics API. It is visibility, not row security. The default change is declared inDEFAULT_CHANGES_BY_MAJOR(packages/spec/scripts/lib/default-changes.ts), which the authorable-defaults ratchet requires.packages/services/service-analytics/src/cube-visibility.ts.isCubePublicis the one reader: a cube is exposed unless it declarespublic: false. The registry holds the input shape, so an omitted key reads as the schema default.cubeNotFoundErroris the refusal, and it is shared with the unknown-cube path (rework round 2).AnalyticsService#getMetaomits a hidden cube.getMeta(name)for a hidden cube answers[], the same answer as a name no cube has.AnalyticsService#assertCubePublicruns first inquery()and ingenerateSql(). It runs before token resolution, cube inference, admission and every strategy, so the refusal leaves the registry untouched. The refusal is404 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.inferCubeFromQuery,compileDatasetandCubeRegistry.inferFromObjecteach wrote a literalpublic: false, the old default. They now writetrue. Without that, the ad-hoc KPI path and the dataset door would refuse the cubes they mint themselves (see the internal-caller census below).examples/app-showcase/src/data/analytics/showcase.cube.tsdrops itspublic: false(evidence below).publicrow inpackages/spec/liveness/analytics_cube.jsonflipsdeadtolive, citingcube-visibility.ts#isCubePublic,analytics-service.ts#getMetaandanalytics-service.ts#assertCubePublic, with the CLI threading asproducer. The README cell andstate-counts.md(regenerated) now readanalytics_cubelive 18 / dead 9.authorable-defaults/data.json(by the build) andcontent/docs/references/data/analytics.mdx(gen:docs).state-counts.mdcomes fromgen:liveness-counts. No file underskills/**or any other governed surface changed, so this PR is not Tier H.Present state, measured before the change (base
4e0f72e8d2)packages/spec/src/data/analytics.zod.ts,public: z.boolean().default(false)under an/** Access Control */comment, with no describe.git grepforcube.publicor.publicfound no reader inpackages/services/service-analytics/srcorpackages/drivers.GET /api/v1/analytics/metagoes togetMeta.POST /api/v1/analytics/querygoes toquery().POST /api/v1/analytics/sqlgoes togenerateSql().domains/analytics.ts.POST /api/v1/analytics/dataset/querygoes toqueryDataset, which usesDatasetExecutorand thenquery().ctx.getCube(query.cube), the root cube. Joins reach objects, not cubes, so no second cube is resolved per query.public,examples/app-showcase/src/data/analytics/showcase.cube.ts:98withpublic: false. No platform object or qa fixture authors a cubepublicvalue. Test fixtures restatedpublic: falsein 45 files: 44 inservice-analytics, pluspackages/runtime/src/cross-field-refusal-operand-withhold.test.ts.99f0e9d296, test only) gaveTests 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 showcase decision, with evidence
The cube is meant to be seen and queried, so this PR drops the
falserather than keeping the cube hidden:examples/app-showcase/src/coverage.tsmarksanalyticsCubesdemonstrated, "Served by the foundational analytics capability (/api/v1/analytics/*)"./api/v1/analytics/*)".docs/qa/platform-checklist/areas/dashboards.jsonrunsGET /api/v1/analytics/meta?cube=showcase_deliveryandPOST /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
AnalyticsServicePluginregisters theanalyticsservice in this repo. In-repo consumers are the runtime REST domain, the REST dataset door andservice-analyticsitself.packages/runtime/src/domains/mcp.tsandaction-execution.tscall a differentgetMeta(the metadata protocol's).Two internal paths reach
query()with a cube they minted:queryDatasetusesDatasetExecutor, thenquery(), 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 noneeds_decision.Pins
packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts, 13 cases:getMetaomits a hidden cube;getMeta(hidden)answers[].query()andgenerateSql()refuse with{ code: 'CUBE_NOT_FOUND', status: 404, cube }, and no strategy ran.CubeSchema-parsed cube are all answered.queryDatasetanswers.packages/spec/src/data/analytics.test.tspins the default (true) and an explicitfalsethat parses and is kept.Ablation
The reader was mutated at HEAD
33348d6ec3so that it stops filtering.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).44d9fcf712d5toe144bb908748.srcpaths (../analytics-service.js,../cube-visibility.js), so no packageexportsand nodist/sit on the subject's resolution path.src/__tests__/cube-public-visibility.test.tsgaveTests 6 failed | 7 passed (13). The six reds are exactly the twogetMetapins and the four door-refusal pins. The controls and the internal-producer cases stay green. The direction was red, as expected.44d9fcf712d5, equal to the HEAD blob, andgit diff HEADis 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.33348d6ec3.Tests 93 passed (93)across the pin file,analytics-service.test.ts,query-dataset.test.tsandcube-inference-gate.test.ts.Changeset
.changeset/20282-analytics-cube-public-enforced.mdbumps@objectstack/specand@objectstack/service-analyticsatminor. The reasons:Clause-②: yes (narrowing)is BREAKING, and ships asminorunder the launch-window convention.fixedgroup.Since rework round 2, the changeset carries a
**BREAKING**sentence and the ADR-0087 dispositionregistered analytics-cube-public-default-visible-enforced. It also records the upgrade notes: omitted key (no change), explicitfalse(now hidden),os compileartifacts that carry a materializedfalse(recompile), and the platform-minted cubes.Local verification
Every reading below was taken at HEAD
33348d6ec3, a clean tree that includes a merge oforigin/mainateea8787aa7, unless a line says otherwise.@objectstack/service-analytics, fullvitest run, atc9382ff256.Test Files 1 failed | 129 passed (130),Tests 1 failed | 3053 passed (3054).dimensions. It is fixed in33348d6ec3, which changes only that test file.33348d6ec3the pin run is93 passed (93).@objectstack/service-analyticstypecheck(tsc --noEmit, which reaches the tests): exit 0 atc9382ff256.@objectstack/spec, targeted.src/data/analytics.test.ts,analytics-strictness-batchd.test.ts,src/api/analytics.test.ts,src/contracts/analytics-service.test.tsandsrc/kernel/metadata-type-schemas.test.tsgaveTests 227 passed (227).data/Cube:public: false → true.check:generatedreports all 15 artifacts up to date.check:livenessis green:analytics_cube 27 classified (live 18, dead 9).packages/runtime/src/cross-field-refusal-operand-withhold.test.ts, against a rebuiltservice-analyticsdist:Tests 11 passed (11).99f0e9d296.Tests 10 failed | 3 passed (13)(the readings are quoted above).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 113 commands. All were run and recorded, then reconciled with--ran: 111 exited 0, 2 are NOT MEASURED, 0 are unrun.check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET, 64 workspace packages have nodist/in this worktree, so this is a whole-tree build for CI.check:type-check-debt, reason: PREREQUISITE NOT MET,@objectstack/driver-tursohas no built types.check:skill-examplesfirst exited 3, because the client packages were not built. It was re-run after building them and exited 0.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: evaluatingsrc/data/analytics/showcase.cube.tswithtsxprintsDeliveryCube.public = true. The file runs in CI.@objectstack/spec's fullpnpm testandtypecheck./analytics/*routes call.Acceptance notes
Observations, not filed:
MemoryAnalyticsServicedoes not readpublic.@objectstack/driver-memory's standaloneMemoryAnalyticsService#getMetaand#queryignore the key.AnalyticsServicePluginregisters theanalyticsservice.AnalyticsServiceit is reached only through the gatedquery()andgenerateSql(), andAnalyticsService#getMetanever consults it.public,refreshKey,format,granularitiesand descriptions take effect (8 keys) #20282 descriptions stage, whose reader list already names bothgetMetaimplementations.docs/qa/platform-checklist/areas/dashboards.json: fixed in rework round 1 (item 1 above).Generated by Claude Code