Skip to content

Commit e175479

Browse files
docs(service-analytics): the source comments state the ruled deny for a deployment with no security service (#22299)
Part of #22279 Clause-②: no This is the source-comment half of #22279: comments only, plus the patch changeset their published text needs. The release-owned text is in the docs-only PR #22296. The card stays open until both PRs have merged. ## Why Maintainer ruling B on #22235 (`6056824332`, 「同意」, 2026-10-08), verbatim: "**B — deny, as measured, becomes the declaration.** On a deployment with no security service the analytics read bridges deny, fail-closed, as they do today; the bridge comments and the header of `admission-bridge-resolution.test.ts` say so; the "absent admits" sentence in the released text is corrected by a docs card, since that text is release-owned." PR #22276 (`58707166f`) rewrote the bridge comments in `plugin.ts` and the test header to that ruling. The comments here are the ones its acceptance notes listed outside its fence. Each one equated "no provider wired" with "a deployment with no security service" and said such a deployment keeps its analytics behaviour. Through `AnalyticsServicePlugin` a provider is always wired, either the host's own or the bridge. On `ObjectKernel` and `LiteKernel` the lookup of a `security` service that was never registered throws, so the bridges take UNUSABLE and refuse the query, fail-closed. The new text follows the `plugin.ts` wording on `main`. ⛔ It does not say the ABSENT branch denies. ABSENT is described as reached only by a context that answers the lookup with nothing (the package's test doubles do, and no in-repo kernel does). Whether ABSENT itself should deny is not ruled. ## What changed (comments only) Line positions are at base `58707166f`. | File | Site | Listed on the card? | |---|---|---| | `read-admission.ts` | module header, *Fail direction*, `:51-65`. The ABSENT bullet is replaced by three bullets: the declared deny on a deployment with no security service, the ABSENT context, and a host that constructs `AnalyticsService` with no provider. The lead-in "but closed is a claim about a WIRED provider" goes. | yes (`:59-65`) | | `analytics-service.ts` | `AnalyticsServiceConfig.admitObjectRead` doc, `:842-845` | yes | | `analytics-service.ts` | `AnalyticsServiceConfig.getReadableFields` doc, `:862-866` | yes | | `analytics-service.ts` | `assertReadAdmitted` doc, `:1899-1901` (the card's `:1897-1899`) | yes | | `field-read-admission.ts` | `assertCallerMembersJudgeable` header, `:338-341` | yes (`:339-341`) | | `analytics-service.ts` | the `[#20917]` field-level read gate doc, `:1922` "A no-op when no provider is wired (no security service)". It documents `assertFieldsReadable` but sits detached, directly above the member-shape gate's doc. | **added** | | `analytics-service.ts` | `assertCallerMembersResolvable` doc, `:1935` "that gate is a no-op with no security service" | **added** | | `analytics-service.ts` | `assertDatasetFieldsJudgeable` doc, `:2061` "which stands down with no security service" | **added** | | `read-scope-refusal.ts` | `readScopeUnresolvedError` doc, `:96-99`: "a deployment with no security service … is reported loudly at init, and it must keep running unscoped exactly as before" | **added** | ### The four added sites (bounded in-place fix, declared here) The card listed four sites, taken from PR #22276's acceptance notes. A census of the package's `src/` for the same claim, `git grep -n -i 'no .?security.? service'` outside `__tests__`, found four more. Three are in `analytics-service.ts`, a file this card already holds. The fourth is the row-scope sibling of the read-admission header. All four meet the four conditions: the same defect class as the card, a mechanical rewording to a shape the ruling already fixed, the same gates, and no other claim on the file (`read-scope-refusal.ts` is in none of the 12 open PRs' file lists, which were read during this run before the edit). Leaving them would keep the claim alive twenty lines below the doc this PR corrects. Reviewer: if you want any of them out, say which and it comes back in a patch round. ## Proof that only comments moved For each of the four files, `ts.transpileModule` with `removeComments: true` gives byte-identical output for the base file and for this head: ``` read-admission.ts base=8a4829c31e94d58c/1351 head=8a4829c31e94d58c/1351 analytics-service.ts base=8dd74af6788b7660/65291 head=8dd74af6788b7660/65291 field-read-admission.ts base=9be45d2722f380cd/5145 head=9be45d2722f380cd/5145 read-scope-refusal.ts base=badef6b1c177459d/233 head=badef6b1c177459d/233 ``` (sha256 prefix / bytes.) Positive control: the same comparison over `read-scope-refusal.ts` with `err.status = 500` changed in memory to `501` reports the stripped outputs as different. ## Changeset: `patch` for `@objectstack/service-analytics`, not `skip-changeset` AGENTS.md: published means what `files[]` ships, and this package ships `["dist","README.md","CHANGELOG.md"]`. After a rebuild of the package (under the verify lock), the edited JSDoc is in the published output: - "That is not a deployment with no security service" (the two `AnalyticsServiceConfig` docs) is in `dist/index.d.ts` and `dist/index.d.cts`. - "without `admitObjectRead`. `AnalyticsServicePlugin` always wires one" and "no-op with no field reader wired" (class-member docs) are in all four of `dist/index.{js,cjs,d.ts,d.cts}`. - "a deployment that registers NO security service denies" (the module header) is in `dist/index.d.ts` and `dist/index.d.cts`. - Control for that reading: the `read-scope-refusal.ts` phrase "their lookup throws on the never-registered name" is in 0 files, because a non-exported function's doc is not emitted. A runtime string from `plugin.ts` is in 2 files. The published `@objectstack/service-analytics@17.6.0` tarball carries the old sentences in the same files. "the deployment has no security service, which" and "keeps its pre-existing analytics behaviour" are in its `index.d.ts` and `index.d.cts`. "A no-op when no provider is wired: that is a deployment with no security" is in all four. So this diff changes published bytes and takes a `patch`: `.changeset/22279-analytics-absent-security-comments.md`. This differs from PR #22276, which carried `skip-changeset`. Its `plugin.ts` comments sit inside a function body, measured at 0 files under `dist/`. Here the comments are on an exported interface and on class members, which the build keeps. ## Verification (head `7fb9cee98`) - `@objectstack/service-analytics` was built under the verify lock: its dependency closure, then the package itself. `check-dts-emitted` found "2/2 declared declaration file(s) present". - `pnpm --filter @objectstack/service-analytics typecheck` exits 0. - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: "Test Files 180 passed (180)", "Tests 4443 passed | 262 skipped (4705)". These are the same counts PR #22276 recorded. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **58** commands. Each was run with its exit code captured before any pipe. **57 exit 0.** **1 is NOT MEASURED**: `pnpm check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`, because 65 workspace packages have no `dist/` in this worktree. A targeted substitute reading: `require('./dist/index.cjs')` of this package loads, with 17 exports and `AnalyticsServicePlugin` a function. `dispatch-gates --ran` gives: "58 derived, 57 run, 1 NOT-MEASURED, 0 UNRUN." - Named results: `check:dts-closure` reports 75/75 declaration files across 15 built packages. `check:sourcemap-no-sources-content` reports 105 maps across 15 packages, none embedding source. `check:published-files`, `check:nul-bytes` (10246 tracked files, no raw control bytes), `check:doc-authoring`, `check-empty-changeset` ("1 declaring changeset(s) added") and `check:lean-entry-closure` all exit 0. Locally, `check-changeset-no-major` reads its clause-② level axis as not applicable, because there is no `pull_request` payload. CI reads this body's `Clause-②: no`. - Lint, narrowed: `eslint --no-inline-config --format json` over the 4 touched source files reports 4 files, 0 errors and 0 warnings. The config's only global ignores are `node_modules`, `dist`, `build`, `.next` and `.turbo`, so all 4 files are in scope. The config never enables type-aware linting (no `parserOptions.project` in `eslint.config.mjs`), so this diff cannot change the verdict on a file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - Not here, per the card: the runtime UNUSABLE log text ("A security service is wired on this deployment") in `plugin.ts` and `read-admission.ts`, and any change in behaviour. - Two test titles still say "no security service" for a double that answers the lookup with nothing. One is the describe at `admission-absence-report.test.ts:199`, the other the tier label at `caller-member-column-reference-gate.test.ts:109`. Test names are outside this comments-only fence. Noted, not filed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98cce87 commit e175479

5 files changed

Lines changed: 63 additions & 29 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
docs(service-analytics): the shipped API docs no longer say a deployment with no security service keeps analytics reads open
6+
7+
Clause-②: no
8+
9+
The doc comments on `AnalyticsServiceConfig.admitObjectRead` and `AnalyticsServiceConfig.getReadableFields`, and on the service's read gates, said that a missing hook means "the deployment has no security service" and that such a deployment keeps its analytics behaviour. Both parts were wrong. `AnalyticsServicePlugin` always wires both hooks. On `ObjectKernel` and `LiteKernel`, looking up a `security` service that was never registered throws, so the bridges refuse the query, fail-closed: `PERMISSION_DENIED` / 403 naming the object, plus an `error` line. The docs now say so. A missing hook now means only a host that constructs `AnalyticsService` itself without one.
10+
11+
Only comments change. No behaviour, export or type moves. The text changes in the published `dist/index.d.ts` and `dist/index.d.cts`, and in the JSDoc kept in `dist/index.js` and `dist/index.cjs`.

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -841,8 +841,11 @@ export interface AnalyticsServiceConfig {
841841
*
842842
* MAY be async. `false` refuses the query with `PERMISSION_DENIED` / 403; a
843843
* THROW also refuses (fail-closed). When the hook is absent entirely no
844-
* object-level gate applies — the deployment has no security service, which
845-
* is the same deployment in which `/data` has no object-level gate either.
844+
* object-level gate applies — a host that constructs the service without
845+
* one. That is not a deployment with no security service: the plugin always
846+
* wires this hook, and on the in-repo kernels its bridge DENIES when no
847+
* `security` service was ever registered, because the lookup throws
848+
* (`plugin.ts`).
846849
*/
847850
admitObjectRead?: ObjectReadAdmissionProvider;
848851
/**
@@ -861,9 +864,11 @@ export interface AnalyticsServiceConfig {
861864
* `getReadableFields`, so the answer is the one the engine middleware
862865
* enforces with. MAY be async. `undefined` for an object is "no answer" and
863866
* judges none of its fields; a THROW refuses the query (fail-closed). When
864-
* the hook is absent entirely no field-level gate applies — the deployment
865-
* has no security service, which is the one in which `/data` has no
866-
* field-level security either.
867+
* the hook is absent entirely no field-level gate applies — a host that
868+
* constructs the service without one. That is not a deployment with no
869+
* security service: the plugin always wires this hook, and on the in-repo
870+
* kernels its bridge THROWS when no `security` service was ever registered,
871+
* so the query is refused (`plugin.ts`).
867872
*/
868873
getReadableFields?: ReadableFieldsProvider;
869874
/**
@@ -1896,9 +1901,11 @@ export class AnalyticsService implements IAnalyticsService {
18961901
* carrying its own copy of the check, which is the arrangement that produced
18971902
* the divergence in the first place.
18981903
*
1899-
* A no-op when no provider is wired: that is a deployment with no security
1900-
* service, where `/data` has no object-level gate either, so the doors still
1901-
* agree. `AnalyticsServicePlugin` reports that state at init.
1904+
* A no-op when no provider is wired — a host that constructs this service
1905+
* without `admitObjectRead`. `AnalyticsServicePlugin` always wires one, so a
1906+
* deployment with no security service is NOT this case: on the in-repo
1907+
* kernels the bridge's lookup throws and the provider denies, fail-closed
1908+
* (`plugin.ts`, `admission-bridge-resolution.test.ts`).
19021909
*/
19031910
private async assertReadAdmitted(
19041911
objects: Iterable<string>,
@@ -1919,7 +1926,8 @@ export class AnalyticsService implements IAnalyticsService {
19191926
* here too, `PERMISSION_DENIED` / 403, ahead of the field verdicts on its
19201927
* object ({@link namedQueryFields}).
19211928
*
1922-
* A no-op when no provider is wired (no security service) or when the query
1929+
* A no-op when no provider is wired (a host that constructs this service
1930+
* without `getReadableFields`; the plugin always wires one) or when the query
19231931
* names no field.
19241932
*/
19251933
/**
@@ -1932,7 +1940,7 @@ export class AnalyticsService implements IAnalyticsService {
19321940
* tier.
19331941
*
19341942
* It is the tier-independent complement of {@link assertFieldsReadable}: that
1935-
* gate is a no-op with no security service and stands down on an object its
1943+
* gate is a no-op with no field reader wired and stands down on an object its
19361944
* reader answers `undefined` for, which is exactly where caller-supplied text
19371945
* that is not a column reference reached `NativeSQLStrategy`'s statement as
19381946
* written. See `field-read-admission.ts`.
@@ -2058,9 +2066,9 @@ export class AnalyticsService implements IAnalyticsService {
20582066
* DECLARED, so a dimension/measure whose `field` is a raw expression resolves to
20592067
* a declared cube member whose `sql` is that expression — and #21156's
20602068
* {@link assertCallerMembersResolvable} leaves a DECLARED expression member to
2061-
* the field-level gate (#20965), which stands down with no security service and
2062-
* on an object its reader answers `undefined` for. In those tiers the
2063-
* expression reached `NativeSQLStrategy`'s statement as written.
2069+
* the field-level gate (#20965), which stands down with no field reader
2070+
* wired and on an object its reader answers `undefined` for. In those tiers
2071+
* the expression reached `NativeSQLStrategy`'s statement as written.
20642072
*
20652073
* ## Every branch that supplies the dataset
20662074
*

‎packages/services/service-analytics/src/field-read-admission.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,10 @@ export async function assertNamedFieldsReadable(
336336
* ## Why this is separate from {@link assertNamedFieldsReadable}
337337
*
338338
* That gate judges READABILITY, and it is a no-op in the two tiers where the
339-
* caller-supplied kind of {@link NamedExpression} is dangerous: a deployment
340-
* with no security service wires no reader, and an object the reader answers
339+
* caller-supplied kind of {@link NamedExpression} is dangerous: a host that
340+
* wires no reader (it constructs `AnalyticsService` without one — the plugin
341+
* always wires one, and on a deployment with no security service its bridge
342+
* throws, so the query is refused), and an object the reader answers
341343
* `undefined` for is skipped (`continue`). In both, a member whose text is not
342344
* a column reference reached the native statement as written — `NativeSQLStrategy`
343345
* emits an unrecognised `sql` verbatim, into the grouping and filter positions.

‎packages/services/service-analytics/src/read-admission.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,32 @@
4848
*
4949
* ## Fail direction
5050
*
51-
* The provider is access-NARROWING, so it fails CLOSED — but "closed" is a
52-
* claim about a WIRED provider, and it is worth saying which states are which:
51+
* The provider is access-NARROWING, so it fails CLOSED, and it is worth saying
52+
* which states are which:
5353
*
5454
* - a wired provider that THROWS, or answers `false`, denies (this module);
5555
* - a `security` service that is wired but cannot be used — resolving it
5656
* throws, or it exposes neither `canReadObject` nor `explain` — denies at
5757
* the bridge, because `/data`'s middleware does not fall open in those
5858
* states either (`plugin.ts`);
59-
* - an ABSENT provider is a different state altogether. No security service
60-
* answered at all, which is the same deployment in which `/data` has no
61-
* object-level gate either, so the two doors still AGREE — and agreement is
62-
* the property being defended, not refusal for its own sake. Such a
63-
* deployment keeps its pre-existing analytics behaviour, and
64-
* `AnalyticsServicePlugin` logs that state loudly at init, the same posture
65-
* it already takes for a missing `getReadScope`.
59+
* - [#22235] a deployment that registers NO security service denies at the
60+
* bridge too, by declaration: the in-repo kernels (`ObjectKernel`,
61+
* `LiteKernel`) throw on a `security` service nothing ever registered, so
62+
* the bridge takes its UNUSABLE branch and refuses with a located error.
63+
* `/data` carries no object-level gate on such a deployment, so there the
64+
* two doors do NOT agree: a loud deny is preferred over a silent admit. A
65+
* composition that wants analytics to answer registers a security service,
66+
* or its host supplies its own `admitObjectRead`;
67+
* - a context that answers the `security` lookup with NOTHING (ABSENT) is a
68+
* different state, reached only by such a context: the package's test
69+
* doubles do, and no in-repo kernel does. What the bridge does there is
70+
* documented at the bridge (`plugin.ts`); it is not a statement about
71+
* deployments;
72+
* - a host that constructs `AnalyticsService` itself with no provider at all
73+
* gets no object-level gate (`assertReadAdmitted` is a no-op). That is the
74+
* host's composition, not a deployment with no security service:
75+
* `AnalyticsServicePlugin` always wires a provider — the host's
76+
* `admitObjectRead`, or the bridge.
6677
*/
6778

6879
import type { ExecutionContext } from '@objectstack/spec/kernel';

‎packages/services/service-analytics/src/read-scope-refusal.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,13 @@ const READ_SCOPE_COMPILE_FAILED: RegisteredErrorCode = 'READ_SCOPE_COMPILE_FAILE
9292
* `READ_SCOPE_COMPILE_FAILED` / 500.
9393
*
9494
* Use it where a wired row-scope provider could not be asked or could not
95-
* answer, and the query is therefore refused fail-closed. ⛔ Not for a caller's
96-
* mistake (that is `dataset-refusal.ts`) and ⛔ not for an ABSENT provider,
97-
* which is a different state entirely: a deployment with no security service is
98-
* one where `/data` has no row-level policy either, it is reported loudly at
99-
* init, and it must keep running unscoped exactly as before.
95+
* answer, and the query is therefore refused fail-closed — which, on the
96+
* in-repo kernels, includes a deployment that registers no security service:
97+
* their lookup throws on the never-registered name, so the bridge is UNUSABLE
98+
* (`plugin.ts`). ⛔ Not for a caller's mistake (that is `dataset-refusal.ts`)
99+
* and ⛔ not for an ABSENT provider — no row-scope provider at all, or a
100+
* context that answers the `security` lookup with nothing — for which the
101+
* row-scope stage applies no scope, as it always has.
100102
*
101103
* The message stays whatever the refusing site says — it is for the operator's
102104
* log, which after this change is its only destination.

0 commit comments

Comments
 (0)