You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2e6d446
Browse filesBrowse the repository at this point in the historyBrowse 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>
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`.
0 commit comments