service-storage / service-settings: an authorization-store outage reaches the wire as its declared 503 SERVICE_UNAVAILABLE - #16580
Conversation
…ngs routes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ings doors Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…relay fixture Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…thz-outage-status-relay
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 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 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
|
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 mockres) for the transport rows, and through the real plugin with atenancyservice registered via a throwing factory for the producer rows.service-datasource/src/admin-routes.tswrap()500{code: INTERNAL_ERROR, message: "No response from handler"}service-settings/src/settings-service-plugin.ts→settings-routes.tscatch, re-encoded500 INTERNAL_ERROR, message survivesservice-storage/src/storage-service-plugin.ts→storage-routes.tsauthorizeDownload'scatch { deny }403 FILE_DOWNLOAD_DENIEDcloud-connection/src/marketplace-install-local-plugin.tswrap()500 "Internal Server Error",text/plain, no ADR-0112 envelope at allplugins/plugin-sharing/src/sharing-plugin.tsshare-link-routes.ts, which already relayserr?.status ?? 500/err?.code ?? 'INTERNAL'503 SERVICE_UNAVAILABLEcore/src/security/authz-store-unavailable.ts— the shared helper's remaining caller (rest/src/package-routes.ts,refusePackageRequest→sendThrownError)catch503 SERVICE_UNAVAILABLEPlus a carriage control, because the whole repair depends on it: a handler that ANSWERS
503/SERVICE_UNAVAILABLEthrough the sharedsendErrorreaches 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
catchthat readserr.status/err.codeand answers that envelope, exactly likebadRequestinservice-datasource'sadmin-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.cloud-connectionmounts ongetRawApp(), so it funnels through neitherwrap()nor any registrar wrapper. Its escaped throw reaches Hono's own default error path and comes back astext/plainInternal 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 inpackages/cloud-connectionis edited here.catcharound actxOf(req)whose exit is a denial or a swallow —settings-routes.tshasGET /api/settings,GET /api/settings/:namespace,PUT /api/settings/:namespaceandPOST /api/settings/:namespace/:actionId. All four are repaired and all four are driven.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.authorizeDownloadis instorage-routes.ts, notstorage-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
catchanswers500 INTERNAL(opaque, worse than the wrong-but-informative 403) and settings' transport answers the bare500 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.ts—authorizeDownload's innercatch { verdict = 'deny' }learns the brand and answerssendError(res, err.status, err.code, err.message), returningfalse(the helper's existing "a response was already sent, stop" contract). Both download doors —/files/:fileId/urland the/files/:fileIdredirect sibling — inherit it.packages/services/service-settings/src/settings-routes.ts— one module-localrelayAuthzStoreOutage(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/codeoff the error rather than writing digits, and both are scoped to the brand predicateisAuthzStoreUnavailableError— neverinstanceof(two copies of the class across a package boundary makeinstanceofanswer 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)becomesexpect(res.status).toBe(503)plus the code. The security half is unchanged and still asserted first in every arm: never 200, andgetPresignedDownloadcalled 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.ts— its 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:
Mutation and restore both proven on disk by blob hash, not by an exit code:
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 withgit statusproven 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:
Lint — the full population, not a narrowing.
eslint . --no-inline-config(thepnpm lintcommand itself) over 6284 files, the count read from eslint's own--format jsonoutput: 0 errors, 0 warnings. No narrowing argument is needed because nothing was narrowed; for the record, this config enables no type-aware linting (noparserOptions.project, no typed rules).Gate family, derived mechanically on the final head
dcd73a2eavianode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(asserted repo; no stale-tree warning; change set 8 paths vs merge based1c86a745, 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 returnedexit 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-conformancewas run for the same reason (a status is what moved): exit 0.Changeset:
patch, both packages — reasoned, not defaulted. A wire status moving403 → 503and500 → 503is user-observable, so the question is real. It ispatchbecause 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 packagepatch. 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 asCHANGELOG.md, along with what a client branching on the old statuses should do instead.Clause-②: no, re-derived from the real diff. Nothing reachespackages/spec/**; no exported signature widens (relayAuthzStoreOutageis module-local,authorizeDownloadis a local const,StorageRoutesOptionsis unchanged). No new error code and no ledger row —SERVICE_UNAVAILABLEis an existing closed-vocabulary member already mapped to 503.验收备注
service-settings/src/settings-service-plugin.tsas 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, insettings-routes.ts— the plugin file holds only the raise site, where nores-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 siblingdomain:clicard 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.tsrelays 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.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Generated by Claude Code