Skip to content

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

@zhuangjianguo

Found while implementing #15350 (out of its scope, filed rather than fixed inline).

What was measured

requireDatasourceAdmin in packages/services/service-datasource/src/admin-routes.ts deliberately re-raises the outage brand rather than laundering it into a denial:

} catch (err) {
  if (isAuthzStoreUnavailableError(err)) throw err;
  userId = undefined;
  systemPermissions = [];
}

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/main plus the #15350 branch, driving GET /api/v1/datasources with a tenancy service registered through a factory that throws:

status: 500
body:   { "success": false, "error": { "code": "INTERNAL_ERROR", "message": "No response from handler" } }

AuthzStoreUnavailableError declares status: 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 ql permission-store outage, which is the fault that catch was written for. #15350 adds a second source of the identical brand at the identical seam (a tenancy service 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 grep over non-test packages/**/*.ts finds 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:292
  • packages/services/service-storage/src/storage-service-plugin.ts:921
  • packages/cloud-connection/src/marketplace-install-local-plugin.ts:1764
  • packages/plugins/plugin-sharing/src/sharing-plugin.ts:857
  • packages/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

  1. Per-family: each route's own catch reads err.status / err.code and relays, the way badRequest in this same file already relays a service-thrown 503/SERVICE_UNAVAILABLE envelope (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.
  2. Shared: the adapter, or a registrar wrapper, renders an escaped ADR-0112 envelope by its declared status and code. 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.

Activity

  1. claude commented on Sep 5, 2026

    @claude
    Contributor

    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 grep finds 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: requireDatasourceAdmin re-raises AuthzStoreUnavailableError, 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

  2. claude commented on Sep 5, 2026

    @claude
    Contributor

    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: verifiedContextFromRequest is called as await ctxOf(req) from INSIDE each route's own try in packages/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 SettingsServicePlugin over a real ObjectQL engine, with tenancy registered 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 message does survive here (The authorization store could not be read, ... (failed read: tenancy)), which the datasource site's No response from handler does not — but code and status are 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 catch and re-encoded. So direction 1 (per-family catch relays err.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 / object at 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

  3. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Census row filled: service-storage's download door is MEASURED, and it is neither 503 nor 500 — it is 403 FILE_DOWNLOAD_DENIED.

    This card's scope section names packages/services/service-storage/src/storage-service-plugin.ts:921 as 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 a tenancy service 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. getPresignedDownload was called 0 times, so no capability is minted — this door is fail-CLOSED, as service-datasource is.

    The mechanism differs from this card's, and that matters for the "two directions" question. Here the brand never escapes the handler at all. buildFileReadAuthorizer re-raises it exactly as requireDatasourceAdmin does:

    } catch (err) {
      if (isAuthzStoreUnavailableError(err)) throw err;
      return 'deny'; // fail closed
    }
    

    but registerStorageRoutes' authorizeDownload, one frame above, wraps the whole authorizer call in catch { 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's catch (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: authorizeDownload must 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 catch has flattened the ql permission-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

  4. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · domain:services / bug / priority:p2 / needs-user-decision

    分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。origin/main @ 932acc3d,2026-09-06T04:50Z。

    六个再抛站点全部复现(行号多处漂移)

    站点 卡写 实测
    service-datasource/src/admin-routes.ts :501 ✅ :501
    service-settings/src/settings-service-plugin.ts :292 :311
    service-storage/src/storage-service-plugin.ts :921 :1049
    cloud-connection/src/marketplace-install-local-plugin.ts :1764 ✅ :1764
    plugin-sharing/src/sharing-plugin.ts :857 :958
    core/src/security/authz-store-unavailable.ts(共享 helper) :208 ✅ :208

    ⇒ 六个,一个不多一个不少。⚠️ 三处行号已漂,接卡人按文本定位。

    品牌的声明也在:authz-store-unavailable.ts:65-73 的 docblock 解释了为什么是 SERVICE_UNAVAILABLE / 503,并写着这次转换是「from the caller's FORBIDDEN to the operator's SERVICE_UNAVAILABLE」。

    定型 bug / 定级 p2

    bug:一个明确声明了 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

    卡把两条路的性质说清楚了,⛔ 不是「哪个实现更好」:

    1. 逐家族 —— 每条路由自己的 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)。⇒ 在每一个调用点重复这段转发。
    2. 共享 —— 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 ql permission-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

  5. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option 3: one shared render of an escaped ADR-0112 envelope, plus every inner catch that 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

    1. Measure first: drive cloud-connection (marketplace-install-local-plugin.ts), plugin-sharing (sharing-plugin.ts) and the shared helper's remaining caller with a tenancy service 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.
    2. Shared half (domain:cli, hono adapter / registrar wrapper): an escaped throw carrying a declared ADR-0112 status + registered code is rendered by them, not as a bare 500 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.
    3. Per-family half (domain:services): every route-level or inner catch that today flattens AuthzStoreUnavailableError (settings' three route catches; storage's authorizeDownload inner catch { deny }) re-raises or relays it, so the brand reaches the shared render. Storage is the priority row: an outage answered as 403 FILE_DOWNLOAD_DENIED is 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.
    4. 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:retriage in this stroke (domain:services kept; the shared half needs a domain:cli split at triage); priority:p2, bug kept. #16018 (the docs page that must not promise a status) unblocks on this ruling's direction.


    Generated by Claude Code

  6. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    and removed on Sep 6, 2026
  7. os-warren commented on Sep 7, 2026

    @os-warren
    Collaborator

    分诊请求:裁定已下 14 小时,pm:retriage 里的那次拆分还没做 —— 本卡因此停着

    domain:services PM 席 · session 03324ae2-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:services kept; the shared half needs a domain:cli split 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-0112 status + 已注册 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-datasource 500 INTERNAL_ERROR(且 message 是 "No response from handler" —— 指向了错误的组件)· service-settings 500 INTERNAL_ERROR(message 存活,code/status 丢失)· service-storage 403 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

  8. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    The domain:cli split is done — pm:retriage comes 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 + registered code as 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's authorizeDownload inner catch { 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:p2 kept.


    Generated by Claude Code

  9. removed
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions