Skip to content

Commit 2e6d446

Browse files
fix(service-analytics)!: the analytics admission bridge answers 503 SERVICE_UNAVAILABLE, not a denial's 403, when the security service is missing or unusable (#25673)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuBKyGxNYJyVxQVGvyziBNBD)_ Refs #25561 Clause-②: no Metadata-protocol: none This is the ruling-A follow-up on the closed card: ruling record 6384696061 (letter **A**, maintainer 「25561 同意」). Claim 6384855135, premise amendment 6384944476. PR #25595 landed the card itself; this PR does not close it. ## What was wrong `AnalyticsServicePlugin` bridges the object-level read admission (`admitObjectRead`) to the `security` service. Its UNUSABLE branch covers three cases: resolving the service threw, the service exposes neither `canReadObject` nor `explain`, or (on `ObjectKernel` and `LiteKernel`, whose synchronous `getService` throws on a miss) no `security` service was ever registered. That branch answered `return false`. So the caller got `403 PERMISSION_DENIED`, the same bytes as a real denial, although no verdict was reached. After PR #25595 it was the last analytics read gate where a fault was answered as a denial. Its `error` log line also said "A security service is wired on this deployment". That sentence is false when no security service was ever registered. ## What changed - **`plugin.ts`, the object-level bridge.** The UNUSABLE branch now throws `readAdmissionUnresolvedError(object)`, the shared constructor PR #25595 added in `read-admission.ts`. `assertObjectsReadable` relays it as `SERVICE_UNAVAILABLE` / `503` with `object` naming the object. The message names only the object; the cause stays in the server log. Fail-closed is unchanged: the query is refused before any read. - **Real denials are byte-unchanged.** `canReadObject` or `explain` answering false still returns `false`, and the caller still gets `403 PERMISSION_DENIED`. The ABSENT branch (a context that answers the lookup with nothing admits and warns once) is untouched, as the ruling leaves it unruled. - **The log line.** The object-level bridge's `error` line now says the `security` service is missing or cannot be used, so no verdict was reached, and analytics refuses rather than fall open. That is true for both the broken and the never-registered case. The row-level read scope bridge in the same file printed the same false sentence for the same two cases. It now says that analytics does not serve rows with no row-level policy when the `security` service is missing or cannot be used. That bridge's answer (`READ_SCOPE_COMPILE_FAILED`) does not change. - **Doc comments** that called the never-registered answer a denial are respelled to `SERVICE_UNAVAILABLE` / 503: the bridge block and the [#22235] declaration in `plugin.ts`, the fail-direction list in `read-admission.ts`, `AnalyticsServiceConfig.admitObjectRead` and `assertReadAdmitted` in `analytics-service.ts`, and `scripts/analytics-reconcile/boot.ts`. No ADR text states the status, so no `docs/adr/**` edit is needed. - **Changeset:** `@objectstack/service-analytics` patch, with a BREAKING banner (a status on the wire moves), the migration, both declaration lines and the ADR-0087 disposition, in the same form as PR #25595. ## The pins that moved (403 to 503 `SERVICE_UNAVAILABLE` with `object`) | file | case | |---|---| | `admission-bridge-resolution.test.ts` | resolving `security` throws | | `admission-bridge-resolution.test.ts` | the service exposes neither `canReadObject` nor `explain` | | `admission-bridge-resolution.test.ts` | a real `ObjectKernel` / `LiteKernel` that never registers `security` (two runs) | | `admission-absence-report.test.ts` | a real `LiteKernel` with no security service | | `read-scope-bridge-resolution.test.ts` | a throwing resolver with both auto-bridges live. The premise read did not list this one; the whole-package run found it. | The real-verdict cases (`canReadObject` false, `explain` allowed false, a later-registered service answering false) stay at `403 PERMISSION_DENIED`. The log regexes still match the corrected text and were kept. `packages/rest`: no seam pin of this branch. `analytics-read-gate-fault-envelope.test.ts` injects its own `admitObjectRead` and never reaches the bridge. The rest door tests that boot the plugin either wire a security service or assert no status on this path. No `packages/rest` source change: the dataset door already relays a declared 5xx (PR #25595). ## Tests Final head `032ae18225`. It differs from `24895888` only by a merge of `origin/main` (#25658, which touches `.claude/skills/pm-dispatch/references/**` and `scripts/pm/check-half-states.mjs`). The `service-analytics` tree is byte-identical between the two. - `pnpm --filter @objectstack/service-analytics test` at `24895888`: VERDICT command-exit 0, Test Files 185 passed (185), Tests 4517 passed, 262 skipped (4779). The first run, before the row-scope pin moved, was red on exactly that pin: 1 failed, 184 passed. - `pnpm --filter @objectstack/service-analytics typecheck`: VERDICT command-exit 0. - `eslint --no-inline-config` on the 7 changed source files: exit 0. - Dogfood battery (owed by the `!` title) at `032ae18225`, closure built under the lock (command-exit 0): `pnpm --filter @objectstack/dogfood test`, VERDICT command-exit 0, Test Files 293 passed, 3 skipped (296), Tests 2509 passed, 18 skipped (2527). ## Ablation (one leg per changed negative pin) Done with `scripts/ablation-replace.mjs` on the committed fix. The anchor `throw readAdmissionUnresolvedError(object);` was replaced by `return false;`: anchor x1 to x0, blob `83715b24c049` to `0e7c50c8c5b1`. The tests import `../plugin.js` from source, so no dist was involved. The three files were then run: ``` × refuses a throwing resolver with BOTH auto-bridges live (no door falls open) × REFUSES 503 when resolving the "security" service THROWS × REFUSES 503 when the "security" service exposes neither canReadObject nor explain × REFUSES the dataset query 503, ... (ObjectKernel) × REFUSES the dataset query 503, ... (LiteKernel) × logs no init-time WARN, and absence stays loud at query time with the outcome unchanged - "code": "SERVICE_UNAVAILABLE", + "code": "PERMISSION_DENIED", - "status": 503, + "status": 403, Test Files 3 failed (3) · Tests 6 failed | 12 passed (18) ``` Restore: blob after restore `83715b24c049` == HEAD blob, `git diff HEAD` empty. The same three files then ran green: Test Files 3 passed (3), Tests 18 passed (18). The 12 that stayed green under the mutation include every real-denial 403 pin, so those pins did not move. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `032ae18225` derived 82 commands. All 82 were run; each record is `cmd :: exit N`, and every one exited 0. `--ran` verdict: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)". On the earlier run at `24895888`, `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no `dist/`) and `check-plugin-teardown-shape --self-test` could not read its pinned fixture on the shallow clone. Both exited 0 at the final head, after the dogfood closure build and a fetch of the fixture commit. CI runs the full farm. ## Acceptance notes - The object-level bridge now logs two `error` lines per refused query on this path: its own line, carrying the cause, and `assertObjectsReadable`'s catch line ("read-admission resolution failed …"). That second line is the same one every throwing provider already gets. Not changed here. - After landing, the seat posts the dated note on #22235 (ruling step 5). This PR does not post it. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JrXUZYyTV4i588KLZRxSvJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5eeb02c commit 2e6d446

