Skip to content

Commit 7399080

Browse files
fix(service-analytics)!: a configured cube or registered dataset over an object the API does not serve is refused at registration, and GET /analytics/meta no longer lists it (#22663) (#22675)
Fixes #22663 Clause-②: yes (narrowing) A configured cube, or a dataset registered through `registerDataset`, whose base object or declared join is an object the spec's `apiExposureDenialReason` denies for the `aggregate` operation is now refused when it is registered. `GET /analytics/meta` no longer lists it. This is the card's preferred shape (a). It reuses the decision PR #22645 put at the query door (`api-exposure-door.ts`), for the same operation, over the same object set. There is no second rule, no new gate, and no lint rule. Dispatched by the `domain:services` seat 1 (session `session_013j5gkUCpqQiti4GgPqqmnt`, seat post #6021) under claim `6096404246`. File surface held: `packages/services/service-analytics/src/**` and one `.changeset/22663-*.md`. Nothing in `packages/spec`, `packages/lint` or `content/docs/releases/` is touched. ## Step 1: census (before any edit, on `origin/main` `ee3ae0360d`) | Producer | Count | Over a denied object? | |---|---|---| | Authored cubes (`analyticsCubes` / `defineCube`), examples and platform | 1: `showcase_delivery` (`showcase_task`, joined to `showcase_project`) | No. Neither object declares an `enable` block. | | Datasets (`defineDataset`): `examples/**` 6, `platform-objects` 5 | 11 | No. The five `sys_*` bases grant `list` or are not declared in this repo (`sys_package_installation` is a cloud object). None of the eleven enters the cube registry: stack datasets are compiled per request by `queryDataset` (request scope), never by `registerDataset`. | | `registerDataset` callers outside tests | 0 | n/a | | Writers of `service.cubeRegistry` / `inferFromObject` outside tests | 0 | n/a | | `analytics:ready` listeners that register cubes | 0 | n/a | | Dogfood suites (`packages/qa/dogfood`) with a cube or dataset over an `apiEnabled: false` / no-`list` object | 0 | n/a | | Test fixtures | 2: `hidden_cube` and `nolist_cube` in `api-exposure-door.test.ts` (PR #22645) | Yes. Re-triaged below. | There is no real producer that shape (a) breaks, so shape (b) (keep the registration and hide the cube from `getMeta`) was not taken. Deployed and cloud-held definitions were NOT MEASURED. ## Registration entry points The before column was read on `ee3ae0360d`. | Door | Reached by | Bad definition, before | After | |---|---|---|---| | `AnalyticsServiceConfig.cubes` | the plugin's `cubes`, which `os serve` fills from `analyticsCubes` (`CAPABILITY_PROVIDERS.analytics`), inside the plugin's `init()` | **accept** (no check at all) | **warn and skip**: one `warn` per cube names the cube, the object and the declaration, and every other cube still registers | | `AnalyticsServiceConfig.datasets` | constructor → `registerDataset` | accept; a dataset that cannot compile was warned and skipped (the #5115 channel) | **warn and skip**, through that same existing channel | | `registerDataset()` | an embedder | accept; a dataset that cannot compile threw | **refuse**: throws a located error and registers nothing | | `service.cubeRegistry.register()` / `registerAll()` / `inferFromObject()` | the public `cubeRegistry` field (no in-repo caller) | accept | **refuse**: the registry's own admission throws, and nothing is stored | | `queryDataset()` / ad-hoc `query()` / `generateSql()` | requests | never write the shared registry (#20356, #20381) | unchanged | **Refusal channel, measured and reused.** The constructor already had a per-definition boot channel: a pre-registered dataset that cannot compile is warned and skipped, so the boot continues and the other datasets register (`dataset-cross-datasource-registration.test.ts`, #5115). Config cubes now take that channel. `registerDataset` already threw for a definition it could not compile, and it now throws for this one too. The thrown error carries the query door's envelope for the same declaration: - `code: OBJECT_API_DISABLED`, `status: 404` for `enable.apiEnabled: false`; - `code: OBJECT_API_METHOD_NOT_ALLOWED`, `status: 405` for an `enable.apiMethods` whitelist that does not grant the operation, plus `allowed`. Both carry `object`. The message names the definition, the object, the declaration and the denial reason. Both codes are already ledgered under `@objectstack/service-analytics` (`check:error-code-provenance` OK). **Level.** The boot-time line is `warn`, as for its dataset sibling. Under AGENTS.md's degradation rule this is a functional degradation: the refused cube is visibly absent from `getMeta`, and nothing claimed as persisted is lost. ## Timing finding The service registers configured cubes when the plugin's `init()` constructs it, so an object is judged only if its declaration exists at that moment. I measured this with a throwaway probe: a `LiteKernel` with the real `ObjectQLPlugin`, three `apiEnabled: false` objects registered through the `manifest` service, and an observer plugin inited just before analytics. The probe is not committed. | Object registered | Declared when analytics inits? | `getMeta` before this change | `getMeta` after | |---|---|---|---| | by a plugin whose `init()` precedes analytics | yes | listed | **refused** | | by a plugin whose `init()` follows analytics | no | listed | listed (window) | | in a plugin's `start()` | no | listed | listed (window) | After boot, all three objects are declared. In `os serve`, read from source: `AppPlugin` (an app's own objects) and `PlatformObjectsPlugin` are `kernel.use`d before the capability resolver mounts `analytics`, so a cube over an app's own objects or a platform object is judged. Objects contributed by a capability provider that follows `analytics` in `requires` order, `start()`-time registrations and `kernel:ready` installs fall in the window. That is the #5115 probes' ordering: `registerDataset` compile gating asks `getObjectDatasource` / `isExternalObject` at the same moment and takes "cannot answer, do not block". This reuses it: - an object with no declaration yet is not refused; - a declaration lookup that throws is not refused either (the plugin's bridge throws when no data engine is registered); - in both cases the query door still refuses every query of the cube, fail-closed. The conclusion drawn is never "absent, therefore refused", so `check:startup-registry-verdict` holds (45 seams, none recording a verdict the boot can contradict). Whether the window is owed a second judgment is left to the seat (see Acceptance notes). ## Change - **`api-exposure-door.ts`**: `assertDefinitionExposed(kind, name, objects, provider)`, the registration face. It makes the same `apiExposureDenialReason(enable, 'aggregate')` call and returns the same codes. It does not block when it cannot answer. - **`cube-registry.ts`**: an optional `CubeAdmission` constructor parameter, asked by `register` before storing. Every write path goes through `register`. A registry constructed without one admits everything, as before. The parameter is additive. - **`analytics-service.ts`**: - The registry is constructed with the admission, which judges `cubeObjects(cube)` (base object plus declared joins), the set the query door's first check reads. - Configured cubes register one at a time, after the probes are assigned, through the warn-and-skip channel. - `registerDataset` judges as a dataset first, so the refusal names the dataset, then registers. The registry's admission repeats the same check on the same cube and gets the same answer. - A host with no `getObjectDeclaration` hook gets no gate, as on the query door. ## Pins (`unexposed-definition-registration.test.ts`, 12 cases) Refusals: - **Configured cubes, base object.** `apiEnabled: false` and a `get`-only whitelist are each warned once, located, absent from the registry and from `getMeta()` / `getMeta(name)`. The other cubes register. - **Configured cube, joined object.** A cube whose joined object is unexposed is refused, and the warning names the joined object. - **Envelope.** The refusal carries `OBJECT_API_DISABLED`/404 or `OBJECT_API_METHOD_NOT_ALLOWED`/405, with `object`. - **Direct registry write.** It is refused, and nothing is stored. - **`registerDataset`.** It throws for an unexposed base object (404 and 405 variants) and for an unexposed joined object (via `include`), naming the dataset and the object. Nothing is registered, and `getMeta(name)` is empty. - **Constructor `datasets`.** Each refused dataset is warned once and skipped, and the exposed one registers. - **Shipped wiring.** `AnalyticsServicePlugin` over a real `ObjectQL` engine with a SQLite driver: configured cubes over unexposed objects are refused at `init()`, warned, and absent from `getMeta`. `registerDataset` on the plugin's service refuses, and an exposed dataset registers. Controls and tiering: - **CONTROL.** A dataset over an exposed object, joined to an exposed object, registers and lists as before. The exposed cubes in every case above register and list. - **Tiering: no declaration yet.** The cube registers. The query door refuses it 404 once the object is declared. - **Tiering: lookup throws.** The cube registers. The query door refuses it `PERMISSION_DENIED`/403. - **Tiering: no probe wired.** Every definition registers as before. **Fixture triage, `api-exposure-door.test.ts`.** Its `hidden_cube` and `nolist_cube` were configured cubes over denied objects, and the "configured cube" rows pinned the query door for them. They are now refused at registration whenever the declaration is known. The suite's `beforeAll` therefore inits the plugin before the subjects are declared, so those rows pin the query door in the window. They now measure the only state in which that door is reachable for a configured cube. Every assertion in the file is unchanged. **Ablation.** I ran it with `scripts/ablation-replace.mjs` in wrap mode, under the verify lock, at `c8dae0a783`. The test imports the service from `src` by a relative path, with no `dist` hop. - **Anchor.** The one call in `assertRegistrable`: `assertDefinitionExposed(kind, cube.name, this.cubeObjects(cube), provider);`. It hit once, as declared. - **Mutation.** The anchor went from 1 to 0 and the replacement from 0 to 1. The blob moved `07cb9454e93f` → `ea44bf4cd373`. - **Result.** 8 failed and 4 passed of 12. Every refusal pin went red. The 4 green are the dataset control and the three tiering pins, which is the predicted direction. - **Restore.** The blob is back to `07cb9454e93f`, equal to HEAD, and `git diff HEAD` is empty. I re-checked independently afterwards: the ablation marker count is 0 and the anchor count is 1. ## Verification (final HEAD `dcc38d4413`, after merging `origin/main`) - `pnpm turbo run build --filter=@objectstack/service-analytics --concurrency=1`: 15/15 tasks. - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: 182 files passed; 4485 passed, 262 skipped. - `pnpm --filter @objectstack/service-analytics typecheck`: `tsc --noEmit` clean. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands, the same set as before the merge. I ran all 65, plus 4 implicated ones: `check:startup-registry-verdict`, `check:durability-log-level`, `check:adr-anchors`, and spec `check:error-code-provenance`. Each exit was captured before any pipe, and all 69 exited 0. `--ran` reports: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero because all 65 recorded an exit code. - Selected verdict lines: - `check:adr-0087-registration`: the `no-migration-prescription` exemption is accepted. - `check:empty-changeset`: one declaring changeset added. - `check:nul-bytes`: OK. - `check:test-source-alias`: OK. - `check:cross-package-test-inputs`: OK. - The dist-reading gates (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:published-files`, `check:sourcemap-no-sources-content`) ran after the rebuild at this HEAD. - Not run locally, and declared to CI: the repo-wide `pnpm lint` and the other packages' suites. The public surface that changed is one additive optional constructor parameter, on a class no other workspace package constructs. ## Changeset `.changeset/22663-analytics-unexposed-cube-registration.md` is a `minor` bump of `@objectstack/service-analytics`, BREAKING as an accept-set narrowing under the launch-window convention. It records FROM and TO and the ADR-0087 disposition `not-required (no-migration-prescription)`: no metadata moves, and the remedy is to point the definition at an exposed object or remove it. ## Acceptance notes 1. **The registration window (see Timing).** A configured cube over an object declared after the analytics plugin's `init()` still registers and lists. This covers a later capability provider's objects, a `start()` registration, a `kernel:ready` install, and an object authored at runtime. Every query of such a cube is still refused by the query door. Closing the window needs a second judgment after the boot settles, for example at `kernel:ready`, which is beyond reusing the #5115 timing. It is raised as an open question in the report, not built here. 2. **A relationship hop one member walks is not judged at registration.** It refuses that member's queries, not the whole cube, so refusing the cube for it would over-refuse. The query door judges it per member, as PR #22645 pins. 3. **A lookup that throws at registration is not logged.** That is the stand-down tier. In the shipped plugin the throw means no data engine yet, which the plugin already warns about at `init()`, and the query door logs the same failure at `error` when a query meets it. 4. **`CubeRegistry.registerAll` stops at the first refused cube**, and the cubes before it stay registered. This is documented on the method. The service no longer uses `registerAll`, and registers config cubes one at a time. 5. **An authoring-time check is not added.** A `packages/lint` rule in the style of `validate-nav-object-servability`, refusing an `analyticsCubes` entry over an unexposed object at `os validate`, would catch the pair before boot and inside the window. The claim fences `packages/lint` off and no producer is measured, so it is noted here and not filed. 6. **Card relation.** This PR closes #22663. It does not touch PR #22645's codes or its query-door behaviour. ## Seat's note: line 2 re-declared Written by `domain:services` seat 1 (`session_013j5gkUCpqQiti4GgPqqmnt`) at 2026-10-10T11:54Z. Line 2 now reads `Clause-②: yes (narrowing)`, following the contract review of `dcc38d4413` (`6097218872`, ①.5 and ②). - `CubeRegistry` is re-exported from the package entry, so its new optional constructor parameter enlarges the published surface. That is a widening, beside the accept-set narrowing. - The changeset's line is re-declared in the patch round. The level stays `minor`. ## Seat's append: patch round 1 (head `3a646f5ffb`) Appended by `domain:services` seat 1 (`session_013j5gkUCpqQiti4GgPqqmnt`) at 2026-10-10T12:49Z, from the dev's round-1 report on #22663. The dev's text follows, unedited except for heading levels. The seat read the head against GitHub, in a seat-owned ref: - the 16 `producer` quotes match `serve.ts` and the per-cube loop in `analytics-service.ts` at this head; - 16 rows carry `verifiedAt: 2026-10-10`; - `registerAll` is gone from the ledger; - the changeset line reads `Clause-②: yes (narrowing)`. ### Round 1 (patch) Head: `3a646f5ffb`. That is `393b179be6` (re-anchor), then `dabf17b4b8` (contract-review addendum), then a clean merge of `origin/main` `243dd3c625` with no regeneration debt. **Cause.** `Test Core (1/6)` at `dcc38d4413` failed in `@objectstack/spec`'s liveness gate. Sixteen rows in `packages/spec/liveness/analytics_cube.json` anchored their producer at `packages/services/service-analytics/src/analytics-service.ts#registerAll`. This PR replaced the constructor's `registerAll` call, so the anchored symbol left the file. Reproduced on `dcc38d4413` before any edit: - `check-liveness.test.ts` (local): 24 failed, 47 passed, exit 1. - `evidence.test.ts` (repo): 1 failed, 41 passed, exit 1. - `check:liveness`: exit 1, with "16 anchored citation(s) name a symbol the cited file does not contain". Round 0's local set missed it. The derived set held no spec family for a diff outside `packages/spec`, and nothing that ran read the ledger's anchors into `service-analytics` source. That blind spot is filed as #22680. **Re-anchored symbol: `analytics-service.ts#register`.** The constructor now registers configured cubes through `this.cubeRegistry.register(cube)`, one call per cube. `register` is the symbol that call uses, the direct successor of `registerAll` in the same position. Nothing in `analytics-service.ts` changed to fit the anchor. **Contract-review addendum (`6097218872`), value-only in the same ledger.** No status, key or row moved, and no other spec file was touched. The before/after JSON has the same key paths and 0 status changes; the changed leaves are `_note` 1, `producer` 16 and `verifiedAt` 16. - **`verifiedAt: 2026-10-10` on the 16 rows.** Their shared producer string now quotes the code at this head. This covers the `serve.ts` fallback `resolveStackCollection(config, 'analyticsCubes')`, which had been stale since #22288 and was a `?? []` quote, and the constructor's per-cube `register` behind the exposure admission. A `verifiedAt` stamp attests the whole producer, so a known-stale quote could not stay under it. - **`_note`.** It now reads "registered one cube at a time by `register`". - **Changeset `Clause-②: yes (narrowing)`.** `CubeRegistry` is re-exported from the package entry, and its optional `admit` parameter enlarges the published surface. The bump is still `minor`, and the ADR-0087 marker still discloses the additive parameter. `check-adr-0087-registration --base origin/main` exits 0 (`[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`), and `check-changeset-no-major --base origin/main` exits 0. **Gate exits at `3a646f5ffb`.** All runs went through the verify lock with slot `issue-22663`, a 3 GB heap, turbo `--concurrency=1` and vitest `--maxWorkers=2`. - **Liveness pair.** `check-liveness.test.ts`: 71/71, exit 0. `evidence.test.ts`: 42/42, exit 0. `check:liveness`: exit 0. - **Spec suites.** `test` (local): 642 files, 19159 passed and 1 todo, exit 0. `test:repo`: 55 files, 971 passed, exit 0. - **`@objectstack/service-analytics`.** 182 files, 4485 passed and 262 skipped, exit 0. Typecheck exit 0. - **Builds.** The service-analytics closure built 15/15. A full `turbo run build --filter=!@objectstack/docs` built 72/72 (71 cached) for the dist-reading gate. - **Derived set.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 72 commands, 7 of them new spec/repo families (including `check:liveness`). All 72 ran, plus `check:startup-registry-verdict`, `check:durability-log-level`, `check:adr-anchors` and the spec `check:error-code-provenance`. 75 exited 0 and 1 exited 1. `--ran` reports 72 derived, 72 run, 0 NOT-MEASURED and 0 UNRUN, a derived zero. - **`check:dual-build-cjs-loads`.** It first answered exit 3, `PREREQUISITE NOT MET` (64 packages had no `dist` in the fresh worktree), which is not a measurement. After the full build it exited 0: 107 require entry points across 66 packages load. - **`check:platform-checklist` exit 1, main-side.** It reports 7 problems, all ABSENT SYMBOL anchors. Five are in `areas/access-security.json`, against `metadata-protocol/src/protocol.ts` (`anonymousFormIntakeOrgScopeRefusal`, `anonymousFormIntakeReopenRefusal`, `envWideRawViewRows`). Two are in `areas/attachments-storage.json`: `#canEdit`, and the symbol-anchor floor at 27 of 28. None of those files is in this diff. Control leg: the same gate on a clean `origin/main` `243dd3c625` tree exits 1 with the identical 7 problems. - **Seat's disposition:** dropped as a duplicate. The red is the watchdog card #22594's, which triage closed `not_planned` with its re-entry conditions (`6095872178`). The gate is kept out of per-PR CI by maintainer decision (`lint.yml`'s note), and the daily watchdog files it again. **CI at `3a646f5ffb`:** 32 success and 3 skipped (`Build Docs`, `Console Pin Gate`, `Packed-tarball smoke (opt-in)`), read latest-run-per-check-name. The red `TypeScript Type Check` and `Test Core` events at `dabf17b4b8` and `393b179be6` were cancellations by the next push, not verdicts. ## Seat's append: patch round 2 (head `0dd4426835`) Appended by `domain:services` seat 1 (`session_013j5gkUCpqQiti4GgPqqmnt`) at 2026-10-10T13:36Z, from the dev's round-2 report on #22663. The seat folded flag ③.4 of `6097791315` into this branch (`6097801200`). The dev's text follows, unedited except for heading levels. The seat read the head against GitHub, in a seat-owned ref: - The diff against `3a646f5ffb` is one file, `analytics_cube.json`, with 17 lines in and 17 out. - `joinTable` is gone from the ledger. - 16 rows cite `packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS`. - Each new quote is present at head: `const joined = joins?.[alias]?.name;` in `hop-object.ts`, `const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS` and `spec.configKey === 'analyticsCubes'` in `serve.ts`, and `object: hop.object` in `native-sql-strategy.ts`. ### Round 2 (ledger) Head: `0dd4426835` (one commit on `3a646f5ffb`). `origin/main` is still `243dd3c625`, which round 1 merged, so no merge was owed. Only `packages/spec/liveness/analytics_cube.json` changed, and only values. The contract review of `3a646f5ffb` (`6097791315`, flag ③.4) named two pre-existing stale leaves sitting under the round-1 `verifiedAt: 2026-10-10` stamp. **1. `joins.children.name`, first `evidence` citation.** It quoted `const joinTable = cube?.joins?.[alias]?.name ?? alias` at `native-sql-strategy.ts#qualifyAndRegisterJoin`. `joinTable` has 0 occurrences in that file at head. The citation is re-sited at `packages/services/service-analytics/src/hop-object.ts#resolvePathHops`, quoting `const joined = joins?.[alias]?.name;` verbatim (tier 1 of the one hop resolver). It keeps `native-sql-strategy.ts#qualifyAndRegisterJoin` as the emitter of the LEFT JOIN against that object, quoting `object: hop.object`, which is verbatim at head. The row's other three citations are unchanged. **2. The 16 `producer` strings.** - The `configKey` declaration clause is re-sited at `packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS`, which declares `configKey: 'analyticsCubes'` on the `analytics` entry. - The `serve.ts` threading quote stays where it lives, at `packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS`. That is the resolver's own declared handle on the table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`), which the resolver loop indexes. The clause now quotes the test it applies, `spec.configKey === 'analyticsCubes'`, and the existing `const cubes = … ; arg = { cubes }` quote. - Every anchor names a declaration in its cited file, and every quote occurs exactly once at head. **Fence proof.** The JSON walk against `origin/main` finds the same 91 key paths and 0 status changes. Changed leaves by field: `_note` 1, `verifiedAt` 16, `producer` 16, `evidence` 1. Against the round-1 head, only `producer` 16 and `evidence` 1 moved. **Gates at `0dd4426835`.** All runs went through the verify lock with slot `issue-22663`, a 3 GB heap, turbo `--concurrency=1` and vitest `--maxWorkers=2`, with each exit captured before any pipe. - `check-liveness.test.ts` (local): 71/71, exit 0. - `evidence.test.ts` (repo): 42/42, exit 0. - `pnpm --filter @objectstack/spec run check:liveness`: exit 0. - Spec `test:repo`: 55 files, 971 passed, exit 0. - Full `turbo run build --filter=!@objectstack/docs`: 72/72 tasks, exit 0. - `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 72 commands, the same set as round 1. All 72 ran, plus 4 implicated ones. 75 exited 0 and 1 exited 1. `--ran` reports 72 derived, 72 run, 0 NOT-MEASURED and 0 UNRUN, a derived zero. - `check:platform-checklist` still exits 1 with the identical 7 main-side ABSENT SYMBOL problems that round 1's clean `origin/main` `243dd3c625` control showed. None of them is in this diff. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80b2da2 commit 7399080

7 files changed

Lines changed: 589 additions & 59 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the analytics registry's accept set: a cube or dataset whose base object or declared join the spec's exposure decision already denies is no longer registered, and every query of such a cube was already refused. The remedy is to point the definition at an object the API serves or remove it, never a rewrite of anyone's code or metadata. `CubeRegistry` gains one optional constructor parameter, which is additive; nothing on the type surface narrows. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question.
14+
15+
**FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time.
16+
17+
**TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins.
18+
19+
- **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes.
20+
- **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object.
21+
- **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words.
22+
- **`GET /analytics/meta`** never lists a refused definition.
23+
24+
**Unchanged.**
25+
26+
- A cube or dataset over an exposed object registers and lists exactly as before.
27+
- A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it.
28+
- Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it.
29+
- A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube.
30+
31+
**Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED.

‎packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ const ROWS: Record<string, Array<Record<string, unknown>>> = {
109109
],
110110
};
111111

