Repository navigation
Commit 156ddfa
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
- .changeset
- packages
- services/service-analytics/src
- __tests__
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
0 commit comments