Skip to content

fix(service-analytics)!: an analytics read gate that could not decide answers 503 SERVICE_UNAVAILABLE, not the denial's 403 - #25595

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25561-analytics-fault-503
Oct 24, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25561-analytics-fault-503

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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_DENIED naming the object. A caller could not tell "you may not read this" from "the server could not decide".

site what happened before now
read-admission.ts assertObjectsReadable, catch branch the object-level admission provider threw 403 PERMISSION_DENIED (byte-identical to the !admitted branch) 503 SERVICE_UNAVAILABLE
field-read-admission.ts (was fieldReadUnresolvedError) the readable or queryable field reader threw 403 PERMISSION_DENIED 503 SERVICE_UNAVAILABLE
api-exposure-door.ts (was exposureUnresolvedError) the object declaration lookup threw 403 PERMISSION_DENIED 503 SERVICE_UNAVAILABLE

What changed

  • One fault answer for the three gates. readAdmissionUnresolvedError (in read-admission.ts, not exported from the package) throws { code: 'SERVICE_UNAVAILABLE', status: 503, object }. SERVICE_UNAVAILABLE is an existing ADR-0112 StandardErrorCode, and it is the spelling packages/core/src/security/authz-store-unavailable.ts uses 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.
  • No oracle. The message names the object and nothing else: not which gate could not answer, not why, not whether the object exists. It says only that the server could not decide. The cause stays in the server log at each site, as before (logger.error with the cause).
  • Still fail-closed. Every path that refused still refuses: the three catch branches still throw. Only the code and the status move.
  • Real denials are byte-unchanged. The !admitted branch keeps 403 PERMISSION_DENIED, and so do the field-level verdicts (fieldReadDeniedError, fieldReadUnjudgeableError). The exposure verdicts keep 404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED.
  • The door. POST /api/v1/analytics/dataset/query already relays a declared 5xx through the shared declared-fault arm (declaredServerFaultAnswer). So the fault reaches the caller as 503 {"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}, not 500 ANALYTICS_QUERY_FAILED. The message is withheld, as for every declared server fault. The cube query door (dispatcher-plugin errorResponseBase) relays the declared status and code the same way. No packages/rest source change.
  • The consumer. packages/metadata-protocol/src/build-probes.ts keyed its access fix on PERMISSION_DENIED. Without a change, a fault would have fallen to "Fix the dataset definition…", which is wrong for an outage. A SERVICE_UNAVAILABLE throw 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. The code (widget_query_failed), severity and message are unchanged.

Tests

Measured on head 18b8b6fa5d (service-analyticsandmetadata-protocol files 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 no getReadableFields), 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 stay 403 PERMISSION_DENIED / 404 / 405, untouched.
  • @objectstack/rest, new analytics-read-gate-fault-envelope.test.ts: a seam test through the real route with a real AnalyticsService. For each of the three gates, a throwing resolver gives 503 SERVICE_UNAVAILABLE (not PERMISSION_DENIED, not ANALYTICS_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 answers 403 PERMISSION_DENIED, 403 PERMISSION_DENIED or 404 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_UNAVAILABLE with 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/runtime http-dispatcher.test.ts + packages-publish-drafts-probe-caller-context.test.ts: Test Files 2 passed, Tests 251 passed.
  • Typecheck: service-analytics, metadata-protocol and rest all 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) through scripts/ablation-replace.mjs. Then I rebuilt service-analytics, and ablation-dist-preflight showed the marker present in dist/index.js and dist/index.cjs. Result: read-admission-gate.test.ts went 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 and git diff HEAD empty. After the rebuild, preflight --absent reported 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/objectstack derived 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 pass check-rest-log-spy-declared was red on the new rest test (no OS_REST_LOG declared); that is fixed in this PR. Three gates were red only on tree state while a concurrent closure build was rewriting dist/ (check:dts-closure, check:dual-build-cjs-loads PREREQUISITE, check:type-check-debt exit 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 test after 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

  • The object-level plugin bridge (service-analytics/src/plugin.ts, the admitObjectRead auto-bridge) answers return false, not a throw, in its UNUSABLE branch: a wired security service that cannot be resolved or exposes neither canReadObject nor explain. 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 at assertObjectsReadable as a verdict, so it still answers 403 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 no getReadableFields; the plugin with no data engine).
  • On the wire the 503's message is withheld ("Internal server error"), as the declared-fault arm does for every declared 5xx. The fault is deliberately not marked refusal: true: it is a fault, not a refusal whose prose is authored for the caller. That matches the core precedent.

Generated by Claude Code

…3 SERVICE_UNAVAILABLE, not the denial's 403 (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>
…ts where the cause lands

Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/service-analytics, touching 10 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/api/error-catalog.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/api/index.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/permissions/authorization.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/permissions/sharing-rules.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/protocol/kernel/error-handling.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/ui/print-pages.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/releases/v17/17-3.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/releases/v17/17-4.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))
  • content/docs/releases/v17/17-7.mdx (via SERVICE_UNAVAILABLE (symbol, a top-level const object), SERVICE_UNAVAILABLE (literal, a string literal in SERVICE_UNAVAILABLE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (symbol, 43 pages), PERMISSION_DENIED (literal, 43 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e073455929a60b67c48da3f91324261e57aaa26b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d6f479abddd9515db1df9e3cf4e3dc646e027124 — the merge of head 18b8b6fa5dd3d24e8879c3291e6c2bf4b95023ae into base e073455929a60b67c48da3f91324261e57aaa26b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e073455929a60b67c48da3f91324261e57aaa26b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 18b8b6fa5dd3d24e8879c3291e6c2bf4b95023ae
Local-runs: none
Scope: every path of the diff outside the prose face (.changeset/25561-analytics-read-gate-fault-503.md): packages/services/service-analytics/src/read-admission.ts, field-read-admission.ts, api-exposure-door.ts, the five service-analytics/src/__tests__/ pins (read-admission-gate, field-read-admission-gate, field-query-admission-gate, api-exposure-door, unexposed-definition-registration), packages/metadata-protocol/src/build-probes.ts + build-probes-widget-refusal-fix.test.ts, packages/rest/src/analytics-read-gate-fault-envelope.test.ts + the files those hunks make a claim about, read at the head: packages/core/src/security/authz-store-unavailable.ts (the precedent), packages/rest/src/analytics-routes.ts (the dataset route's catch, arm ③a) and packages/types/src/data-error-classification.ts (declaredServerFaultAnswer, thrownCodeFields), packages/runtime/src/dispatcher-plugin.ts (errorResponseBase and the /analytics/query route), packages/spec/src/api/errors.zod.ts + error-code-ledger.zod.ts (StandardErrorCode, HttpStatusErrorCodeMap SERVICE_UNAVAILABLE: 503), packages/services/service-analytics/src/plugin.ts (the admitObjectRead, getReadableFields and getObjectDeclaration bridges), analytics-service.ts (gate order, hasDeclaredErrorEnvelope, the hook docblocks), service-analytics/src/index.ts + package.json exports, packages/client/src/index.ts, and the content/docs/** pages that mention analytics refusals.

① Derived judgments

  • Fail-closed preserved at all three sites: right. assertObjectsReadable (read-admission.ts), assertNamedFieldsReadable (field-read-admission.ts) and declarationOf (api-exposure-door.ts) each keep the try scope they had on main. The try wraps only the provider call(s). Each catch still logs the cause at error (or warn) and still throws; only the thrown constructor changes. The verdict throws (readAdmissionDeniedError, fieldReadDeniedError, fieldReadUnjudgeableError, internalFieldError, unexposedDefinitionError) sit outside those try blocks, so a real denial cannot be caught and re-spelled as a fault. Nothing that was refused is admitted. declarationOf also feeds the internal-field gate (assertNoInternalFieldsQueried), so that read now also answers 503 on a throw. That is consistent with the card, and the exposure gate runs ahead of it on the same objects. The registration face (assertDefinitionExposed, continue on a throw) and the label face (servesLabelTarget, return false) have their own catches and are untouched.
  • Real denials byte-unchanged: right. The diff touches only the docblocks of readAdmissionDeniedError, fieldReadDeniedError and fieldReadUnjudgeableError. Their bodies, messages, code, status and object/fields/member keys are not in a hunk. The exposure 404/405 constructors are not in a hunk. The verdict twins are pinned: the !admitted and hidden-field 403 pins stay unedited in the analytics suites, and the new rest seam asserts 403/403/404 beside each fault row through the real route.
  • The 503 envelope names only the object: right. readAdmissionUnresolvedError throws { code: 'SERVICE_UNAVAILABLE', status: 503, object }. Its message is the object name plus a fixed sentence: no gate, layer, grant, cause or existence fact. One constructor serves all three sites, so the message does not reveal which gate failed. The pins assert the message names the object and does not contain the cause (exploded, reader unavailable). This follows the shape of authz-store-unavailable.ts: the existing ADR-0112 SERVICE_UNAVAILABLE code, which HttpStatusErrorCodeMap maps to 503. No code and no envelope key is added. The error is deliberately not marked refusal: true, so the prose stays the operator's.
  • Wire answer, dataset door (POST /api/v1/analytics/dataset/query): right, pinned. A 503 skips the route's 4xx classification arm and reaches ③a declaredServerFaultAnswer. That arm relays declaredHttpStatus (503), gives error: INTERNAL_ERROR_MESSAGE (no refusal flag), and adds code through thrownCodeFields. SERVICE_UNAVAILABLE at 503 is consistent, so it is not demoted. The answer is 503 {"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}, not 500 ANALYTICS_QUERY_FAILED. logError runs before the relay, so the operator line is kept. hasDeclaredErrorEnvelope in analytics-service.ts keeps the missing-source heuristics away from it. analytics-read-gate-fault-envelope.test.ts pins producer plus route for all three gates with a real AnalyticsService (Test Core green on this head). No packages/rest source change was needed, and none was made.
  • Wire answer, cube door (POST /api/v1/analytics/query): right, read, not pinned by this PR. The route catches into errorResponse, and from there errorResponseBase, which takes httpStatus from err.status (503) and the code from resolveThrownHttpError. Because serverFaultProvenance is 'declared', the message is withheld. This is the same generic relay the existing analytics-query-read-scope-withhold.test.ts pins for a declared 500. The dev names the missing runtime pin as a deviation (outside the file surface). Not blocking.
  • Removed per-file constructors were not exported: right. On main, fieldReadUnresolvedError and exposureUnresolvedError were bare function declarations (the - lines carry no export). git grep on main finds no reference outside their own files, tests included. The new readAdmissionUnresolvedError is a module export that only the two sibling modules import. src/index.ts does not re-export it, and the package's exports map has only ., so the published surface does not grow. No import cycle is added: read-admission.ts imports only types from @objectstack/spec.
  • Consumer build-probes.ts: right. readAdmissionRefusal still keys only on PERMISSION_DENIED. The new serviceUnavailableRefusal is reached only when the error is not that refusal, and it keys on code (never the message). It changes only the fix text. The finding's code, severity and message are unchanged, and the function is not exported. Non-blocking observation: it matches any SERVICE_UNAVAILABLE from queryDataset, not only the analytics gates. The case that does reach it, @objectstack/core's authz-store outage, fits the text, and any other 503 still gets a server-side remedy, which is better than "Fix the dataset definition…".
  • Other consumers of these refusals: no stale branch. packages/client/src/index.ts does not branch on PERMISSION_DENIED or 403 for analytics. The content/docs/** analytics mentions make no fault-is-403 claim. The 403 sentences in service-analytics/CHANGELOG.md are release-owned history, which the changeset supersedes.
  • Side effect, named and right: before this change, a core AuthzStoreUnavailable (503) thrown from inside the object-level provider was caught by assertObjectsReadable and re-spelled as 403. It now surfaces as 503, which agrees with the core ruling instead of masking it.
  • Gates: 41 check-runs on the head. Every conclusion is success or skipped; the skipped runs are opt-in or matrix-placeholder jobs. Test Core 1–6, all Type Check and Lint families, Check Changeset, Governed Surface Queue Guard and Dogfood Regression Gate are all success. None failed or is pending.

② Semver level

  • patch on @objectstack/service-analytics and @objectstack/metadata-protocol is right. Both publish changed runtime behavior. @objectstack/rest gains only a test, so it publishes nothing and needs no entry.
  • Clause-②: no is right. The test is whether the card widens the accept set or grows the public surface. It does neither: every request that was refused is still refused, every request that was served is still served, and no export is added. It is not (narrowing) either, because nothing that was served is now refused.
  • Metadata-protocol: none is right: no authorable key, value, type or default moves. Under AGENTS.md's Post-Task Checklist step 3, a non-protocol change ships patch and keeps its BREAKING banner and ADR-0087 disposition. The changeset does that: a BREAKING banner with Before / Now / Fix (the consumer-side migration: treat 503 SERVICE_UNAVAILABLE as a server fault and retry), and exactly one adr-0087: not-required (no-migration-prescription) marker in the changeset body, beside both declaration lines. The PR body carries both lines at line start.
  • The ! in the PR title (fix(service-analytics)!:) matches a wire change on a fault path. The changeset's sentences match the diff, including "the cube query door relays the status the same way", which the read of errorResponseBase confirms.

③ Boundary flags

  • open_questions[0] (the plugin bridge's UNUSABLE branch still answers 403): leaving it out of THIS PR is right. The card names three sites, and plugin.ts is outside the claimed file surface (the diff does not touch it). That branch is a return false verdict pinned at 403 by admission-bridge-resolution.test.ts (also untouched). On the in-repo kernels it is also the finding(service-analytics, core): with no security service ever registered, every analytics query is refused 403 on the in-repo kernels, while the declared contract and the released text say an absent security service admits #22235 maintainer ruling's declared refusal for a deployment with no security service. Moving it would edit a ruled answer, so it needs the maintainer, and the seat has escalated it. This PR does not disturb that ruling. callCtx runs the object-level gate before the field-level gate, so a deployment with no security service is still refused by the object bridge's 403 before the field bridge, which now throws to 503, is reached. The UNUSABLE answer is byte-unchanged here. The PR body's acceptance note states that leftover asymmetry plainly.
  • Deviation: one packages/rest test, no rest source: right. It pins the seam the card asks for, and it declares OS_REST_LOG, so check-rest-log-spy-declared is green.
  • Deviation: the two per-file constructors were folded into one shared one: right. They were not exported (①), and one constructor is what keeps the no-oracle rule.
  • Deviation: cube door read but not pinned: accepted (①). A runtime pin of the 503 through /analytics/query is a reasonable follow-up, not a blocker.
  • Deviations: PR-body footer and line order: prose-face concerns for the lane seat, not a contract question here.
  • out_of_scope_findings[0]: the same bridge as the open question, so it is answered by the escalation above.
  • Author: the PR's user.login is objectstack-fleet[bot] (relay write), so no extra author note applies.

Implemented-by: claude/issue-25561-analytics-fault-503
Reviewed-by: session_01CxBKycaiUpA1yGx1XyoGqX

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:cli seat 1 (seat post #6024; lane read as domain:server) · os-sales · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-24T09:21Z · head 18b8b6fa5d

item reading
Shape 12 files, +404 / −68 (diff read by the seat). One shared readAdmissionUnresolvedError(object) in read-admission.ts throws { code: 'SERVICE_UNAVAILABLE', status: 503, object }. It replaces the fault throws at the three gates the card names: the assertObjectsReadable catch, field-read-admission.ts (fieldReadUnresolvedError removed) and api-exposure-door.ts (exposureUnresolvedError removed). Each try still wraps only the provider call, so a real denial is never re-spelled as a fault. build-probes.ts gains a SERVICE_UNAVAILABLE arm that changes only the publish finding's fix text.
Read against the code Denials are byte-unchanged (403 / 404 / 405 constructor bodies are outside every hunk). The 503 message names only the object, the same for all three gates, so it does not reveal which gate failed. That follows the ADR-0112 authz-store-unavailable.ts precedent; no code or envelope key is added. The removed constructors were not exported, and the new one is not re-exported from src/index.ts. Side effect, named and right: a core AuthzStoreUnavailable thrown inside the object provider now surfaces as 503 instead of being masked as 403.
Tests Analytics suites pin the 503 at each gate beside the unedited 403 pins. build-probes-widget-refusal-fix.test.ts pins the fix arm. The new packages/rest seam analytics-read-gate-fault-envelope.test.ts drives a real AnalyticsService through POST /api/v1/analytics/dataset/query: 503 {"code":"SERVICE_UNAVAILABLE"} per gate, beside 403 / 403 / 404 verdict twins.
Contract review 6379597099, this head, PASS (declaration face, default tier). fix(service-analytics)!:, @objectstack/service-analytics and @objectstack/metadata-protocol patch with the BREAKING banner (Before / Now / Fix), one adr-0087: not-required (no-migration-prescription) marker, Clause-②: no, Metadata-protocol: none: level, arm and kind right.
Deviations and open question (dev report 6379262084) The cube door (POST /api/v1/analytics/query) was read and not pinned; it relays the status through errorResponseBase the same way. A runtime pin is a reasonable follow-up, not a blocker. The two per-file constructors were folded into one, which is what keeps the 503 from telling the gates apart. Open question: the plugin bridge's UNUSABLE branch still answers 403. It is outside this card's three sites and is the #22235 ruled answer, so it is left for a maintainer ruling and is not moved here; the PR body's acceptance note states the asymmetry.
Gates (check-runs on this head) 41 runs: 34 success, 7 skipped, 0 failed; mergeable_state: clean. Writes all through the relay.
Landing Not governed (check-governed-merges.mjs --pr 25595, +404 / −68). The release freeze was lifted after 18.1 shipped. Ready + auto-merge through the relay in this act.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants