实施 #5413(给 list() 加上 skipped 返回契约)时在同一个文件里核验发现;未在该 PR 里改 —— #5413 的判定面被 PM 限定在 list() 的返回契约与它的三个消费者,本条是 read() 的返回契约,另一组调用方。
事实
packages/cloud-connection/src/local-manifest-source.ts(分支 claude/issue-5413-manifest-source-skipped-entries,read() 本身未被 #5413 改动):
/** Read one entry; null when absent or unreadable. */
read(manifestId: string): InstalledManifestEntry | null {
const file = this.fileFor(manifestId);
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
和 #5413 修掉的 list() 是同一个形状的另一半:不带绑定的 catch,抛出的对象在原地丢弃。null 同时表示两个事实 —— 「这个 manifest 没装过」和「装过,但那个文件读不出来」。注释 null when absent or unreadable 说明这是有意的合并。
与 list() 的区别在于:read() 的 null 至少是一个信号(调用方会分支),不像 list() 的短列表那样完全不可察觉。所以这条比 #5413 轻。
后果
三个调用方(都在 packages/cloud-connection/src/marketplace-install-local-plugin.ts):
-
:586 安装路径的 ADR-0120 D5e posture gate —— previousEntry = this.ledger.read(manifestId) 喂给 evaluateGlobalUniqueGate()。损坏的既有条目读成 null = 「没有既往 attestation」= gate 重新发问。这个方向是安全的(宁可重复一次一次性仪式,也不会跳过它),列在这里只为说明它已核验、不是隐患。
-
:929 reseed 与 :1009 purge —— 两处都是先 has()(existsSync,为 true)再 read()(因损坏得到 null),于是回:
500 { code: 'MARKETPLACE_STORAGE_FAILED', message: 'Failed to read manifest cache.' }
has() 刚说文件在,read() 就说读不出来,而为什么读不出来(Unexpected end of JSON input / EACCES / EISDIR)在 catch 处已经被丢掉了。操作者拿到的是一句自己指向自己的话,没有任何一条能指向那个文件的线索;服务端日志里也没有补一行。属 #5403 / #5412 / #5413「catch 吞掉唯一能解释状况的那个对象」家族。
复现路径
.objectstack/installed-packages/com.acme.crm.json # 截断的 JSON
POST /api/v1/marketplace/install-local/com.acme.crm/reseed-sample-data
→ 500 "Failed to read manifest cache."(无因由;日志无补充)
决策点(不预设,留给分诊)
read() 的返回契约怎么改,和 #5413 是同一类决策,但结论不见得相同 —— read() 的 null 已经是一个可分支的信号,破坏性改造的收益比 list() 小:
倾向 A(与 #5413 同一形状,两个方法的契约不至于分叉),但 read() 的 null 语义被三处调用方以不同方式消费,需要维护者拍板。
备注
实施 #5413(给
list()加上skipped返回契约)时在同一个文件里核验发现;未在该 PR 里改 —— #5413 的判定面被 PM 限定在list()的返回契约与它的三个消费者,本条是read()的返回契约,另一组调用方。事实
packages/cloud-connection/src/local-manifest-source.ts(分支claude/issue-5413-manifest-source-skipped-entries,read()本身未被 #5413 改动):和 #5413 修掉的
list()是同一个形状的另一半:不带绑定的catch,抛出的对象在原地丢弃。null同时表示两个事实 —— 「这个 manifest 没装过」和「装过,但那个文件读不出来」。注释null when absent or unreadable说明这是有意的合并。与
list()的区别在于:read()的null至少是一个信号(调用方会分支),不像list()的短列表那样完全不可察觉。所以这条比 #5413 轻。后果
三个调用方(都在
packages/cloud-connection/src/marketplace-install-local-plugin.ts)::586安装路径的 ADR-0120 D5e posture gate ——previousEntry = this.ledger.read(manifestId)喂给evaluateGlobalUniqueGate()。损坏的既有条目读成null= 「没有既往 attestation」= gate 重新发问。这个方向是安全的(宁可重复一次一次性仪式,也不会跳过它),列在这里只为说明它已核验、不是隐患。:929reseed 与:1009purge —— 两处都是先has()(existsSync,为 true)再read()(因损坏得到null),于是回:has()刚说文件在,read()就说读不出来,而为什么读不出来(Unexpected end of JSON input/EACCES/EISDIR)在catch处已经被丢掉了。操作者拿到的是一句自己指向自己的话,没有任何一条能指向那个文件的线索;服务端日志里也没有补一行。属 #5403 / #5412 / #5413「catch 吞掉唯一能解释状况的那个对象」家族。复现路径
决策点(不预设,留给分诊)
read()的返回契约怎么改,和 #5413 是同一类决策,但结论不见得相同 ——read()的null已经是一个可分支的信号,破坏性改造的收益比list()小:LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 对齐,返回{ entry, failure }之类的结构化结果:契约一致,漏报编译期可见;要改 3 处调用方。read()不动,改在调用方 —— 但调用方拿不到 cause(它在read()里就没了),所以这条实际上要求read()至少把 cause 透出来,等价于 A 的弱化版。read()内补一行日志:与LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 分诊里否决 C 的理由相同(剥夺调用方报告权、该类不持有 logger)。倾向 A(与 #5413 同一形状,两个方法的契约不至于分叉),但
read()的null语义被三处调用方以不同方式消费,需要维护者拍板。备注
LocalManifestSource/installed-packages/manifest cache/ledger),无重复;唯一命中是LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 自身。LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 无依赖关系,独立成单。不作为LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 的 sub-issue ——LocalManifestSource.list()静默丢弃损坏的 ledger 条目 —— 已装应用在 boot 时消失、在控制台列表里缺席,且没有任何一条日志 #5413 的完成范围是list()的契约与它的三个消费者,read()与它的调用方不落在其中。