Skip to content

fix(service-analytics): queryDataset compiles into a request scope and never writes the shared registries - #20380

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20356-inline-dataset-isolation
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20356-inline-dataset-isolation

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #20348, analytics_cube.public)

PR #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 feat(analytics): enforce analytics_cube.public and default it to visible #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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 24 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-analytics/src/cube-registry.ts, packages/services/service-analytics/src/dataset-compiler.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 3 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v16.mdx (via queryDataset (symbol, a method of class AnalyticsService))
  • content/docs/releases/v9.mdx (via queryDataset (symbol, a method of class AnalyticsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-analytics/src/cube-registry.ts, packages/services/service-analytics/src/dataset-compiler.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b1cbd9277719a7c524ac83bc183db1b3a77d4139 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c40dfec5a567840f43e3dcb7fbf54c921a309344 — the merge of head 15e21b984cb0ac455cab80f57b1593451b96f125 into base b1cbd9277719a7c524ac83bc183db1b3a77d4139, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b1cbd9277719a7c524ac83bc183db1b3a77d4139 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 15e21b984cb0ac455cab80f57b1593451b96f125
Local-runs: none

① Derived judgments

  • The published surface of @objectstack/service-analytics is unchanged. RIGHT.
    • The exports map addresses . only (dist/index), and src/index.ts is not in the diff.
    • CubeScope and CubeReads are module-private interfaces. compile, queryIn, requestScope, scopedService and cubeReads are private methods, and sharedScope and configuredAllowedRelationships are private fields. None is reachable from the entry type graph.
    • The spec contract IAnalyticsService (packages/spec/src/contracts/analytics-service.ts:182-231) is untouched, and registerDataset and queryDataset keep their signatures.
    • StrategyContext (strategies/types.ts) is untouched; getCube, getAllowedRelationships and getDatasetScope pre-existed.
    • The dev's "no spec or public-surface change" holds.
  • registerDataset behaves exactly as before. RIGHT.
    • At base, :1426-1444 compiled with compileDataset(dataset, resolver, options), then wrote cubeRegistry.register + datasetRegistry.set.
    • The head splits the same body into a private compile (with identical options: getObjectDatasource, isExternalObject, declaredFieldType) plus the same two writes, at :1548-1549.
    • The constructor's config.datasets loop (:994-998) still calls registerDataset, so boot registration is unchanged, as triage required.
  • No request-time write into the shared registries remains reachable from queryDataset. RIGHT.
    • The base's first statement, this.registerDataset(dataset) at :1458, becomes this.compile(dataset) at head :1605.
    • Every this.cubeRegistry.register at head is one of:
      • :969, the shared scope's register, reached only by query / generateSql on sharedScope;
      • :974, the constructor's registerAll;
      • :1548, registerDataset.
    • The only this.datasetRegistry.set is at :1549.
    • On a dataset call, ensureCube's two mints (scope.register) go to the request scope's own Map. That is the second write the card's suggested shape missed, and the unit test's fresh-name/augmented leg pins it (cubeRegistry.names() unchanged after an amount_sum augmentation).
  • Every name-keyed read on the dataset query path goes through the request scope. RIGHT.
    • ensureCube reads scope.getCube.
    • queryObjects(query, scope) feeds both the admission set (callCtx :1214, assertReadAdmitted) and the read-scope set (resolveReadScopes).
    • The strategy context's getCube / getAllowedRelationships / getDatasetScope are cubeReads(scope), spread into the per-call ctx. resolvedDatasetScopeGetter takes reads.getDatasetScope rather than this.baseCtx, and no this.baseCtx. member read remains.
    • Strategies read only ctx.getCube (native-sql :163 / :287 / :331 / :391 / :403; objectql :148 / :363) and ctx.getAllowedRelationships (native-sql :558). resolveFkAttr reads ctx.getReadScope and ctx.executeAggregate only.
    • DatasetExecutor reads compiled.cube directly, and issues service.query with the cube set to compiled.cube.name (:1351) through the scoped face. It never calls getMeta.
    • The request scope answers getCompiledDataset for the call's own name, so the join allowlist and the dataset scope come from the call's dataset, exactly as the pre-fix registration made them.
  • Admission still runs before any side effect, and refuses the same set. RIGHT.
    • The queryIn order is ensureCube → callCtx (admission, read scopes) → strategy, identical to base. compile throws the same compile-time refusals registerDataset threw.
    • The added source lines contain zero throw, code = or status =, so there is no new refusal, honouring triage note 3 (no collision refusal).
    • The route pins assert that the refused leg is still 403 PERMISSION_DENIED.
  • Concurrent requests are isolated. RIGHT. requestScope builds a fresh Map per call (:1559), scopedService a fresh face (:1580), and callCtx a fresh ctx object; no instance field is written on the dataset path.
  • The POST /api/v1/analytics/dataset/query accept set is unchanged. It has the same admission, read scope and refusals. The datasetName branch resolves through the metadata protocol (rest-server :11252-11259), not the analytics registry, so saved datasets serve as before (the route CONTROL leg).
  • POST /analytics/query, POST /analytics/sql and GET /analytics/meta behave identically to base on the shared scope (query :1426, generateSql :2161-2162, getMeta :2124-2128). The one exception is that a request-compiled dataset's name no longer becomes a registered cube afterwards. The ad-hoc doors' own ensureCube writes are, in effect and order, byte-for-byte the base's :2073 / :2141 writes: not made worse, and correctly filed as analytics: an ad-hoc /analytics/query or /analytics/sql request writes inferred and augmented cubes into the shared registry before admission, so a refused request still changes every member's meta #20381. Judged under ②.
  • The hidden-cube leg: the NOT MEASURABLE claim is true, and the substitute pin is honest. RIGHT.
    • git grep -w public over the non-test src of service-analytics, runtime and rest at head finds no reader of Cube.public.
    • The positive control is the three writers (public: false at analytics-service.ts :2819, cube-registry.ts :165, dataset-compiler.ts :689) and the schema field (packages/spec/src/data/analytics.zod.ts:398).
    • assertCubePublic is absent at head, so feat(analytics): enforce analytics_cube.public and default it to visible #20348 has not landed.
    • What is pinned instead is declared as a substitute, not passed off as the hidden leg: the registry entry toBe(HIDDEN_SUMMARY) with public false (unit), and B's whole meta equal to baseline (route).
  • In-repo reliance on the removed side effect: none. RIGHT.
    • The non-test .queryDataset( callers are the REST door (rest-server.ts :11319) and metadata-protocol/src/build-probes.ts:343, which reads rows and queries nothing by name afterwards.
    • The same pathspec finds .registerDataset( at analytics-service.ts :997 as the positive control.
    • No runtime code queries a dataset name as a cube, and Test Core plus the Dogfood Regression Gate are green on this head.
  • The tests assert the ADR-0112 envelope on every refusal case.
    • Unit: rejects.toMatchObject with code PERMISSION_DENIED and status 403 (two legs × two strategies).
    • Route: status 403 and body code PERMISSION_DENIED (the REFUSED and CONTROL legs × two drivers).
    • The counts match the body: 6 legs × 2 strategies = 12; 5 legs × 2 drivers = 10.
  • Check-runs on the head: 39 runs. The latest per name are all success or skipped, with no failure at any generation. All seven required contexts are success, including Check Changeset. No governed surface is among the 7 paths.
  • Disclosure discipline holds. The PR body and the changeset describe the defect at the code-path level (which method wrote which registry before which gate), with no request recipe.

② Semver level

  • The changeset .changeset/20356-query-dataset-request-scope.md is "@objectstack/service-analytics": patch, with Clause-②: no and no arm, and the PR body carries the same line. Nothing widens (no new export, config key, accepted key or value), so no is truthful, and the WHICH LEVEL rule (a fix( that changes no public surface stays patch) is met.
  • The narrowing question, tested. The changeset's "one observable difference" is real at public doors: a cube that only a queryDataset call compiled is no longer listed by getMeta(), nor queryable by name via query() / POST /api/v1/analytics/query. It is NOT a narrowing of a published accept set:
    • the published statement of those doors (content/docs/api/data-api.mdx: "registered cubes ... explicitly defined via defineCube or the cubes config ... lazily auto-inferred") never admitted a dataset name registered by a query;
    • the spec contract's queryDataset declares compile-then-run, with no registration;
    • that acceptance existed only as process state written by the very defect this card names, and the card's triage ordered its removal.
      This is a pull-back to the declared contract, in line with this package's own precedent: the 17.3.0 patch entries b0d7d54, 967402a and 5c7cbe3 stop over-reach the platform previously accepted, and were graded patch without a BREAKING banner. It is unlike 17.2.0 57e4571, which refused an input the contract admitted.
      So patch, Clause-②: no with no (narrowing) arm, no BREAKING banner and no ADR-0087 marker is the RIGHT declaration. The changeset still names the difference and a remedy, which is the disclosure a reader needs. The Clause-②: line is judged RIGHT.
  • The changeset prose, read sentence by sentence against the code: every "what changes" and "what does not change" sentence is true:
    • registration before run, at base :1458;
    • the request-local lookup for the cube, the admission and read-scope sets, the join allowlist, the dataset scope, and inferred measures;
    • the same admission and refusals;
    • registerDataset and configured cubes untouched;
    • no collision refusal.
      Two nits, below.

③ Boundary flags

  • open_questions: empty; nothing to answer.
  • Deviation 1 (the edited dataset-i18n-label-resolution.test.ts): the one case asserted the removed registration side effect. It now calls registerDataset first, and its intent (a zh-CN query leaves meta in the source language) is kept. Accepted.
  • Deviation 2 (model-free commit trailers): all five non-merge commits carry Claude-Session + Co-authored-by: Claude with no model identifier. That is AGENTS.md compliance, not a deviation. Accepted.
  • Deviation 3 (main advanced, not re-merged): the PR reads mergeable: true, state clean, and the queue rebuilds on the current main. Accepted.
  • Deviation 4 (the scratch probe deleted): the file list is exactly the 7 declared paths. Accepted.
  • Out of scope (a), the ad-hoc doors' shared writes before admission: the writes and their order are identical to base, and the seat filed them as analytics: an ad-hoc /analytics/query or /analytics/sql request writes inferred and augmented cubes into the shared registry before admission, so a refused request still changes every member's meta #20381. Accepted as out of scope; this PR does not worsen them.
  • Out of scope (b): strategies/native-sql-strategy.ts:579-581 still says "queryDataset registers the compiled dataset first, so getAllowedRelationships answers from datasetRegistry". That is now false as a description of the mechanism, but the invariant it protects still holds through the request scope's getCompiledDataset: the legacy hook is unreachable from the dataset door. NIT: no behavioural consequence; a follow-up wording fix outside the claimed surface.
  • Out of scope (c): the draft preview reads draftRowsResolver before its own assertReadAdmitted (head :1615-1629). It is pre-existing and unchanged, a read whose rows never leave on refusal. Accepted; noted.
  • Cross-seat hazard with feat(analytics): enforce analytics_cube.public and default it to visible #20348 (assertCubePublic must read scope.getCube): posted on analytics: an authored cube's public, refreshKey, format, granularities and descriptions take effect (8 keys) #20282, and not this PR's defect. The dogfood HIDDEN leg, asserting 200, is the tripwire. Accepted.
  • NIT (changeset prose): "queryable by name through query() / POST /api/v1/analytics/query". The same shared-scope lookup also serves generateSql() / POST /analytics/sql and getMeta(cubeName); the sentence names one query door. No reader would conclude differently.
  • NIT (changeset prose): the remedy "register it through registerDataset or AnalyticsServiceConfig.datasets" is reachable only by an embedder constructing AnalyticsService. AnalyticsServicePlugin exposes cubes only (plugin.ts :146), and it holds the service in a private service? field (:287). This is not false, since the changeset scopes the door to "AnalyticsServiceConfig.datasets and embedders", but it is incomplete for the plugin composition. No in-repo consumer needs it.
  • Not a finding: the PR body's ablation paragraphs are process claims, not re-run here (a read-only review). The head's check-runs are the gate verdicts.

Implemented-by: claude/issue-20356-inline-dataset-isolation
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 05:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 70ce802 Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20356-inline-dataset-isolation branch September 28, 2026 05:45
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: any member's inline dataset query replaces an authored cube for every user until restart, even when the query itself is refused 403

2 participants