Skip to content

Commit 156ddfa

Browse files
fix(service-analytics)!: the analytics door judges the generic-exit declarations — an object's enable block and a field's internal flag (#22634) (#22645)
Fixes #22634 Clause-②: no (narrowing) The analytics door now judges the two generic-exit declarations every other door judges: - **The object facet.** An object's `enable` block is judged through the spec's one exposure decision, `apiExposureDenialReason`, for the `aggregate` operation. - **The field facet.** A member that reads a field declared `internal: true` is refused. Both bind every caller, administrators included, on both strategies, and both are asked before any strategy is selected. > **Named gap — one CI gate is red by design, outside this claim's file surface.** `check:error-code-provenance` (spec) reds on the two new stamp sites: `@objectstack/service-analytics` now emits `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`, and the ledger lists both codes under `@objectstack/rest` only. Closing it is a ledger row (or a provenance waiver) in `packages/spec/src/api/error-code-ledger.zod.ts`, and the claim fences `packages/spec` off. The constants are deliberately named `*_CODE` so the gate sees them; a spelling the gate is blind to would have hidden the drift. The decision is with the seat (see the report on #22634). ## Step 1 — the measurement, by class The measurement used a real stack: `@objectstack/verify` `bootStack` with the real `SecurityPlugin`, on `sqlite-wasm`. The native-SQL strategy answered on that driver; the ObjectQL strategy was forced by withholding the native-SQL capability. Callers were a member holding read on every subject through a fallback permission set, and the seeded platform administrator. Subjects were two platform credential stores (`sys_jwks`, `sys_oauth_access_token`), `sys_session`, and fixture objects for the classes no platform object isolates. The probe was a scratch file and is not committed. No values are quoted below. **Before, on `origin/main` `f368b7e980`.** "Served" means `200` with the rows; for a column, the stored value came back as a group key, or the filter answered differently for a stored value and for any other value. | Class | Doors | Native SQL | ObjectQL | Data door, same declaration | |:--|:--|:--|:--|:--| | Object declaring `apiEnabled: false` | ad-hoc query, SQL face, configured cube, dataset | served, member and admin | served, member and admin | `404 OBJECT_API_DISABLED` | | Object whose `apiMethods` omits `list` | same four | served | served | `405 OBJECT_API_METHOD_NOT_ALLOWED` | | `internal` field as a dimension | ad-hoc, configured cube, dataset | value served as the group key. The member got it on the fixture object (row scope hid the credential stores' rows from the member); the admin got it on `sys_jwks` and `sys_oauth_access_token` too | engine refusal, undeclared `500` | aggregate face: engine refusal, undeclared `500` | | `internal` field as an aggregate input | ad-hoc | served | undeclared `500` | — | | `internal` field as a filter operand | ad-hoc, configured cube | served (oracle) | served (oracle) | **admitted** (oracle) — see Acceptance notes | | `internal` field in the SQL face | SQL face | statement compiled | statement compiled | — | | Controls: an ordinary object, an ordinary field | all | served | served | served | **After, on `013c717f91` (this branch merged with `origin/main`).** Same stack, same subjects, both strategies, both callers, one answer per row: | Class | Every analytics door, both strategies, member and admin | |:--|:--| | Object declaring `apiEnabled: false` | `404 OBJECT_API_DISABLED`, in the data door's words | | Object whose whitelist omits `list` | `405 OBJECT_API_METHOD_NOT_ALLOWED` | | `internal` field, in any position (dimension, measure input, filter, SQL face, configured cube, dataset) | `400 INVALID_FIELD`, naming the object and the field | | `internal` field on an `apiEnabled: false` object | `404 OBJECT_API_DISABLED` (the object answers first) | | Controls | `200`, unchanged: an ordinary column, a bare count over an object that has an internal field, `sys_session` counted | `sys_approval_token` is `apiEnabled: false` since #22633 landed. With `ApprovalsServicePlugin` mounted, it answered `404 OBJECT_API_DISABLED` on every door, both strategies and both callers. The codes reach the wire on all three route families: `/analytics/query` and `/analytics/sql` through the runtime dispatcher, and `/analytics/dataset/query` through the REST server. ## Census Read from the built object definitions on `f368b7e980`, plus #22633: - **`apiEnabled: false`:** `sys_flow_credential`, `sys_jwks`, `sys_oauth_access_token`, `sys_oauth_client_assertion`, `sys_oauth_client_resource`, `sys_oauth_consent`, `sys_oauth_refresh_token`, `sys_oauth_resource`, and since #22633 `sys_approval_token`. Four of them also declare an `internal` field: `sys_jwks`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_approval_token`. - **Whitelist without `list`:** `sys_device_code`, `sys_two_factor`, `sys_verification`. - **`internal` fields on exposed objects:** `sys_account` (5), `sys_api_key`, `sys_email`, `sys_http_delivery`, `sys_oauth_application`, `sys_scim_connection_credential`, `sys_session`, `sys_share_link` (2), `sys_sso_provider` (2), `sys_two_factor` (2), `sys_verification` (2). - **Authored analytics: no fork.** No authored cube, dataset, dashboard or example names any of these objects or fields. `examples/` declares no `apiEnabled: false`, no `apiMethods` and no `internal`. The only authored analytics over `sys_*` objects are the System Overview datasets. They count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by `action` and `user_id`. Each of those objects grants `list`, and none of those fields is `internal`. ## Which precedent decided refused vs withheld Measured on the data door, `origin/main`, both callers: - **Row path:** the `internal` field is withheld. It is omitted from rows, including when `select` names it. - **Aggregate face** (`POST /data/:object/query` grouping by it): refused, by the engine's `rejectCredentialAggregation` (#7922), because a group key cannot be withheld without changing what the groups count. The refusal arrives as an undeclared `500`. - **Filter:** admitted. That is not a precedent to copy; see Acceptance notes. Every analytics member is evaluated, never projected: a group key, an aggregate input, a predicate. So nothing can be omitted, and the governing precedent is the aggregate face's: refuse. The card's Ask names the filter position too. The envelope is the one this door already uses for a member it will not evaluate whoever asks, the stored-metadata-body refusal (#21120): `400 INVALID_FIELD`, naming the object and the field. ## The change `packages/services/service-analytics/src/api-exposure-door.ts`, new: - `assertObjectsExposed` asks `apiExposureDenialReason(enable, 'aggregate')`. It honours both answers: off switch → `404`, whitelist → `405` with the effective set. It holds no rule of its own. - `assertNoInternalFieldNamed` asks `@objectstack/core`'s `collectInternalWriteResponseFields`, the collector every write mouth and the knowledge index already reuse, over what `namedQueryFields` resolves (dimensions, measures, filters, sort keys, dataset filters, relationship hops). - **Fail-closed:** a lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `analytics-service.ts` asks the gates at these seams: - **Each door's entry**, before `ensureCube` and before dataset compile, over the named cube's objects. A refused ad-hoc request infers nothing (#20381 stays closed). - **`callCtx`**, over the full `queryObjects` set, relationship hops included, ahead of the read admission (exposure first, then permission, the data door's order). The field half sits beside the stored-metadata-body refusal. - **The draft-preview branch**, which never reaches `callCtx`. `plugin.ts` wires `getObjectDeclaration` from the data engine's `getObject()`, and throws (refuses) when no engine can answer. There is no system carve-out and no persona input. A host that builds `AnalyticsService` by hand with no hook gets no gate and a one-time warning, the same as the other providers. **Bounded in-place widening, stated.** The card names the off switch. Honouring only that half of the decision would be a second rule: the whitelist read as unrestricted at this door alone. So the `405` facet is included. It is the same defect class, in the same file, with the same envelope family. No in-repo analytics reads an object whose whitelist omits `list` (census above). ## Pins and their ablation `src/__tests__/api-exposure-door.test.ts`: 30 cases. - **Harness:** a real ObjectQL engine and a real `SqlDriver`, under `AnalyticsServicePlugin`'s own composition, on both strategies. Two callers: a member and an administrator, each granted read on every object and field by the security double. - **Negative pins,** per facet: off switch, whitelist, no minting on refusal, internal field in eight positions, internal field through a hop. Each asserts the envelope (`code` + `status` + `object` [+ `field`]) and that nothing read the object. - **Controls:** an ordinary object and field; an object that has an internal field, served for its other fields and through a hop. - **Fail-closed:** a throwing lookup; a plugin with no data engine. - **Red first:** at `6c5eb0278f` (the pins only, on the unmodified door), 22 failed / 8 passed. The 8 are the controls. The reds were rows served, or the engine's undeclared refusal. - **Green:** at `160b2200db`, 30/30. - **Ablation:** run through `scripts/ablation-replace.mjs` at `a7c40fbb2d`. Every anchor hit as declared and every blob changed. Each restore is proven by the blob equalling HEAD with `git diff HEAD` empty. The subject is imported from source, so no build sits between mutation and run. - A, the decision bypassed: 12 red (404 ×4, 405 ×4, no-mint ×4). - B, the internal check bypassed: 8 red. - C, the fail-closed catch neutralised: 2 red. - D, both entry gates removed: 4 red (no-mint only — the `callCtx` half still refuses, so the entry gate is what keeps a refused request from inferring a cube). ## Tests and gates All at `013c717f91`, this branch merged with `origin/main`, unless a line says otherwise. - **`@objectstack/service-analytics` test:** `vitest run --maxWorkers=2`, 181 files, 4473 passed / 262 skipped. - **`@objectstack/service-analytics` typecheck:** `tsc --noEmit` exit 0. The package tsconfig compiles the tests; it caught one unused parameter in the pins, fixed in `a7c40fbb2d`. - **`@objectstack/spec` `test:repo`:** 54 files / 915 tests passed. The new stamp sites are in that project's scan reach. - **Derived gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` (no paths) derived 65 commands from the real change set (5 paths vs merge base `86da19491`). All 65 exited 0, each exit captured before any pipe. `--ran` with exit codes recorded: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, packages without `dist/`); after the full build (72 tasks, 71 cached) it exited 0 (107 entries load). - **Not derived, run beside them:** - `check:adr-0087-registration` exit 0: the changeset reads `not-required (no-migration-prescription)`. - `check-changeset-no-major` exit 0. - `check:error-code-provenance` (spec) **exit 1**: the named gap above, two stamp sites and nothing else (337 sites: 317 listed, 18 waived). - **Lint, narrowed:** `eslint --no-inline-config` on the 4 changed `.ts` files. - All 4 are in the population: `--print-config` resolves 6, 6, 6 and 5 active rules. - `--format json` gives 4 files, 0 errors, 0 warnings. - The config enables no type-aware linting (no `parserOptions.project`, no `projectService`), so the verdicts of untouched files cannot move. - The repo-wide `pnpm lint` is CI's. - **Real-stack measurement:** a scratch probe through `bootStack`, before and after; the tables above. Not committed. - **CI:** not awaited. ## Acceptance notes - **The data door admits a filter on an `internal` field.** On `origin/main`, its list and query routes answer differently for the stored value and for any other value. That is a confirmation oracle over a withheld value, for any caller holding read. It is outside this card's surface and reported to the seat as a security finding. - **The engine's credential-aggregation refusal reaches the data door's aggregate face as an undeclared `500`.** It is a refusal of the caller's request, not a server fault. Reported to the seat. - **`check:error-code-provenance` is red until the ledger row lands** (the named gap above). - **A plugin composed with no data engine now refuses every analytics query.** The declarations live in that engine. The package suite (181 files) composes none that way. - **`GET /analytics/meta` still lists a configured cube over an `apiEnabled: false` object.** That is metadata, not rows. Querying the cube is refused. Not changed here. - **Registration is not refused.** `registerDataset` does not refuse a dataset over such an object; the query is refused instead. An authoring-time refusal would be a new gate, and none is added. - **The dimension-label pass reads a lookup target's display field.** Neither that pass nor the data door's `$expand` judges the target's exposure. No in-repo reach: the only lookup into an `apiEnabled: false` object starts from another one. - **`@objectstack/mcp`'s stdio bridge stamps `OBJECT_API_DISABLED` without a ledger row of its own.** Its constant's name is outside the provenance gate's declared patterns. Noted, not filed. ## Seat's append: patch round 1 (head `6709a20427`) Appended by `domain:services` seat 1 (`session_013j5gkUCpqQiti4GgPqqmnt`) at 2026-10-10T09:23Z, from the dev's round-1 report (`6096072918` on #22634). - The seat answered open question 1 with **A**: claim amendment `6095740822`, with the spec-lane declaration on #6017. - The data door's two findings are filed as #22646: a filter on an `internal` field, and the group-by `500`. - **The ledger row.** In `packages/spec/src/api/error-code-ledger.zod.ts`, the existing `'@objectstack/service-analytics'` row now lists `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`, in alphabetical order. A comment names the measured wire paths: `/analytics/query` and `/analytics/sql` through the runtime dispatcher, and `/analytics/dataset/query` through rest. The change is value-only: no other spec file, no export, no schema change. - **Nothing generated moved.** - `REGISTERED_ERROR_CODES` is byte-identical before (`e2b9e83626`) and after: 279 codes, the same array, the same sha256 prefix `b4910b3ab70945c7`. - Exactly one owner row of 32 changed. - `check:generated` reports all 15 generated artifacts up to date and nothing regenerated. - **`check:error-code-provenance`: exit 0.** 337 stamp sites: 319 listed (was 317), 18 waived. - **Changeset.** The ledger row ships in `@objectstack/spec`'s published files, so it gets its own `patch` changeset, `.changeset/22634-spec-ledger-analytics-exposure-codes.md`. That keeps the analytics BREAKING narrative out of spec's CHANGELOG. - **`origin/main` merged twice**, as `d85615ddd9` and `cc305a3cfc`. Both merges were clean, with no regeneration debt. - **Gates and tests at `6709a20427`:** - `dispatch-gates --commands` derived 90 commands from 7 paths. All 90 exited 0, and `--ran` reports 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. `check:adr-0087-registration` and `check:error-code-provenance` are among them. - `@objectstack/service-analytics`: 181 files, 4473 passed and 262 skipped. Typecheck exits 0. - `@objectstack/spec`: test (`local`) 642 files, 19171 passed. `test:repo` 54 files, 915 passed. Typecheck exits 0. - Narrowed lint on the 5 changed `.ts` files: 0 errors, 0 warnings. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e231a0 commit 156ddfa

7 files changed

Lines changed: 865 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634)
6+
7+
Clause-②: no (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 a runtime read door: analytics stops serving objects and fields whose existing declarations already withhold them from every other generic exit, and the remedy is the declaration the author already wrote, never a rewrite of anyone's code or metadata. One optional hook, `getObjectDeclaration`, is added to the exported `AnalyticsServiceConfig` type; it is additive, and 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+
Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration.
14+
15+
**FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike:
16+
17+
- The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`.
18+
- On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand.
19+
- On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand.
20+
21+
**TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration:
22+
23+
- `404 OBJECT_API_DISABLED` for `apiEnabled: false`;
24+
- `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb.
25+
26+
On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop.
27+
28+
- **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key.
29+
- **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out.
30+
- **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once.
31+
- **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it.
32+
33+
**Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634)
6+
7+
The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves.

0 commit comments

Comments
 (0)