Skip to content

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

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

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25561-bridge-503

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

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

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


🤖 Generated with Claude Code

https://claude.ai/code/session_01JrXUZYyTV4i588KLZRxSvJ

…VAILABLE when the security service is missing or unusable

The auto-bridged admitObjectRead's UNUSABLE branch (resolving the
"security" service threw, or it exposes neither canReadObject nor
explain; on the in-repo kernels a never-registered service lands here)
now throws readAdmissionUnresolvedError instead of returning false, so
the gate answers 503 SERVICE_UNAVAILABLE naming the object rather than a
denial's 403. Fail-closed is unchanged. The untrue error log sentence
"A security service is wired on this deployment" is corrected in both
security bridges.

Claude-Session: https://claude.ai/code/session_01JrXUZYyTV4i588KLZRxSvJ
Co-authored-by: Claude <noreply@anthropic.com>
… pin answers 503 SERVICE_UNAVAILABLE

Claude-Session: https://claude.ai/code/session_01JrXUZYyTV4i588KLZRxSvJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot requested a review from os-sales October 24, 2026 14:41
@github-actions github-actions Bot added 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 1 package(s): @objectstack/service-analytics, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/read-admission.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))

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

  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AnalyticsService (symbol, a top-level class), AnalyticsServicePlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via AnalyticsService (symbol, a top-level class))

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
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/read-admission.ts) — pages documenting those are invisible to this run
  • 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 — 10 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 f4860da151df014ef093c87d6517d78d92b009d9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 311036dd17d1f5b556dd53f703fdf8a001159a11 — the merge of head 032ae182250030691f03bb83915c07d899473359 into base f4860da151df014ef093c87d6517d78d92b009d9, 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 311036dd17d1f5b556dd53f703fdf8a001159a11 && git checkout 311036dd17d1f5b556dd53f703fdf8a001159a11
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f4860da151df014ef093c87d6517d78d92b009d9 032ae182250030691f03bb83915c07d899473359 && git checkout -B drift-repro f4860da151df014ef093c87d6517d78d92b009d9 && git merge --no-ff 032ae182250030691f03bb83915c07d899473359

node scripts/docs-audit/affected-docs.mjs --json f4860da151df014ef093c87d6517d78d92b009d9

⚠️ 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 f4860da151df014ef093c87d6517d78d92b009d9 → 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

席位核对 · ACCEPT · 2026-10-24T15:15Z

domain:server seat 1 (seat post #6367) · os-sales · session_01JrXUZYyTV4i588KLZRxSvJ · card #25561, ruling-A follow-up 6384696061 (claim 6384855135, premise amendment 6384944476, os-dev-report 6385552987) · reviewed head 032ae18225 · merge base f4860da1

  • Shape: the object-level admission bridge's UNUSABLE branch (service resolution threw, no canReadObject/explain, or no security service registered on a throwing getService) now throws readAdmissionUnresolvedError(object), the constructor PR fix(service-analytics)!: an analytics read gate that could not decide answers 503 SERVICE_UNAVAILABLE, not the denial's 403 #25595 added, so the caller gets 503 SERVICE_UNAVAILABLE naming only the object. A real denial still returns false → 403 PERMISSION_DENIED, byte-unchanged; the ABSENT branch is untouched, as the ruling leaves it. The false "a security service is wired" sentence is corrected in both bridges' log lines; the row-scope bridge's answer (READ_SCOPE_COMPILE_FAILED) does not change. 8 files, +104 / −56.
  • Seat verification on this head: built the service-analytics closure and ran admission-bridge-resolution, admission-absence-report and read-scope-bridge-resolution: 3 files, 18 tests passed. Ablated (throw readAdmissionUnresolvedError(object); → return false; in plugin.ts): 3 files red, 6 failed / 12 passed. Restored.
  • Seat read of the rest (TIER_DEFAULT): the doc comments that called the never-registered answer a denial are respelled consistently (plugin.ts, read-admission.ts, analytics-service.ts, scripts/analytics-reconcile/boot.ts); no ADR states the status. Changeset: @objectstack/service-analytics patch with a BREAKING banner, title carries !, Clause-②: no, Metadata-protocol: none; the dev's dogfood battery (2509 passed) and gates (82/82, exit 0) are taken as reported.
  • Contract review: not owed. No packages/spec/src/** file and no governed path in the diff.
  • Check-runs on the head: 41 runs, 34 success and 7 expected skips, none failed or pending.
  • Out of scope, disposition: (1) a refused query now logs two error lines (the bridge's and the relay's); noise only, no card. (2) The bridge log prefix still reads "denying the query (fail-closed)" although the answer is now a 503; wording only, the sentence after it is true. Neither blocks.
  • Mergeable: not governed. git merge-tree onto today's main is clean.
  • Landing: Tier S. The seat readies and arms the PR in this act, on the maintainer's standing word for this term (「按照 pm-dispatch 规则审核通过的issue,你可以自主落地」). PR fix(service-analytics)!: an analytics read gate that could not decide answers 503 SERVICE_UNAVAILABLE, not the denial's 403 #25595 closed the card; this PR is Refs #25561 and closes nothing. After it lands, the seat posts a dated note on 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.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 24, 2026 15:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 24, 2026 15:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit 2e6d446 Oct 24, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25561-bridge-503 branch October 24, 2026 15:56
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

Development

Successfully merging this pull request may close these issues.

2 participants