Repository navigation
fix(service-analytics)!: an analytics read gate that could not decide answers 503 SERVICE_UNAVAILABLE, not the denial's 403 - #25595
Conversation
…3 SERVICE_UNAVAILABLE, not the denial's 403 (WIP) Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
…ns (WIP) Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
…alytics service (WIP) Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
…ILABLE Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
…ts where the cause lands Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d6f479abddd9515db1df9e3cf4e3dc646e027124 && git checkout d6f479abddd9515db1df9e3cf4e3dc646e027124
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e073455929a60b67c48da3f91324261e57aaa26b 18b8b6fa5dd3d24e8879c3291e6c2bf4b95023ae && git checkout -B drift-repro e073455929a60b67c48da3f91324261e57aaa26b && git merge --no-ff 18b8b6fa5dd3d24e8879c3291e6c2bf4b95023ae
node scripts/docs-audit/affected-docs.mjs --json e073455929a60b67c48da3f91324261e57aaa26b
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
Generated by Claude Code |
Requested by Sales · project thread
Fixes #25561
Clause-②: no
Metadata-protocol: none
What was wrong
Three analytics door refusals are operator-side faults: a resolver threw or could not answer. All three answered with the same wire body as a real denial,
403 PERMISSION_DENIEDnaming the object. A caller could not tell "you may not read this" from "the server could not decide".read-admission.tsassertObjectsReadable, catch branchPERMISSION_DENIED(byte-identical to the!admittedbranch)SERVICE_UNAVAILABLEfield-read-admission.ts(wasfieldReadUnresolvedError)PERMISSION_DENIEDSERVICE_UNAVAILABLEapi-exposure-door.ts(wasexposureUnresolvedError)PERMISSION_DENIEDSERVICE_UNAVAILABLEWhat changed
readAdmissionUnresolvedError(inread-admission.ts, not exported from the package) throws{ code: 'SERVICE_UNAVAILABLE', status: 503, object }.SERVICE_UNAVAILABLEis an existing ADR-0112StandardErrorCode, and it is the spellingpackages/core/src/security/authz-store-unavailable.tsuses for the same class under the maintainer ruling quoted there. No code is added, and no envelope key is added. The two per-file fault constructors are removed and their call sites use the shared one.logger.errorwith the cause).!admittedbranch keeps403 PERMISSION_DENIED, and so do the field-level verdicts (fieldReadDeniedError,fieldReadUnjudgeableError). The exposure verdicts keep404 OBJECT_API_DISABLED/405 OBJECT_API_METHOD_NOT_ALLOWED.POST /api/v1/analytics/dataset/queryalready relays a declared 5xx through the shared declared-fault arm (declaredServerFaultAnswer). So the fault reaches the caller as503 {"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}, not500 ANALYTICS_QUERY_FAILED. The message is withheld, as for every declared server fault. The cube query door (dispatcher-pluginerrorResponseBase) relays the declared status and code the same way. Nopackages/restsource change.packages/metadata-protocol/src/build-probes.tskeyed its access fix onPERMISSION_DENIED. Without a change, a fault would have fallen to "Fix the dataset definition…", which is wrong for an outage. ASERVICE_UNAVAILABLEthrow now gets its own fix: the dataset is not the cause; the server could not decide read access to the named object (falling back to the dataset's object); check the server log for the resolver that failed, then republish once it is healthy. Thecode(widget_query_failed),severityandmessageare unchanged.Tests
Measured on head
18b8b6fa5d (service-analyticsandmetadata-protocolfiles are byte-identical to their state at each run below; later commits touched only the rest test and the changeset):@objectstack/service-analytics, whole package: Test Files 185 passed (185), Tests 4517 passed | 262 skipped. Pins that asserted the 403 on a fault leg moved with the fix:read-admission-gate.test.ts(now across all three doors, both strategies; it also asserts the message names the object and not the cause),field-read-admission-gate.test.ts(reader throws, both strategies, plus the plugin bridge with nogetReadableFields),field-query-admission-gate.test.ts(queryable reader throws, both strategies),api-exposure-door.test.ts(lookup throws; plugin with no data engine),unexposed-definition-registration.test.ts(query face after a throwing lookup). The real-denial twins in the same files stay403 PERMISSION_DENIED/404/405, untouched.@objectstack/rest, newanalytics-read-gate-fault-envelope.test.ts: a seam test through the real route with a realAnalyticsService. For each of the three gates, a throwing resolver gives503 SERVICE_UNAVAILABLE(notPERMISSION_DENIED, notANALYTICS_QUERY_FAILED), the prose is withheld, nothing executes, the cause lands in the service log and the fault lands in the route log. The verdict twin of each gate answers403 PERMISSION_DENIED,403 PERMISSION_DENIEDor404 OBJECT_API_DISABLED, and there is a positive control (200). The generic relay was already pinned for a hand-built declared 503; this file pins the producer and the route together. Test Files 1 passed, Tests 7 passed (re-run on 18b8b6f);src/analytics*in@objectstack/rest: Test Files 23 passed (23), Tests 298 passed | 22 skipped.@objectstack/metadata-protocol,build-probes-widget-refusal-fix.test.ts: two new cases (SERVICE_UNAVAILABLEwith an object; without one, which falls back to the dataset's object). Whole package: Test Files 263 passed | 3 skipped (266), Tests 21472 passed | 19 skipped; the widget-refusal file 8/8 re-run on 18b8b6f.@objectstack/runtimehttp-dispatcher.test.ts+packages-publish-drafts-probe-caller-context.test.ts: Test Files 2 passed, Tests 251 passed.service-analytics,metadata-protocolandrestall exit 0.Ablation (one site, predicted red). With the fix committed, I reverted the read-admission catch branch to the denial (
throw readAdmissionDeniedError(...), tagged with a marker) throughscripts/ablation-replace.mjs. Then I rebuiltservice-analytics, andablation-dist-preflightshowed the marker present indist/index.jsanddist/index.cjs. Result:read-admission-gate.test.tswent 6 failed / 19 passed (the fault case on all three doors × two strategies), and the rest seam went 1 failed / 6 passed (the object-level fault row; the twins stayed green). Restore: blob equal to HEAD andgit diff HEADempty. After the rebuild, preflight--absentreported the marker absent from all 6 built files, the tree clean, and the rest seam back at 7/7.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 72 commands. All 72 were run locally and reconciled with--ran: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero, all 72 recorded an exit code)". All exit 0 on 18b8b6f. On the first passcheck-rest-log-spy-declaredwas red on the new rest test (noOS_REST_LOGdeclared); that is fixed in this PR. Three gates were red only on tree state while a concurrent closure build was rewritingdist/(check:dts-closure,check:dual-build-cjs-loadsPREREQUISITE,check:type-check-debtexit 3); each was re-run on the settled tree and exits 0.pnpm lint(repo-wide eslint): lock VERDICT command-exit 0. CI families are read from this PR's check-runs.Dogfood battery
18b8b6f,
pnpm --filter @objectstack/dogfood testafter the@objectstack/dogfood^...closure build, under the verify lock: VERDICT command-exit 0. Test Files 291 passed | 3 skipped (294), Tests 2505 passed | 18 skipped (2523). No failures.Acceptance notes
service-analytics/src/plugin.ts, theadmitObjectReadauto-bridge) answersreturn false, not a throw, in its UNUSABLE branch: a wiredsecurityservice that cannot be resolved or exposes neithercanReadObjectnorexplain. On the in-repo kernels that branch also covers a deployment that registers no security service. That is the same class (the server could not decide), but it arrives atassertObjectsReadableas a verdict, so it still answers403 PERMISSION_DENIED. It is outside this card's file surface and is left untouched here; see the report's out-of-scope findings. The field-level and exposure bridges throw, so they already answer 503 with this change (pinned: the bridge with nogetReadableFields; the plugin with no data engine)."Internal server error"), as the declared-fault arm does for every declared 5xx. The fault is deliberately not markedrefusal: true: it is a fault, not a refusal whose prose is authored for the caller. That matches the core precedent.Generated by Claude Code