fix(service-analytics): the ad-hoc query and sql doors run in a request scope, publishing an inferred cube only after admission - #20407
Conversation
…st scope, publishing an inferred cube only after admission query() and generateSql() ran ensureCube over the shared scope before callCtx asked the object-level admission, so a refused request left its inferred cube in the shared registry and a caller-named suffix measure was appended to a configured cube for every caller. Both doors now reuse the request CubeScope queryDataset runs in; the ad-hoc door publishes an inferred cube to the shared registry only after admission (first registration wins), and an augmented cube is never published. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…view unchanged until admitted Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…st scope Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 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 43baf4d20d14d009faba9dcbfb5dfa172594efe2 && git checkout 43baf4d20d14d009faba9dcbfb5dfa172594efe2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7 && git checkout -B drift-repro dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 && git merge --no-ff 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7
node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580
|
Contract reviewServed-tier: Inputs: card #20381 (body with its Ruled line, and all 7 comments — triage ① Derived judgmentsRead from the diff and the head's
② Semver level
③ Boundary flagsEvery dev flag — PR body "What stays open", "Acceptance notes", "Overlap with PR #20348", the NOT MEASURED lines; dev report
Nothing remains unanswered and un-escalated. The diff touches no governed surface ( Implemented-by: VERDICT: PASS Rendered by an isolated contract-review subagent and adopted by the Generated by Claude Code |
…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>
Part of #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) andgenerateSql()(POST /api/v1/analytics/sql) ranensureCubeover the SHARED cube scope, beforecallCtxasked the object-level read admission.ensureCuberecords what it mints in the scope it is given, so:PERMISSION_DENIEDstill left the cube it inferred for the refused object in the service-wide registry, and so in every member'sgetMeta();FIELD_sum,FIELD_count_distinct, …) was appended to that cube for every later reader, refused or admitted.Both doors now run in the request
CubeScopethat PR #20380 introduced forqueryDataset— the samerequestScope(), 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.ensureCubenow returns the cube it INFERRED (nothing on the augmentation or declared paths). The ad-hoc doors hand it topublishInferredCubeAFTERcallCtxhas 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.scopedService/queryInwithout the flag) publishes nothing, as before.No refusal is added, and no code or status changes. Boot-time
cubes/datasetsregistration is untouched. Nopackages/specchange.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/maindf3ba164plus this branch.queryIncalledensureCube(query, scope)beforecallCtx(the admission is incallCtx), andgenerateSqlcalledensureCube(query, this.sharedScope)beforecallCtx. Pre-fix route measurement (dist built fromdf3ba164): the new route pins are 12 of 24 red, and every negative leg fails on the observer-equality line after its403envelope assertion passed. The new unit pins are 20 of 24 red.queryObjectsresolves the cube through the scope and returns an EMPTY object set, so an admission asked beforeensureCubeadmits vacuously and never asks about the object. Ablation M0 below movedcallCtxahead ofensureCubeonquery(). 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.dotted-measure-refusal.test.ts's warm-registry case, which asserts the registered cube keeps['count'], stays green.getMeta(), which lists it;ensureCubeand 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;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?)inpackages/spec; the runtime route passes none), so it lists every registered cube to every caller.generateSqlhas the same admission): holds. Measured through the route:/analytics/sqlanswers the same403 {"success":false,"error":{"code":"PERMISSION_DENIED","httpStatus":403}}as/analytics/query, from the sharedcallCtx. No refusal was added to that door.sqlite-wasmandmemory, 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 inGET /analytics/meta, with the administrator's measure and dimension member names. B'sGET /dataof that object answers 403. B'sGET /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.tshas 24 tests: both strategies × both doors, a second caller as the observer, and whole-snapshot equality. It covers:packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.tshas 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 pluserror.codePERMISSION_DENIEDpluserror.httpStatus403. The legs:metaand B's configured-cube answer are unchanged;pnpm --filter @objectstack/service-analytics test: 131 files, 3077 passed.typecheck: clean, andtsc --listFilesincludes the new test.analytics-rls,analytics-label-scope,analytics-timezone): 6 files, 54 passed.pnpm --filter @objectstack/dogfood typecheck: clean, and it includes the new test.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackover the actual changed paths on16fc9f3bgives 66 commands, identical to the dispatch-time list. All 66 exit 0.--ranreconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Two commands needed a second run:check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3), because eight packages outside the dogfood closure had nodist/. After a turbo build of those packages (41/41 cached), it exited 0.check:type-check-debtwas first killed by my own batch timeout. Run on its own, it exited 0.eslint --no-inline-config --format jsonover the 3 changed TS files gives 3 files, 0 errors, 0 warnings. The population is these 3 files; none is ignored byeslint.config.mjs. The invariance:eslint.config.mjsnever enables type-aware linting (its own header states this), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.Ablations
Each ablation used
scripts/ablation-replace.mjsin wrap mode, with the fix committed first. The route legs rebuilt@objectstack/service-analyticsand ranscripts/ablation-dist-preflight.mjsfor both the present and the--absentreadings.callCtxmoved ahead ofensureCubeinqueryIn(the naive order fix)ensureCube, i.e. before admission95f2ef9aequals the HEAD blob,git diff HEADis empty, and the rebuilt dist carries no marker (--absent✓, tree clean).Overlap with PR #20348
This PR is textually disjoint from #20348's hunks except for the head of
generateSql, and it does not contradict #20348:assertCubePublic(name, scope)at the head ofqueryInasks the call'sscope, which is now the request scope reading the shared registry through. The answer is the same.generateSqlgate ask the call'sscope(hoistconst scope = this.requestScope()above it), so the gate and the rest of the call ask one scope. The answer is identical today.public: truebecause an admitted inferred cube stays registered, and it still does here.What stays open on #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:getMeta(apackages/speccontract change plus the runtime route);Triage reserved that choice, so it goes back as
needs_decisionwith the four-axis analysis. Whichever option is chosen, it is a small follow-up on top of this PR.Acceptance notes
infer-cube-relation-traversal.test.ts("mints the identical cube for both spellings") reads that cube throughgetMetaand 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 thananalytics-service.tsnow is. It was left untouched to stay inside the card's file surface.auto-inferred a minimal cubeline (atwarnfor grouped queries) before admission. This is a server-side log only, unchanged.Generated by Claude Code