8 files changed

Lines changed: 104 additions & 56 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics)!: the analytics admission bridge answers `503 SERVICE_UNAVAILABLE`, not a denial's `403 PERMISSION_DENIED`, when the `security` service is missing or cannot be used
6+
7+
`AnalyticsServicePlugin` bridges the object-level read admission to the `security` service. When that service cannot be used, the bridge refuses the query, fail-closed. That covers three cases: resolving the service throws, the service exposes neither `canReadObject` nor `explain`, or no `security` service was ever registered (`ObjectKernel` and `LiteKernel` throw on a lookup for a service nobody registered). Until now the refusal answered `403 PERMISSION_DENIED`, the same answer as a caller who may not read the object. The bridge reached no verdict, so the refusal now answers `503 SERVICE_UNAVAILABLE` naming the object, like the other analytics read gates that cannot decide. The cause stays in the server log. Fail-closed is unchanged: nothing is read, and nothing that was refused is now served. A real denial (`canReadObject` or `explain` answering false) still answers `403 PERMISSION_DENIED`, byte for byte. The bridge's `error` log line no longer says "A security service is wired on this deployment". That was untrue when no security service was ever registered. The line now says the `security` service is missing or cannot be used. The row-level read scope bridge's `error` line said the same untrue sentence and is corrected the same way; what that bridge answers does not change.
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored shape or exported declaration is added, removed, renamed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the code and status one analytics refusal answers when the `security` service is missing or cannot be used (403 PERMISSION_DENIED to 503 SERVICE_UNAVAILABLE); every request that was refused is still refused and every request that was served is still served. The remedy is on the consumer's side, reading the code, or on the deployment's side, registering a security service; never a rewrite of a package source or a stored row. 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 nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
13+
14+
**BREAKING** (the wire answer of one fault refusal changes; no request that was served is refused and none that was refused is served), shipped as `patch` because the metadata protocol does not move.
15+
16+
- **Before:** with no `admitObjectRead` supplied, an analytics query on a deployment whose `security` service is broken, or was never registered, was refused `403 PERMISSION_DENIED`. That was the same code and status as a caller who genuinely may not read the object.
17+
- **Now:** the same refusal answers `503 SERVICE_UNAVAILABLE` (`status: 503`, `object` naming the object). On `POST /api/v1/analytics/dataset/query` the body is `{"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}`. Real denials still answer `403 PERMISSION_DENIED`, unchanged.
18+
- **Fix:** a client that treated every analytics `403` as "no access" should treat `503 SERVICE_UNAVAILABLE` as a server-side fault and check the server log. A deployment that wants analytics to answer registers a `security` service (for example `plugin-security`), or its host supplies its own `admitObjectRead`.

