Skip to content

service-storage / service-settings: an authorization-store outage reaches the wire as its declared 503 SERVICE_UNAVAILABLE - #16580

Merged
os-trump merged 5 commits into
mainfrom
claude/issue-15999-authz-outage-status-relay
Sep 7, 2026
Merged

service-storage / service-settings: an authorization-store outage reaches the wire as its declared 503 SERVICE_UNAVAILABLE#16580
os-trump merged 5 commits into
mainfrom
claude/issue-15999-authz-outage-status-relay

Conversation

@os-trump

@os-trump os-trump commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15999

Ruling item 1 (drive the three unmeasured sites) and item 3 (the per-family relay) of decision batch #55 — the maintainer's 「同意」 on option 3, recorded at issuecomment-5559668176, and the director's split at issuecomment-5567537493. Item 2 is the sibling card's and is untouched here: no adapter file is in this diff.


Ruling item 1 — the census, completed. Three rows driven, and two of them are already correct.

⛔ No repair was written before these rows existed. Measured on the merge base, through a real HonoHttpServer (getRawApp().fetch(new Request(...)) — the wire, not a mock res) for the transport rows, and through the real plugin with a tenancy service registered via a throwing factory for the producer rows.

site how the brand leaves the seam what reaches the wire verdict
service-datasource/src/admin-routes.ts escapes the handler → adapter wrap() 500 {code: INTERNAL_ERROR, message: "No response from handler"} broken (the card's own row, re-measured here)
service-settings/src/settings-service-plugin.tssettings-routes.ts caught by each route's own catch, re-encoded 500 INTERNAL_ERROR, message survives broken — repaired here
service-storage/src/storage-service-plugin.tsstorage-routes.ts absorbed by authorizeDownload's catch { deny } 403 FILE_DOWNLOAD_DENIED broken, worst shape — repaired here
cloud-connection/src/marketplace-install-local-plugin.ts escapes the handler → raw Hono mount, not wrap() 500 "Internal Server Error", text/plain, no ADR-0112 envelope at all broken — a shape the card did not anticipate
plugins/plugin-sharing/src/sharing-plugin.ts caught by share-link-routes.ts, which already relays err?.status ?? 500 / err?.code ?? 'INTERNAL' 503 SERVICE_UNAVAILABLE already correct
core/src/security/authz-store-unavailable.ts — the shared helper's remaining caller (rest/src/package-routes.ts, refusePackageRequestsendThrownError) relayed by the route's own catch 503 SERVICE_UNAVAILABLE already correct

Plus a carriage control, because the whole repair depends on it: a handler that ANSWERS 503/SERVICE_UNAVAILABLE through the shared sendError reaches the wire verbatim through the adapter — 503, application/json, {"success":false,"error":{"code":"SERVICE_UNAVAILABLE","message":"The authorization store could not be read… (failed read: \tenancy`)"}}`.

What the census changes about the ruling's own assumptions

  1. The relay arm is not a novel shape — it is the shape this repo already lands correctly. Two of the three undriven sites deliver the declared 503 today, and both do it the same way: a route-level catch that reads err.status / err.code and answers that envelope, exactly like badRequest in service-datasource's admin-routes.ts (IMetadataService.list() presents a known-partial answer as a complete one — the #5840 shape on the plural read #6504). Nothing had to be invented; the two broken families were the ones that had not adopted it.
  2. ⚠️ cloud-connection mounts on getRawApp(), so it funnels through neither wrap() nor any registrar wrapper. Its escaped throw reaches Hono's own default error path and comes back as text/plain Internal Server Error — no envelope, no code, and byte-identical for a non-envelope throw. The sibling card's stated landing shape ("the adapter (or the registrar wrapper it already funnels through) — measure which one every direct-mount route actually passes") will therefore not reach this door as written. That card explicitly asked for this measurement; it is recorded here rather than filed again, since this card owns the census. ⛔ Nothing in packages/cloud-connection is edited here.

⚠️ Three more card-face citations are stale, measured on this tree. All located by text, never by line number:

  • 「settings' three route catches」 is four. Located by predicate — a catch around a ctxOf(req) whose exit is a denial or a swallow — settings-routes.ts has GET /api/settings, GET /api/settings/:namespace, PUT /api/settings/:namespace and POST /api/settings/:namespace/:actionId. All four are repaired and all four are driven.
  • Ruling item 4 says the settings pin asserts the outage class. Measured, it asserts an exact expect(res.status).toBe(500) (settings-admission-tenancy-posture.test.ts). So the settings pin has a retiring arm too, not only storage's — see below.
  • authorizeDownload is in storage-routes.ts, not storage-service-plugin.ts; the latter only documents the seam. Confirmed, and its now-false docblock is corrected in this diff.

Ruling item 3 — the per-family relay. Storage first.

RELAY, ⛔ not a bare re-raise. The shared render that would give an escaped ADR-0112 envelope its declared status is the sibling card's and has not landed. Measured consequence of stopping at a re-raise today: storage's outer route catch answers 500 INTERNAL (opaque, worse than the wrong-but-informative 403) and settings' transport answers the bare 500 INTERNAL_ERROR "No response from handler" (losing the message the flattening at least preserved). A relay answers before the throw escapes, so it is correct today and stays correct once the shared render exists — that render then only sees what no route relayed.

packages/services/service-storage/src/storage-routes.tsauthorizeDownload's inner catch { verdict = 'deny' } learns the brand and answers sendError(res, err.status, err.code, err.message), returning false (the helper's existing "a response was already sent, stop" contract). Both download doors — /files/:fileId/url and the /files/:fileId redirect sibling — inherit it.

packages/services/service-settings/src/settings-routes.ts — one module-local relayAuthzStoreOutage(res, err) called as the first arm of all four route catches. Written once rather than copied four times: these catches are one decision reached four ways, and a copy per handler is how a family drifts apart on the arm that matters least often.

Both read status / code off the error rather than writing digits, and both are scoped to the brand predicate isAuthzStoreUnavailableError — never instanceof (two copies of the class across a package boundary make instanceof answer false for a genuine instance, which would silently restore the quiet 403), and never a duck-typed "anything carrying a status" (that would let an unrelated coded throw pick the gate's answer, and this door must never fall open).

Ruling item 4 — the pins whose meaning changes

Both are updated in this diff, loudly, in place — ⛔ neither is papered over:

  • service-storage/src/file-read-tenancy-posture-admission.test.ts §5b — its 403 arm is retired. expect([403, 500, 503]).toContain(res.status) becomes expect(res.status).toBe(503) plus the code. The security half is unchanged and still asserted first in every arm: never 200, and getPresignedDownload called zero times. A door that answered 503 after minting a URL would pass a status-only suite and still have issued the capability.
  • service-settings/src/settings-admission-tenancy-posture.test.tsits 500 arm is retired, for the same reason and with the same care: settingRows() empty is still asserted first.

Evidence

Red before green, prediction written before the run. Predicted: RED in the ordinary direction — §2 of both new pins plus the two updated pins fail on the status digits; the §1 happy-path and §3 width controls stay GREEN (if they went red too, the relay would be wider than the brand and the repair would be wrong, not the pin).

Measured, with the two source files restored to their merge-base bytes and every test file left exactly as committed:

storage:  Tests  9 failed | 35 passed (44)   — 6 × "expected 403 to be 503", §1 and §3 green
settings: Tests  8 failed | 31 passed (39)   — 4 × "expected 500 to be 503", §0/§1/§3 green

Mutation and restore both proven on disk by blob hash, not by an exit code:

HEAD      storage b2d3f27491f0e23d8618f5d1c06275e507339c79   settings cba6846da81fd75f9c697a52b55286f586de0b11
MUTATED   storage 3984411c37bf1ca09d0d2fd7f87f99917e9c5c0a   settings f5770cdc1b1beddbb97bc2210a87aa590f92788c
RESTORED  storage b2d3f27491f0e23d8618f5d1c06275e507339c79   settings cba6846da81fd75f9c697a52b55286f586de0b11   match=yes/yes
anchored text counts before/after the mutation: 2 → 0 and 5 → 0 (a no-op ablation is a void reading)
git diff HEAD  empty · git status  clean

The measurement scaffold for the census rows was transient and is not in this diff: a scratch vitest file under packages/rest/src/ for the wire rows, removed with git status proven clean before any repair byte was written. The two producer rows were driven with the packages' own existing suites (marketplace-install-local-tenancy-admission.test.ts, share-link-tenancy-posture-admission.test.ts), both green.

Suites, on the final head:

pnpm --filter @objectstack/service-storage --filter @objectstack/service-settings run test
  service-settings  Test Files 32 passed (32)   Tests 572 passed (572)
  service-storage   Test Files 37 passed (37)   Tests 580 passed (580)
pnpm --filter @objectstack/service-storage --filter @objectstack/service-settings run typecheck   → exit 0
  (service-storage's typecheck includes check:test-typecheck — 0 files / 0 errors / 0 pinned signatures)

Lint — the full population, not a narrowing. eslint . --no-inline-config (the pnpm lint command itself) over 6284 files, the count read from eslint's own --format json output: 0 errors, 0 warnings. No narrowing argument is needed because nothing was narrowed; for the record, this config enables no type-aware linting (no parserOptions.project, no typed rules).

Gate family, derived mechanically on the final head dcd73a2ea via node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (asserted repo; no stale-tree warning; change set 8 paths vs merge base d1c86a745, three-dot). 59 commands, all run, every exit code captured immediately after its own single redirection and never through a pipe: 59 × exit 0. Three first returned exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:i18n, check:type-check-debt — all three read built output); that is not a reading, so the prerequisite was satisfied with the full package build and all three re-run to exit 0.

The derivation flagged four artifact-roster families whose roster lives under a directory one of these paths is in, where silence is evidence in neither direction. Read rather than assumed — run, all exit 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity. check:error-status-conformance was run for the same reason (a status is what moved): exit 0.

Changeset: patch, both packages — reasoned, not defaulted. A wire status moving 403 → 503 and 500 → 503 is user-observable, so the question is real. It is patch because nothing is added, removed or renamed: no export moves, no signature widens, no authorable key changes, and this repo bumps a bug fix in a released package patch. What the observability earns is not a bigger number — a version carries no mapping — but the FROM → TO table, which is in the changeset body and ships to consumers as CHANGELOG.md, along with what a client branching on the old statuses should do instead.

Clause-②: no, re-derived from the real diff. Nothing reaches packages/spec/**; no exported signature widens (relayAuthzStoreOutage is module-local, authorizeDownload is a local const, StorageRoutesOptions is unchanged). No new error code and no ledger row — SERVICE_UNAVAILABLE is an existing closed-vocabulary member already mapped to 503.


验收备注

⚠️ One declared-surface deviation, stated rather than buried. The dispatch named service-settings/src/settings-service-plugin.ts as the settings file. Located by predicate as that same dispatch instructed (「Find them by predicate, ⛔ do not enumerate from the card's number」), the flattening catches are one file over, in settings-routes.ts — the plugin file holds only the raise site, where no res-shaped responder is in scope and where a repair would have meant inventing a transport, which the dispatch forbids. Same package, same lane, zero open PRs in it. The dispatch delegated the location and its filename was a placeholder; the deviation is one file inside a package the dispatch already scoped, and it is reported here and in the structured report rather than silently taken.

Observations, ⛔ not filed — no card opened for any of these:

  • cloud-connection's raw-Hono mount (census row 4) is evidence the sibling domain:cli card asked for by name; it belongs in that card's landing shape, not in a duplicate.
  • storage-routes.ts's two outer route catches (sendError(res, 500, 'INTERNAL', …)) are a second flattener on the same doors. They are unreachable for this brand now that the inner seam relays, and adding an arm there would be a check that can never fire — deliberately left alone rather than made into a phantom.
  • share-link-routes.ts relays with the string 'INTERNAL' where the rest of the repo writes 'INTERNAL_ERROR'; not this card's, not touched, and its 503 arm is correct either way.
  • The docs page blocked on this direction (attachments-access.mdx's 401 row is incomplete once the four tenancy-posture doors land — one family-wide addition, not four per-card edits #16018) can now promise a status for the two families repaired here; the datasource family still cannot until the sibling card lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-settings, @objectstack/service-storage, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-storage/src/storage-service-plugin.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/protocol/kernel/config-resolution.mdx (via registerSettingsRoutes (symbol, a top-level function))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-storage/src/storage-service-plugin.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 11 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 3e560da59c49453487604d5f42ca35906c7a6d02packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3e560da59c49453487604d5f42ca35906c7a6d02

⚠️ 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 3e560da59c49453487604d5f42ca35906c7a6d02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/l tests tooling

Projects

None yet

2 participants