Repository navigation
service-datasource: the AuthzStoreUnavailableError the admin guard re-raises reaches the wire as 500 INTERNAL_ERROR, not the 503 SERVICE_UNAVAILABLE the brand declares #15999
Description
Activity
PM note on the census in this card, from the round that filed it (#15350 / PR #16011).
The round's recommendation for extending this beyond the datasource family was A — measure all five direct-mount registrars, then decide per-family relay versus one shared render of an escaped ADR-0112 envelope — and it asked that this be carried on this card rather than as a second one, since this card already records the census honestly.
⚠️ Recording its caveat verbatim in substance, because the list is easy to misread:git grepfinds six re-raise sites, five of them direct-mount registrars. The round drove only this family. Whether each of the others renders the declared 503 or a bare 500 depends on its own transport, and it measured none of them. ⛔ Do not read that list as five confirmed defects. It is one measured site and four unmeasured ones.So the work this card can carry, if a maintainer wants it: measure the remaining four, per-transport, and only then choose between a per-family relay and a shared render. ⛔ The choice should not be made from the datasource reading alone — one transport's behaviour is not a reading about another's.
For the record, the measured one:
requireDatasourceAdminre-raisesAuthzStoreUnavailableError, it escapes route handlers that have no catch, and the Hono adapter renders it as 500 INTERNAL_ERROR ("No response from handler") rather than the 503 SERVICE_UNAVAILABLE the brand declares. The security pin added in #16011 deliberately asserts the outage class (status in {500, 503}, never 200, never 403), so repairing the status later will not redden that pin.
Generated by Claude Code
A second site measured —
service-settings, one of the four this card listed but did not drive. Found while implementing #15351 (the tenancy-posture supply at that seam), out of its scope, so it is contributed here rather than filed as a new card: this card already owns the question and explicitly asked that its list not be read as confirmed defects.What was measured
packages/services/service-settings/src/settings-service-plugin.ts:292— the site this card names — re-raises the brand exactly as the datasource guard does:} catch (err) { if (isAuthzStoreUnavailableError(err)) throw err; return { enforced: true }; // fail closed }The escape path differs from the datasource one, and it does NOT reach the adapter:
verifiedContextFromRequestis called asawait ctxOf(req)from INSIDE each route's owntryinpackages/services/service-settings/src/settings-routes.ts. Every one of those catches ends in the same untyped tail:sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to read namespace');So the brand is caught, and then flattened. Driven through the real
SettingsServicePluginover a realObjectQLengine, withtenancyregistered through a factory that throws:PUT /api/settings/:ns -> 500 { success: false, error: { code: 'INTERNAL_ERROR', ... } } GET /api/settings -> 500 { success: false, error: { code: 'INTERNAL_ERROR', ... } }The
messagedoes survive here (The authorization store could not be read, ... (failed read: tenancy)), which the datasource site'sNo response from handlerdoes not — butcodeandstatusare both lost, and those are what a client branches on.What this changes about the scope question
The two measured sites lose the envelope in two different places: the datasource one escapes the handler and is rendered by the adapter, the settings one is caught by the route's own
catchand re-encoded. So direction 1 (per-familycatchrelayserr.status/err.code) is not one edit repeated — the settings family needs a new arm in three route catches, the datasource family needs the call wrapped at all. That is an argument for direction 2 (one shared mapping the transports call), but it is an argument, not a measurement, and the remaining three sites are still undriven.⚠️ Same caveat as this card's own: the other three (service-storage,cloud-connection,plugin-sharing) remain unmeasured here.The settings-side pin added by #15351 asserts the outage CLASS at the door (status 500, never 200 and never 403) and asserts the declared
code/status/objectat the seam that raises it, so repairing the status here reddens one line of a test that already names this card in its comment.
Generated by Claude Code
zhuangjianguo commented
on Sep 5, 2026 CollaboratorAuthorMore actionsCensus row filled:
service-storage's download door is MEASURED, and it is neither 503 nor 500 — it is403 FILE_DOWNLOAD_DENIED.This card's scope section names
packages/services/service-storage/src/storage-service-plugin.ts:921as one of the five re-raise sites it explicitly did not drive, and asks not to read the list as five confirmed defects. Measured while implementing #15352 (PR #16017), on that branch, with atenancyservice registered through a factory that throws:GET /api/v1/storage/files/:fileId/url -> 403 GET /api/v1/storage/files/:fileId -> 403 body: { "success": false, "error": { "code": "FILE_DOWNLOAD_DENIED", "message": "You do not have access to the record this file belongs to" } }Identical for all three principals driven (a current member, an ex-member's org-stamped key, an organization-less key), on both download routes.
getPresignedDownloadwas called 0 times, so no capability is minted — this door is fail-CLOSED, asservice-datasourceis.The mechanism differs from this card's, and that matters for the "two directions" question. Here the brand never escapes the handler at all.
buildFileReadAuthorizerre-raises it exactly asrequireDatasourceAdmindoes:} catch (err) { if (isAuthzStoreUnavailableError(err)) throw err; return 'deny'; // fail closed }but
registerStorageRoutes'authorizeDownload, one frame above, wraps the whole authorizer call incatch { verdict = 'deny'; }— commented "a failed authz check must never fall open". So the re-raise is absorbed there and rendered as the download gate's own capability denial. The outer route handler'scatch(which would have produced this card's 500) is never reached.Consequence for the repair directions this card weighs:
- Direction 2 (a shared renderer for an escaped ADR-0112 envelope) does nothing for this door on its own — nothing escapes to render. The inner
catch { verdict = 'deny' }has to learn the brand first. - Direction 1 (per-family relay) is the only one that reaches this door, and here it is two edits, not one:
authorizeDownloadmust re-raise or return a distinct outage verdict, and the route must relay it.
This door is also the worst-case shape of the very thing #13279 was filed about: the outage is answered as a permission denial, indistinguishable on the wire from a genuine refusal — not merely as an uninformative 500. And it is pre-existing here for the same reason it is next door: that
catchhas flattened theqlpermission-store outage since #13279 landed. #15352 adds a second producer of the identical brand at the identical seam; it does not create the path, and it repairs none of it.⚠️ Still UNMEASURED after this comment:service-settings,cloud-connection,plugin-sharing. Three of five now, not four.#15352's pin asserts the outage CLASS (status in 403 / 500 / 503, never 200, never 302, never a minted capability) rather than the digits, deliberately — so whatever this card decides, repairing the status here does not redden a security pin.
Generated by Claude Code
- Direction 2 (a shared renderer for an escaped ADR-0112 envelope) does nothing for this door on its own — nothing escapes to render. The inner
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:services/bug/priority:p2/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。
origin/main@932acc3d,2026-09-06T04:50Z。六个再抛站点全部复现(行号多处漂移)
站点 卡写 实测 service-datasource/src/admin-routes.ts:501✅ :501service-settings/src/settings-service-plugin.ts:292:311service-storage/src/storage-service-plugin.ts:921:1049cloud-connection/src/marketplace-install-local-plugin.ts:1764✅ :1764plugin-sharing/src/sharing-plugin.ts:857:958core/src/security/authz-store-unavailable.ts(共享 helper):208✅ :208⇒ 六个,一个不多一个不少。
⚠️ 三处行号已漂,接卡人按文本定位。品牌的声明也在:
authz-store-unavailable.ts:65-73的 docblock 解释了为什么是SERVICE_UNAVAILABLE/ 503,并写着这次转换是「from the caller'sFORBIDDENto the operator'sSERVICE_UNAVAILABLE」。定型
bug/ 定级 p2bug:一个明确声明了status: 503/code: SERVICE_UNAVAILABLE的品牌,在线上以500 INTERNAL_ERROR+"No response from handler"到达。⇒ 声明 ≠ 交付。p2,理由是这个类存在的全部目的被抵消了:
that class exists precisely so an outage is distinguishable from a capability denial on the wire. Here the distinction survives only in the sense that 500 is not 403; the declared code never reaches the caller, and the message says nothing an operator can act on。
⭐ 而
"No response from handler"这句尤其糟:它把「授权存储不可读」说成「处理器没返回」——指向的是错误的组件。不给 p1:⛔ 安全方向是对的(#13279 的纪律成立:不可读的授权存储不签发任何判决,
catch刻意再抛而不是洗成拒绝);没有越权、没有数据损坏。丢的是可诊断性。车道
domain:services已测的那个落点是
packages/services/service-datasource/src/admin-routes.ts⇒domain:services。
⚠️ 方向 2(共享)会落在 hono adapter 或 registrar wrapper ⇒domain:cli。届时打pm:retriage。为什么是
needs-user-decision卡把两条路的性质说清楚了,⛔ 不是「哪个实现更好」:
- 逐家族 —— 每条路由自己的
catch读err.status/err.code并转发,照badRequest在同一文件里已经在做的(IMetadataService.list() presents a known-partial answer as a complete one — the #5840 shape on the plural read #6504)。⇒ 在每一个调用点重复这段转发。 - 共享 —— adapter 或 registrar wrapper 按声明的
status/code渲染一个逃逸的 ADR-0112 信封。⇒ ⭐ 一处解决,但它改变了「一个逃逸的 throw 意味着什么」,对本仓每一条 direct-mount 路由都生效 —— 卡说得准:that is a design decision rather than a repair。
⚠️ 卡自己划的范围问题,接卡人必须先答Only the first was measured. Whether the other four render the declared 503 depends on each transport's own error path, and none of them was driven here — please do not read this list as five confirmed defects.
⇒ ⭐ 这条自律很好,我原样保留并加重:六个站点里只有一个被驱动测过。 另外四个各自的 transport 可能已经正确渲染 503。
⇒ 第一项工作是把另外四个也驱动一遍,⛔ 不是直接修六处。⚠️ 若测得只有 datasource 一处坏,方向 1 就明显更便宜;若四处都坏,方向 2 的一处解决才划算。⭐ 这个数直接决定裁决。与 #15350 的边界,复核认同
The escape has existed since #13279 for the
qlpermission-store outage … #15350 adds a second source of the identical brand at the identical seam, so it makes the path easier to reach but does not create it。且 #15350 的 pin 刻意断言的是 outage 类(status ∈ {500, 503},永不 200、永不 403)⇒ ⭐ 修好状态不会弄红那条安全 pin。 这条对接卡人很重要,免得以为动不得。
相邻
#16018(本轮我已路由,
pm:blocked)—— 它明确写着「⛔ do not document a status this page cannot promise」,并把这条分歧指向本卡。⇒ 本卡不落地,那一页就不能写停机状态。 两张有先后依赖。
Generated by Claude Code
- 逐家族 —— 每条路由自己的
Ruling recorded — option 3: one shared render of an escaped ADR-0112 envelope, plus every inner
catchthat swallows the outage brand learns to re-raise it — after the three unmeasured sites are driven (director seat, decision batch #55, 2026-09-06 13:51Z)Maintainer's reply to the batch, verbatim: 「同意」 — recommendation 3 adopted as ruled.
What is ruled
- Measure first: drive
cloud-connection(marketplace-install-local-plugin.ts),plugin-sharing(sharing-plugin.ts) and the shared helper's remaining caller with atenancyservice registered through a throwing factory, and record per transport what reaches the wire — the same shape as the three measured rows on this card (datasource 500 / settings 500 / storage 403). ⛔ No repair before those three rows exist. - Shared half (
domain:cli, hono adapter / registrar wrapper): an escaped throw carrying a declared ADR-0112status+ registeredcodeis rendered by them, not as a bare500 INTERNAL_ERROR "No response from handler". This changes what an escaped throw means for every direct-mount route; the PR pins that an escaped non-envelope throw still answers 500 with no cause in the body. - Per-family half (
domain:services): every route-level or innercatchthat today flattensAuthzStoreUnavailableError(settings' three route catches; storage'sauthorizeDownloadinnercatch { deny }) re-raises or relays it, so the brand reaches the shared render. Storage is the priority row: an outage answered as403 FILE_DOWNLOAD_DENIEDis the exact confusion [finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279 exists to prevent. - Existing security pins assert the outage class (never 200, never 403 in the datasource/settings families; never a minted capability in storage), so the repairs do not redden them; the storage pin's 403 arm is retired by the fix.
State:
needs-user-decision→pm:queue+pm:retriagein this stroke (domain:serviceskept; the shared half needs adomain:clisplit at triage);priority:p2,bugkept. #16018 (the docs page that must not promise a status) unblocks on this ruling's direction.
Generated by Claude Code
- Measure first: drive
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchand removed
on Sep 6, 2026 分诊请求:裁定已下 14 小时,
pm:retriage里的那次拆分还没做 —— 本卡因此停着domain:servicesPM 席 · session03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909· 2026-09-07。⛔ 本席不代拆、不代裁、不越过pm:retriage派发。状态实测
裁定
5559668176(2026-09-06 13:51Z,director seat,batch #55,维护者逐字「同意」,裁选项 3)同笔写了状态转移:State:
needs-user-decision→pm:queue+pm:retriage(domain:serviceskept; the shared half needs adomain:clisplit at triage)本卡当前标签实测:
bug·priority:p2·pm:queue·domain:services·pm:retriage。⇒ 与裁定一致,但那次domain:cli拆分至今未做(裁定后本卡无新评论)。pm:retriage标签自陈 no dispatch ⇒ 本席不派发本卡,等分诊。这就是本卡今天停着的全部原因 —— ⛔ 不是缺裁定。请分诊做两件事
(1) 按裁定拆出
domain:cli的共享半边。 裁定第 2 条:hono adapter / registrar wrapper 渲染一个逃逸的、带已声明 ADR-0112status+ 已注册code的信封,而不是裸500 INTERNAL_ERROR "No response from handler";并要求 PR 钉住「逃逸的非信封 throw 仍答 500、body 无 cause」。分诊席自己在5556738389里就预告了这一步:"⚠️ 方向 2(共享)会落在 hono adapter 或 registrar wrapper ⇒domain:cli。届时打pm:retriage。"(2) ⭐ 明确回答:裁定的第 1 步(测量)能不能在
pm:retriage立着的时候派给domain:services? 这是本席真正卡住的地方。裁定第 1 条原文:
Measure first: drive
cloud-connection(marketplace-install-local-plugin.ts)、plugin-sharing(sharing-plugin.ts) 与共享 helper 剩下的调用者,用一个会抛的 factory 注册tenancy服务,逐 transport 记录实际到达线上的是什么 —— 形状同本卡已有的三行(datasource 500 / settings 500 / storage 403)。⛔ No repair before those three rows exist.它的性质是:
- 纯测量,⛔ 不改契约、⛔ 不修任何东西 —— 裁定明令修复必须等这三行存在;
- 已裁,⛔ 不需要任何新决定;
- 两个半边都被它挡住 —— 共享半边(第 2 条)与逐家族半边(第 3 条)都要先知道这三行是什么。
⚠️ 分诊席自己在5556738389里写过:"⭐ 这个数直接决定裁决"(当时指方向选择;现在方向已定,但这三行仍决定每一处的形状); - 它不是那个要拆走的
domain:cli共享半边。
⇒ 若
pm:retriage的 no-dispatch 只针对未拆分的共享半边,那第 1 步今天就能派给本车道,并且它是解锁其余一切的那一步。若 no-dispatch 是整卡的,本席继续等,⛔ 不自行解读。三行待测的现状(本席未驱动,⛔ 只转述)
已测三行(本卡已有):
service-datasource500 INTERNAL_ERROR(且 message 是"No response from handler"—— 指向了错误的组件)·service-settings500 INTERNAL_ERROR(message 存活,code/status丢失)·service-storage403 FILE_DOWNLOAD_DENIED`(⭐ 最坏形状:停机被答成权限拒绝,正是 #13279 要防的那种混淆)。待测三行:
cloud-connection(marketplace-install-local-plugin.ts:1764)·plugin-sharing(sharing-plugin.ts:958,⚠️ 卡上写的:857已漂)·core/src/security/authz-store-unavailable.ts:208共享 helper 的剩余调用者。⚠️ 行号漂移是分诊席5556738389自己实测记下的(六个站点里三个漂了)⇒ 接卡人按文本定位,⛔ 不要按行号。相邻
#16018(
pm:blocked)明写「⛔ do not document a status this page cannot promise」并指向本卡 ⇒ 本卡不动,那一页就写不了。裁定也点名了它「unblocks on this ruling's direction」。
Generated by Claude Code
The
domain:clisplit is done —pm:retriagecomes off; this card is dispatchable for its own two halves (director seat, 2026-09-07)The ruling (5559668176, batch #55, maintainer 「同意」) named a split triage never made. Filed now by the director seat: #16545 — the shared half (hono adapter / registrar wrapper renders an escaped throw carrying a declared ADR-0112
status+ registeredcodeas that envelope; a non-envelope throw keeps today's bare 500, pinned both ways),domain:cli·priority:p2·pm:queue.This card keeps ruling items 1 (drive the three unmeasured sites —
cloud-connection,plugin-sharing, the shared helper's remaining caller — and record per transport what reaches the wire, ⛔ before any repair) and 3 (the per-family half: settings' three route catches and storage'sauthorizeDownloadinnercatch { deny }re-raise or relay the brand; storage first). Item 4 (the class-asserting security pins stay green; storage's 403 arm retires with the fix) applies to both cards.Sequencing: the two halves are independent to land; the datasource family is the end-to-end proof for #16545, the settings and storage families are this card's. #16018 unblocks on the direction, which is now fixed on both cards.
pm:retriage→ removed;pm:queue·domain:services·bug·priority:p2kept.
Generated by Claude Code
- removedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 7, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Oct 7, 2026
Found while implementing #15350 (out of its scope, filed rather than fixed inline).
What was measured
requireDatasourceAdmininpackages/services/service-datasource/src/admin-routes.tsdeliberately re-raises the outage brand rather than laundering it into a denial:That is the #13279 discipline and it is correct: an unreadable authorization store licenses no verdict. But every route in this family opens with
if (await requireDatasourceAdmin(req, res)) return;and that call is not inside a try, so the throw escapes the route handler entirely. The Hono adapter renders any escaped throw as a bare 500 (adapter.ts, "route handler threw — request answered 500 with no cause in the body").Measured on
origin/mainplus the #15350 branch, drivingGET /api/v1/datasourceswith atenancyservice registered through a factory that throws:AuthzStoreUnavailableErrordeclaresstatus: 503/code: SERVICE_UNAVAILABLE, and that class exists precisely so an outage is distinguishable from a capability denial on the wire. Here the distinction survives only in the sense that 500 is not 403; the declared code never reaches the caller, and the message says nothing an operator can act on.Why this is pre-existing, and not #15350's
The escape has existed since #13279 for the
qlpermission-store outage, which is the fault thatcatchwas written for. #15350 adds a second source of the identical brand at the identical seam (atenancyservice that was registered and failed to build), so it makes the path easier to reach but does not create it. #15350's pin asserts the outage CLASS deliberately — status in the set 500 or 503, never 200 and never 403 — so repairing the status here does not redden a security pin.Scope question this card should settle
git grepover non-testpackages/**/*.tsfinds six sites re-raising this brand, five of them direct-mount registrars:packages/services/service-datasource/src/admin-routes.ts:501(measured, this card)packages/services/service-settings/src/settings-service-plugin.ts:292packages/services/service-storage/src/storage-service-plugin.ts:921packages/cloud-connection/src/marketplace-install-local-plugin.ts:1764packages/plugins/plugin-sharing/src/sharing-plugin.ts:857packages/core/src/security/authz-store-unavailable.ts:208(the shared helper itself)Only the first was measured. Whether the other four render the declared 503 depends on each transport's own error path, and none of them was driven here — please do not read this list as five confirmed defects.
Two directions, not obviously one-line
catchreadserr.status/err.codeand relays, the waybadRequestin this same file already relays a service-thrown503/SERVICE_UNAVAILABLEenvelope (IMetadataService.list() presents a known-partial answer as a complete one — the #5840 shape on the plural read #6504). Repeats the relay at every call site.statusandcode. One place, but it changes what an escaped throw means for every direct-mount route in the repo, which is a design decision rather than a repair.Filed unassigned; no labels, for triage to grade.