‎packages/services/service-analytics/src/__tests__/admission-absence-report.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,20 @@ describe('admission absence report — a real kernel that never registers a secu
157157
it('logs no init-time WARN, and absence stays loud at query time with the outcome unchanged', async () => {
158158
// The in-repo kernels answer a missing service by THROWING
159159
// ("[Kernel] Service 'security' not found"), which this bridge classifies
160-
// as a resolution that threw: it denies, fail-closed, and says so at
160+
// as a resolution that threw: it refuses, fail-closed, and says so at
161161
// `error` on every query. That outcome is what this boot produced before
162162
// the init-time WARN moved, and it is not this file's to change; what is
163163
// pinned is that dropping the init-time WARN left this case loud.
164164
const { service, reads, lines } = await bootKernel(undefined);
165165

166166
expect(about(lines.warn)).toEqual([]);
167167

168+
// [#25561] No verdict was reached, so the refusal is the fault answer
169+
// (`SERVICE_UNAVAILABLE` / 503, naming the object), not a denial's 403.
168170
await expect(runProbe(service)).rejects.toMatchObject({
169-
code: 'PERMISSION_DENIED',
170-
status: 403,
171+
code: 'SERVICE_UNAVAILABLE',
172+
status: 503,
173+
object: 'employer_member',
171174
});
172175
expect(reads).toEqual([]);
173176
expect(about(lines.error).join('\n')).toMatch(

‎packages/services/service-analytics/src/__tests__/admission-bridge-resolution.test.ts‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,23 @@
2020
* - the service resolved but exposes NEITHER `canReadObject` NOR `explain`
2121
* (UNUSABLE) — it exists and cannot answer.
2222
*
23-
* Both UNUSABLE corners DENY, fail-closed, before anything is read, and both say
24-
* why at `error`. For a wired-but-broken provider, `/data`'s middleware does not
25-
* fall open either, so admitting here would reopen exactly the divergence
26-
* between the two doors that this gate closes — and would do it silently.
23+
* Both UNUSABLE corners REFUSE, fail-closed, before anything is read, and both
24+
* say why at `error`. They reached no verdict, so the refusal answers
25+
* `503 SERVICE_UNAVAILABLE`, not a denial's `403 PERMISSION_DENIED` (#25561).
26+
* For a wired-but-broken provider, `/data`'s middleware does not fall open
27+
* either, so admitting here would reopen exactly the divergence between the
28+
* two doors that this gate closes — and would do it silently.
2729
*
28-
* ## The declaration: no security service registered ⇒ analytics DENIES
30+
* ## The declaration: no security service registered ⇒ analytics REFUSES
2931
*
3032
* [#22235] A deployment that registers no security service gets its analytics
3133
* read queries REFUSED, fail-closed: the kernel's lookup throws, the bridge
32-
* takes UNUSABLE, the caller gets `PERMISSION_DENIED` / 403 naming the object,
33-
* and the operator gets an `error` line naming the object and the failed
34-
* lookup. That is the declared answer, not an accident of how the lookup fails,
35-
* and it holds even though `/data` carries no object-level gate on such a
36-
* deployment: a loud deny is preferred over a silent admit. A composition that
34+
* takes UNUSABLE, the caller gets `SERVICE_UNAVAILABLE` / 503 naming the object
35+
* (#25561: the code moved from 403, the refusal did not), and the operator
36+
* gets an `error` line naming the object and the failed lookup. That is the
37+
* declared answer, not an accident of how the lookup fails, and it holds even
38+
* though `/data` carries no object-level gate on such a deployment: a loud
39+
* refusal is preferred over a silent admit. A composition that
3740
* wants analytics to answer registers a security service, or its host supplies
3841
* its own `admitObjectRead`. The last block of this file pins it on both
3942
* in-repo kernels.
@@ -151,13 +154,15 @@ const runProbe = (service: AnalyticsService) =>
151154
describe('analytics admission bridge — resolving the "security" service', () => {
152155
// ── The two corners that used to admit silently ────────────────────────────
153156

154-
it('DENIES when resolving the "security" service THROWS', async () => {
157+
it('REFUSES 503 when resolving the "security" service THROWS', async () => {
155158
const boom = () => { throw new Error('security service is initialising'); };
156159
const { service, reads, error } = await bootAnalytics(boom);
157160

161+
// [#25561] No verdict was reached, so the refusal is a fault, not a denial.
158162
await expect(runProbe(service)).rejects.toMatchObject({
159-
code: 'PERMISSION_DENIED',
160-
status: 403,
163+
code: 'SERVICE_UNAVAILABLE',
164+
status: 503,
165+
object: 'employer_member',
161166
});
162167
// The refusal has to happen BEFORE the statement runs, or it is not a gate.
163168
expect(reads).toEqual([]);
@@ -168,15 +173,16 @@ describe('analytics admission bridge — resolving the "security" service', () =
168173
);
169174
});
170175

171-
it('DENIES when the "security" service exposes neither canReadObject nor explain', async () => {
176+
it('REFUSES 503 when the "security" service exposes neither canReadObject nor explain', async () => {
172177
// A registered object that is not the contract it claims to be —
173178
// `explain` is NON-optional on `ISecurityService`, so a conforming
174179
// provider never lands here.
175180
const { service, reads, error } = await bootAnalytics(() => ({ getReadFilter: () => undefined }));
176181

177182
await expect(runProbe(service)).rejects.toMatchObject({
178-
code: 'PERMISSION_DENIED',
179-
status: 403,
183+
code: 'SERVICE_UNAVAILABLE',
184+
status: 503,
185+
object: 'employer_member',
180186
});
181187
expect(reads).toEqual([]);
182188
expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(
@@ -189,16 +195,16 @@ describe('analytics admission bridge — resolving the "security" service', () =
189195
it('ADMITS when the context answers the "security" lookup with nothing (ABSENT)', async () => {
190196
// The ABSENT branch: this double answers the lookup with nothing, which no
191197
// in-repo kernel does — they throw on a miss (the last block of this file).
192-
// Kept as the negative control for the two deny cases above; whether this
193-
// branch should deny too is a separate question, not settled here.
198+
// Kept as the negative control for the two refusal cases above; whether
199+
// this branch should refuse too is a separate question, not settled here.
194200
const { service, reads } = await bootAnalytics(undefined);
195201

196202
const result = await runProbe(service);
197203
expect(result.rows).toEqual([{ cnt: 24 }]);
198204
expect(reads).toHaveLength(1);
199205
});
200206

201-
// ── The two working spellings, so the deny cases cannot pass by refusing all ─
207+
// ── The two working spellings, so the refusals cannot pass by refusing all ──
202208

203209
it('asks canReadObject when the service has it, and serves an ADMITTED caller', async () => {
204210
const canReadObject = vi.fn(() => true);
@@ -238,7 +244,7 @@ describe('analytics admission bridge — resolving the "security" service', () =
238244
});
239245
});
240246

241-
// ── The declaration, on the in-repo kernels: no security service ⇒ DENY ──────
247+
// ── The declaration, on the in-repo kernels: no security service ⇒ REFUSE ────
242248

243249
/**
244250
* Boots a REAL kernel with a `data` provider and this plugin, and NO security
@@ -271,17 +277,19 @@ describe.each([
271277
['ObjectKernel', () => new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false })],
272278
['LiteKernel', () => new LiteKernel({ logger: { level: 'silent' } })],
273279
] as const)('analytics admission bridge — a real %s that never registers a "security" service', (_name, makeKernel) => {
274-
it('DENIES the dataset query fail-closed, before any read, with an error located on the object and the lookup', async () => {
280+
it('REFUSES the dataset query 503, fail-closed, before any read, with an error located on the object and the lookup', async () => {
275281
const { kernel, service, reads, errors, warns } = await bootKernelWithoutSecurity(makeKernel());
276282

277283
// The mechanism the declaration rests on: this kernel answers a lookup for
278284
// a never-registered name by THROWING, never with nothing.
279285
expect(() => kernel.getService('security')).toThrow(/'security'/);
280286

281-
// The caller's refusal: the declared envelope, naming the object.
287+
// The caller's refusal: the declared envelope, naming the object. [#25561]
288+
// A service that is "not loaded" is the spec's SERVICE_UNAVAILABLE, the
289+
// same answer a registered-but-broken one gets.
282290
await expect(runProbe(service)).rejects.toMatchObject({
283-
code: 'PERMISSION_DENIED',
284-
status: 403,
291+
code: 'SERVICE_UNAVAILABLE',
292+
status: 503,
285293
object: 'employer_member',
286294
});
287295
// Refused BEFORE the statement ran.

‎packages/services/service-analytics/src/__tests__/read-scope-bridge-resolution.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,18 @@ describe('analytics row-scope bridge — resolving the "security" service', () =
217217

218218
it('refuses a throwing resolver with BOTH auto-bridges live (no door falls open)', async () => {
219219
// With no `admitObjectRead` override the object-level bridge (commit 041d9fdc6)
220-
// answers first, with `PERMISSION_DENIED`. Pinned so the file-level
221-
// property — a broken security service serves no analytics rows through
222-
// EITHER door — cannot regress from the other side.
220+
// answers first. It reached no verdict, so it answers the fault refusal,
221+
// `SERVICE_UNAVAILABLE` / 503 naming the object, not a denial's 403
222+
// (#25561). Pinned so the file-level property — a broken security service
223+
// serves no analytics rows through EITHER door — cannot regress from the
224+
// other side.
223225
const boom = () => { throw new Error('security service is initialising'); };
224226
const { service, reads } = await bootAnalytics(boom);
225227

226228
await expect(runProbe(service)).rejects.toMatchObject({
227-
code: 'PERMISSION_DENIED',
228-
status: 403,
229+
code: 'SERVICE_UNAVAILABLE',
230+
status: 503,
231+
object: 'employer_member',
229232
});
230233
expect(reads).toEqual([]);
231234
});

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -851,12 +851,13 @@ export interface AnalyticsServiceConfig {
851851
* construction rather than by maintenance.
852852
*
853853
* MAY be async. `false` refuses the query with `PERMISSION_DENIED` / 403; a
854-
* THROW also refuses (fail-closed). When the hook is absent entirely no
854+
* THROW also refuses (fail-closed), answered `SERVICE_UNAVAILABLE` / 503
855+
* because no verdict was reached. When the hook is absent entirely no
855856
* object-level gate applies — a host that constructs the service without
856857
* one. That is not a deployment with no security service: the plugin always
857-
* wires this hook, and on the in-repo kernels its bridge DENIES when no
858-
* `security` service was ever registered, because the lookup throws
859-
* (`plugin.ts`).
858+
* wires this hook, and on the in-repo kernels its bridge REFUSES
859+
* (`SERVICE_UNAVAILABLE` / 503) when no `security` service was ever
860+
* registered, because the lookup throws (`plugin.ts`).
860861
*/
861862
admitObjectRead?: ObjectReadAdmissionProvider;
862863
/**
@@ -1978,8 +1979,9 @@ export class AnalyticsService implements IAnalyticsService {
19781979
* A no-op when no provider is wired — a host that constructs this service
19791980
* without `admitObjectRead`. `AnalyticsServicePlugin` always wires one, so a
19801981
* deployment with no security service is NOT this case: on the in-repo
1981-
* kernels the bridge's lookup throws and the provider denies, fail-closed
1982-
* (`plugin.ts`, `admission-bridge-resolution.test.ts`).
1982+
* kernels the bridge's lookup throws and the provider refuses, fail-closed,
1983+
* `SERVICE_UNAVAILABLE` / 503 (`plugin.ts`,
1984+
* `admission-bridge-resolution.test.ts`).
19831985
*/
19841986
private async assertReadAdmitted(
19851987
objects: Iterable<string>,

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
assertReadScopePlaceholdersResolvable,
1818
} from './read-scope-sql.js';
1919
import { readScopeUnresolvedError } from './read-scope-refusal.js';
20+
import { readAdmissionUnresolvedError } from './read-admission.js';
2021
// [#16206] The narrowing from a driver's FOUR-name `dialectName` to the THREE
2122
// this package's config hook declares — see the bridge below.
2223
import { asAcceptedSqlDialect, type AcceptedSqlDialect } from './text-match-sql.js';
@@ -611,7 +612,7 @@ export class AnalyticsServicePlugin implements Plugin {
611612
// `getService` answers by throwing. A failed security lookup is a
612613
// refusal everywhere else in this stack, including the object-level
613614
// bridge below, which used to be spelled exactly like this one and
614-
// now denies.
615+
// now refuses (`503 SERVICE_UNAVAILABLE`).
615616
return {
616617
kind: 'unusable',
617618
why:
@@ -653,8 +654,8 @@ export class AnalyticsServicePlugin implements Plugin {
653654
ctx.logger.error(
654655
`[Analytics] row-level read scope could not be resolved for "${object}" — ` +
655656
`refusing the query (fail-closed): ${resolved.why}. ` +
656-
'A security service is wired on this deployment, so analytics must not fall ' +
657-
'open and serve rows with no row-level policy applied.',
657+
'Analytics does not fall open and serve rows with no row-level policy applied ' +
658+
'when the "security" service is missing or cannot be used.',
658659
);
659660
// [commit 54b3d1d4a] Declared, not bare. `resolveReadScopes` replaces this
660661
// error with its own on the dataset path, but this provider is read
@@ -712,15 +713,20 @@ export class AnalyticsServicePlugin implements Plugin {
712713
// wired-but-broken provider `/data`'s middleware does NOT fall
713714
// open either, so admitting here would reopen the exact
714715
// divergence between the two doors that this gate closes, and
715-
// silently. It DENIES, fail-closed, and says why at `error`,
716-
// naming the object.
716+
// silently. It REFUSES, fail-closed, and says why at `error`,
717+
// naming the object. It reached no verdict, so it THROWS and
718+
// the gate answers `503 SERVICE_UNAVAILABLE`, not the 403 of
719+
// a denial (#25561).
717720
// USABLE — ask it (below).
718721
//
719722
// [#22235] The declaration (the maintainer's ruling): a deployment that
720723
// registers no security service is REFUSED at this door, fail-closed — the
721-
// in-repo kernels throw on the miss, so it lands in UNUSABLE. `/data`
722-
// carries no object-level gate on such a deployment, so the two doors do
723-
// NOT agree there: a loud deny is preferred over a silent admit. A
724+
// in-repo kernels throw on the miss, so it lands in UNUSABLE. [#25561] The
725+
// refusal answers `503 SERVICE_UNAVAILABLE` (the service is "not loaded"),
726+
// the same as a registered service that is broken: the code moved from
727+
// 403, the fail-closed refusal did not. `/data` carries no object-level
728+
// gate on such a deployment, so the two doors do NOT agree there: a loud
729+
// refusal is preferred over a silent admit. A
724730
// composition that wants analytics to answer registers a security service,
725731
// or its host supplies its own `admitObjectRead`.
726732
// `admission-bridge-resolution.test.ts` pins it on both in-repo kernels.
@@ -818,10 +824,14 @@ export class AnalyticsServicePlugin implements Plugin {
818824
ctx.logger.error(
819825
`[Analytics] object-level read admission could not be resolved for "${object}" — ` +
820826
`denying the query (fail-closed): ${resolved.why}. ` +
821-
'A security service is wired on this deployment, so analytics must not fall open: ' +
822-
'GET /data/' + object + ' does not.',
827+
'The "security" service is missing or cannot be used, so no verdict was reached; ' +
828+
'analytics refuses rather than fall open.',
823829
);
824-
return false;
830+
// [#25561] A refusal that reached no verdict does not answer as
831+
// one: THROW, so the gate answers `503 SERVICE_UNAVAILABLE`, never
832+
// the `403 PERMISSION_DENIED` a real denial gets. Declared, not bare,
833+
// for the reason the row-scope bridge above gives.
834+
throw readAdmissionUnresolvedError(object);
825835
}
826836
const svc = resolved.svc;
827837
if (typeof svc.canReadObject === 'function') {

0 commit comments

Comments
 (0)