发现于 #5259(`unregister()` 失效/删除顺序)实现期,**不在该单范围内**(改的是顺序与失败上报,不是 loader 契约),故单独立卡。**当前无人踩到**,按 observation-class 归档:挂 `finding`,不挂 `pm:queue`,留给分诊决定要不要动契约。 ## 事实 `packages/metadata/src/loaders/loader-interface.ts` 的 `MetadataLoader` 接口声明了可选的 ```ts save?( type: string, name: string, data: any, options?: MetadataSaveOptions ): Promise< MetadataSaveResult >; ``` 但**完全没有 `delete`**。于是 `MetadataManager.unregister()` 只能在调用点鸭子类型地探它(#5259 之前是两处 `as any`,之后是一个具名的 `DeletableMetadataLoader` 形状 + `typeof … === 'function'` 守卫,casts 收敛了,契约缺口没有变): ```ts if (loader.contract.protocol !== 'datasource:' || !loader.contract.capabilities.write) continue; if (typeof ( loader as DeletableMetadataLoader ).delete !== 'function') continue; // ← 静默跳过 ``` 也就是说:一个 loader 只要声明 `protocol: 'datasource:'` + `capabilities.write: true`,`register()` 就会往它写(`save` 有就写、没有就跳过),而 `unregister()` 在它**没有 `delete` 方法**时**一声不吭地跳过**——不是 `warn`,不是 #5259 新加的 `error`,什么都没有。随后 `unregister()` 照常删 registry、失效 listCache、`publishRealtimeMetadataEvent('deleted')` 并 `notifyWatchers({type:'deleted'})`。 后果与 #5259 处理的「delete 抛错」完全同类,而且更安静: - 调用方(Studio/Setup、REST DELETE、CLI、package teardown)被告知删除成功; - 行还在那个 loader 里,下一次 `list()`/`get()` 直接把它读回来(registry ∪ loaders 合并),重启后依旧; - 与 #5259 的区别只有一点:失败路径现在**响**(`error`,带后果与修复),而「压根没有 delete 方法」这条路径**完全没有信号**。 `capabilities.write` 的语义因此是分叉的:对 `register()` 它意味着「可写」,对 `unregister()` 它什么也不保证。declared ≠ enforced 的标准形状。 ## 为什么现在没人踩到(所以是 observation,不是 P0) 全仓 `protocol: 'datasource:'` 的 loader 只有一个 —— `packages/metadata/src/loaders/database-loader.ts` —— 而它有 `async delete(type: string, name: string): Promise< void >`(第 1199 行)。所以今天**任何用户路径都不会**走到这个静默分支;它是给第三方/未来 loader 留的坑,以及给「AI 写一个新 loader」留的坑:接口没声明 `delete`,照着接口实现出来的 loader 天然就少这个方法,而它声明 `write: true` 时不会被任何东西拦下。 严重度不由我判:#5259 正文自己也说过「是否一并处理请分诊定夺」,而按 objectstack#4949 的纪律,填卡时判的严重度两个方向都不可靠。这里只把事实与范围写清楚。 ## 可能的方向(不预设结论,涉及公共契约,应由维护者定) 1. **`MetadataLoader` 声明 `delete?( type: string, name: string ): Promise< void >`**,并在 `unregister()` 里把「声明了 `write` 却没有 `delete`」变成**响的**(一次性 `error`,或注册时就拒绝)。契约优先,作者期就能发现。 2. **把删除能力搬进 contract**:`capabilities` 已经是声明面(`read`/`write`/`watch`/`list`),让 `write` 真正蕴含「可删」,并在 `registerLoader()` 落地时校验方法存在——declared = enforced,注册期就炸,而不是删除期静默。 3. 维持现状,只补一条 `registerLoader()` 期的诊断。 方向 1/2 会动 `MetadataLoader` 这个公共接口(第三方 loader 的实现面),因此明确不在 #5259 内擅自决定。 发现于 #5259 实现期(会话 `session_01Pbu27iNUfQCHeuS551Rqo7`);未认领,留给分诊。
Activity
baozhoutao commentedon Aug 5, 2026
发现分诊轮判级(session_01VkPSGsX9o17MsGv3Lbxu2w,2026-08-05):晋级
pm:queue。前提核验见 #4604 01:57Z(对b4ad98435成立:loader-interface.ts有save?无delete)。理由:loader 无delete方法时unregister()静默跳过却照常宣告「已删除」—— 声明与执行脱钩的静默容错,正是两轴都要杀的形状。维护者可否决。Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
认领:PM 循环第 2 轮(engine-core 车道)
会话:
session_01V7WetGmnfoXNn8cLieKKmx分支:
claude/issue-5276-loader-delete-contractWorktree:
objectstack-issue-5276域:
domain:engine-core文件面:
packages/metadata/src/loaders/loader-interface.ts、packages/metadata/src/metadata-manager.ts(registerLoader 校验 + unregister 守卫)、metadata 包内测试、.changeset/*.md。方向裁决(PM 拍板,维护者可否决——依据:declared = enforced 是既有仓规范 PD #10,restore-invariant 类不升级):取方向 1+2 合并——
MetadataLoader接口声明delete?(type, name): Promise<void>;registerLoader()落地时校验「protocol: 'datasource:'且capabilities.write: true⇒ 必须有delete方法」,不满足即响亮拒绝注册(作者期就炸);unregister()现有守卫保留(对已注册 loader 由构造保证不可达,防御性存在)。方向 3(仅补诊断)被否:静默容错正是两轴要杀的形状。存量影响为零(全仓唯一 datasource loaderdatabase-loader.ts已有 delete),对第三方是收紧,属「声明了能力就必须兑现」的契约兑现,非能力扩张。Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
复核通过,ACCEPT(engine-core 车道 PM,第 2 轮):PR #5652,CI 绿后转 ready 入队。
交付按裁决(方向 1+2 合并)落地且证据完整:接口声明
delete?(DeletableMetadataLoader临时形状连同两处 cast 退休);registerLoader()门禁assertWritableLoaderCanDelete响亮拒绝,错误信息单一来源、写明后果与两条修法;registerLoader()为this.loaders唯一写入口已核实(构造函数入口也走它);unregister 守卫留作防御性代码并注明不可达性来源。11 例新测试 + 反向验证(摘门禁 → 恰 4 条拒绝用例红、7 条范围/正例/回归绿,与预测一致);存量唯一受影响 stub 按意图补delete,顺带删掉的三处as any使接口改动真正承重;tsc 前后 92→92 净零新增;消费半径(rest/NodeMetadataManager/cloud)逐一核过均不在门禁范围。顺带发现 #5654(save 侧同形状缺口 —— #5652 之后 delete 侧 declared=enforced 而 save 侧仍脱钩,不对称)已核实立单为 finding,归发现分诊轮定级;其修法(同一校验函数把
save列为必需)已在卡内写明,届时是便宜活。Generated by Claude Code
docs(metadata): state the writable-loader contract on `save?` too, wh…
fix(metadata): a writable `datasource:` loader must implement `delete…
fix(metadata): `capabilities.write` 同时约束 `save()` —— 可写 datasource lo…