发现于 #5089(#5040 E2 端点匹配器)实现期,不在该单范围内,故单独立卡。
现象
packages/metadata/src/loaders/database-loader.ts 的三个读方法把任何存储异常吞成「没有」:
loadMany(type)(:740-753)—— catch { return []; },连 warn 都没有;
exists(type, name)(:765-773)—— catch { return false; };
stat(type, name)(:785-)—— 同形。
于是 sys_metadata 所在库不可达时,DatabaseLoader.loadMany('permission') 与「这个环境一条 permission 都没声明」返回完全一样的值。上层 MetadataManager.list() 也无从分辨:它拿到的是一次「成功的空读」,不是一个异常,所以连它自己的 logger.warn(...loader failed to loadMany...) 降级分支都不会触发 —— 整条链上没有任何一处会说出「读失败了」。
为什么这是缺陷而不是设计
ADR-0110 D3 已经为单数读路径立过这条规矩,理由写在 MetadataManager.loadDiagnosed 的注释里:
A miss and an outage are different facts with opposite security meanings, and plain load cannot express the difference: a loader that throws is warn-logged and skipped, so a database the metadata plane cannot reach returns the same null as a name that was never declared. Callers that gate on a declaration MUST NOT read that null as "the author declared no gate" — an availability failure would silently widen access (the REST /actions route's fail-open branch, #3935).
loadDiagnosed 让单数读能分辨两者(degraded + errors)。复数读没有对应物,而且比单数读更糟:load 至少让异常冒到 manager 那层被 warn 记下,loadMany 在 loader 内部就把异常抹掉了,manager 那层的降级分支形同虚设。
凡是「按已声明清单做判断」的消费方都吃这一口 —— 权限/策略/共享规则清单读成空,按消费方的姿态不同,后果要么是静默 fail-open(放行),要么是静默 fail-closed(全站锁死);两种都在外部看起来「一切正常」。这正是 AGENTS.md 降级日志分级那节点名的形状:系统看着正常,而它声称掌握的东西其实没读到。
复现
const loader = new DatabaseLoader({ driver: brokenDriver, tableName: 'sys_metadata' });
await loader.loadMany('permission'); // => [] (驱动抛错,零日志)
await loader.exists('permission', 'admin_all'); // => false
建议方向(裁决留给维护者)
- 复数读补齐
loadDiagnosed 的对位物(loadManyDiagnosed → { items, degraded, errors }),MetadataManager.list() 至少把 degraded 记成 warn/error;
- 或者让 loader 层照实抛出,由 manager 现有的
try/catch 决定降级姿态 —— 这样至少 logger.warn 会真的响一次,消费方也能选择改用严格读。
两条都不是本 issue 要定的;要定的是「读失败必须能被说出来」这一点。
已知的一处下游影响(已在 #5089 里绕开,未修根因)
IMetadataService.matchEndpoint 的契约文本明确要求「读不到存储必须抛出,不得伪装成 miss(miss 会变成 404)」。#5089 为此没有走 list(),而是新增了一个私有的 listForIndex(),不包 try/catch,让 loader 的异常冒到调用方。这在 MemoryLoader / RemoteLoader 上是有效的,但对 DatabaseLoader 无效 —— 它在自己内部就吞了,谁也看不见。listForIndex() 的注释已经把这条限制写明并指向本 issue。
关联
ADR-0110 D3、#3935(fail-open 先例)、#5089(发现处)、AGENTS.md「Degradation log levels — warn vs error」。
发现于 #5089(#5040 E2 端点匹配器)实现期,不在该单范围内,故单独立卡。
现象
packages/metadata/src/loaders/database-loader.ts的三个读方法把任何存储异常吞成「没有」:loadMany(type)(:740-753)——catch { return []; },连 warn 都没有;exists(type, name)(:765-773)——catch { return false; };stat(type, name)(:785-)—— 同形。于是
sys_metadata所在库不可达时,DatabaseLoader.loadMany('permission')与「这个环境一条 permission 都没声明」返回完全一样的值。上层MetadataManager.list()也无从分辨:它拿到的是一次「成功的空读」,不是一个异常,所以连它自己的logger.warn(...loader failed to loadMany...)降级分支都不会触发 —— 整条链上没有任何一处会说出「读失败了」。为什么这是缺陷而不是设计
ADR-0110 D3 已经为单数读路径立过这条规矩,理由写在
MetadataManager.loadDiagnosed的注释里:loadDiagnosed让单数读能分辨两者(degraded+errors)。复数读没有对应物,而且比单数读更糟:load至少让异常冒到 manager 那层被 warn 记下,loadMany在 loader 内部就把异常抹掉了,manager 那层的降级分支形同虚设。凡是「按已声明清单做判断」的消费方都吃这一口 —— 权限/策略/共享规则清单读成空,按消费方的姿态不同,后果要么是静默 fail-open(放行),要么是静默 fail-closed(全站锁死);两种都在外部看起来「一切正常」。这正是 AGENTS.md 降级日志分级那节点名的形状:系统看着正常,而它声称掌握的东西其实没读到。
复现
建议方向(裁决留给维护者)
loadDiagnosed的对位物(loadManyDiagnosed→{ items, degraded, errors }),MetadataManager.list()至少把degraded记成 warn/error;try/catch决定降级姿态 —— 这样至少logger.warn会真的响一次,消费方也能选择改用严格读。两条都不是本 issue 要定的;要定的是「读失败必须能被说出来」这一点。
已知的一处下游影响(已在 #5089 里绕开,未修根因)
IMetadataService.matchEndpoint的契约文本明确要求「读不到存储必须抛出,不得伪装成 miss(miss 会变成 404)」。#5089 为此没有走list(),而是新增了一个私有的listForIndex(),不包try/catch,让 loader 的异常冒到调用方。这在MemoryLoader/RemoteLoader上是有效的,但对DatabaseLoader无效 —— 它在自己内部就吞了,谁也看不见。listForIndex()的注释已经把这条限制写明并指向本 issue。关联
ADR-0110 D3、#3935(fail-open 先例)、#5089(发现处)、AGENTS.md「Degradation log levels —
warnvserror」。