112-
/** Configured cubes — the authored cube door over each subject. */
112+
/**
113+
* Configured cubes — the authored cube door over each subject, registered
114+
* before the subjects are declared (see `beforeAll`).
115+
*/
113116
const CUBES = [
114117
{
115118
name: 'hidden_cube',
@@ -217,6 +220,28 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati
217220
engine = new ObjectQL({ logger: quiet } as any);
218221
engine.registerDriver(driver, true);
219222
await engine.init();
223+
224+
// The plugin inits BEFORE the subjects are declared, on purpose: a
225+
// configured cube over an object the API does not serve is refused at
226+
// registration when the declaration is already known
227+
// (`unexposed-definition-registration.test.ts`), so the configured-cube
228+
// rows below can only reach the QUERY face through the window registration
229+
// cannot judge — an object registered after the analytics plugin's init.
230+
// That window is what they pin.
231+
const security = grantEverything(engine);
232+
const registered: Record<string, unknown> = {};
233+
await new AnalyticsServicePlugin({
234+
cubes: CUBES as any,
235+
...(capabilities ? { queryCapabilities: capabilities } : {}),
236+
}).init({
237+
getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]),
238+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
239+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
240+
hook: () => {},
241+
logger: quiet,
242+
} as never);
243+
service = registered.analytics as AnalyticsService;
244+
220245
for (const obj of [HIDDEN_OBJECT, NO_LIST_OBJECT, VAULT_OBJECT, LEDGER_OBJECT, OPEN_OBJECT]) {
221246
engine.registry.registerObject(obj as any);
222247
}
@@ -236,20 +261,6 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati
236261
reads.set(object, readsOf(object) + 1);
237262
return (realAggregate as any)(object, ...rest);
238263
};
239-
240-
const security = grantEverything(engine);
241-
const registered: Record<string, unknown> = {};
242-
await new AnalyticsServicePlugin({
243-
cubes: CUBES as any,
244-
...(capabilities ? { queryCapabilities: capabilities } : {}),
245-
}).init({
246-
getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]),
247-
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
248-
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
249-
hook: () => {},
250-
logger: quiet,
251-
} as never);
252-
service = registered.analytics as AnalyticsService;
253264
});
254265

255266
afterAll(async () => {

0 commit comments

Comments
 (0)