发现于 #5276(PR #5652)实现期,**不在该单的裁决范围内**(PM 明确把方向 1+2 收在 `delete` 上),故单独立卡。**当前无人踩到**,按 observation-class 归档:挂 `finding`,不挂 `pm:queue`,留给分诊决定。 ## 事实 `packages/metadata/src/metadata-manager.ts` 的 `register()` 持久化循环: ```ts for (const loader of this.loaders.values()) { if (loader.save && loader.contract.protocol === 'datasource:' && loader.contract.capabilities.write) { await loader.save(type, name, data); } } ``` `loader.save &&` 排在最前面:一个声明了 `protocol: 'datasource:'` + `capabilities.write: true` 却**没有 `save` 方法**的 loader,会被**一声不吭地跳过**——没有 warn,没有 error,什么都没有。随后 `register()` 照常写内存 registry、失效 listCache、广播 `created`/`updated` 事件并 `notifyWatchers`,调用方被告知写入成功。 这与 #5276 描述的 delete 侧缺口是**同一个形状**:`capabilities.write` 声明了能力,执行期却在方法缺席时静默降级,而不是拒绝。区别只在于失败方向——delete 侧丢的是删除,save 侧丢的是持久化(内存 registry 仍有,所以本进程内读得到,重启后消失)。按 AGENTS.md「Degradation log levels」的判据,这属于耐久性/一致性降级(系统看起来一切正常,声称已持久化的东西没落地),连 `warn` 都不该是,应该是拒绝或 `error`。 ## #5652 之后的不对称 #5276 的修复(PR #5652)在 `registerLoader()` 上加了门禁:`protocol: 'datasource:'` 且 `capabilities.write: true` ⇒ 必须实现 `delete()`,否则响亮拒绝注册。门禁刻意只覆盖 delete 一侧(PM 裁决的范围),于是现在的状态是: - 同一个 `capabilities.write` 声明,**delete 侧是 declared = enforced**(注册期就炸); - **save 侧仍是 declared ≠ enforced**(注册通过,写入静默丢失)。 如果要收口,最小改动就是在 `registerLoader()` 的同一个校验函数里把 `save` 也列为必需方法(`assertWritableLoaderCanDelete` 相应改名),错误信息同风格。**但这会再次收紧公共接口 `MetadataLoader`**(第三方 loader 的实现面),按仓规范不该由实现方擅自决定,所以只填卡。 ## 为什么现在没人踩到 全仓 `protocol: 'datasource:'` 的 loader 只有 `packages/metadata/src/loaders/database-loader.ts`,它 `save` 和 `delete` 都有。仓内其余 loader 都不是 `datasource:` 协议(`MemoryLoader` = `memory:`、`FilesystemLoader` = `file:`、`RemoteLoader` = `http:`),`register()` 从不写它们。所以今天任何用户路径都走不到这个静默分支;它是给第三方/未来 loader 和「AI 照着接口写一个新 loader」留的坑——`save?` 在接口上是可选的,不实现完全合法,而声明 `write: true` 时(在 #5652 之后)只有 `delete` 会被拦下。 严重度不由我判(objectstack#4949:填卡时判的严重度两个方向都不可靠),这里只把事实与范围写清楚。 相关:#5276(delete 侧,已由 PR #5652 关闭)、#5259(unregister 的失效/删除顺序与失败上报)。 发现于会话 `session_01V7WetGmnfoXNn8cLieKKmx`;未认领,留给分诊。
Activity
os-zhuang commentedon Aug 5, 2026
发现分诊:晋级 —— 摘
finding换pm:queue,domain:engine-core维持(落点packages/metadata/src/metadata-manager.ts→ 域表 metadata* 行)。capabilities.write声明与执行面不一致属 restore-invariant 类(step 0 的 auto-queue 判据),且同一接口、同一声明的 delete 侧门禁刚由MetadataLoader不声明delete?:一个capabilities.write的 datasource loader 若没有 delete 方法,unregister()会静默跳过它并照常宣告「已删除」 #5276 → PR fix(metadata): 声明了capabilities.write的 datasource loader 必须实现delete(),注册期响亮拒绝 (#5276) #5652 合入 —— save 侧是同一裁决形状的补全,不是新的契约方向:错误信息、校验位置(assertWritableLoaderCanDelete,宜相应改名)与 fix(metadata): 声明了capabilities.write的 datasource loader 必须实现delete(),注册期响亮拒绝 (#5276) #5652 同款即可。立卡人顾虑的「收紧公共接口MetadataLoader」在 delete 侧已被接受为先例;维护者如认为 save 侧应另裁,可否决本晋级(否决窗口照旧)。assertWritableLoaderCanDelete(metadata-manager.ts:129)只查delete,:665 的loader.save &&静默跳过仍在,前提成立。assertEngineDeleteDispatch下沉 metadata-core)、unregister()先失效 listCache 再删 loader:删除落库前到达的并发 list() 会把「已删项」重新缓存满 30s,之后没有任何东西再失效它 #5259(unregister 顺序)同包相邻,engine-core 车道选批时注意文件面串行。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 6, 2026
认领:PM 循环第 3 轮(engine-core 车道)
会话:
session_01V7WetGmnfoXNn8cLieKKmx分支:
claude/issue-5654-loader-save-contractWorktree:
objectstack-issue-5654域:
domain:engine-core文件面:
packages/metadata/src/metadata-manager.ts(同一校验函数扩 save,相应改名)、metadata 包内测试、.changeset/*.md。⛔ 不碰loader-interface.ts之外的接口面变更(save? 已声明,无需接口改动)、不碰 #5619 的下沉面(metadata-core)。方向确认(PM,同 #5276 先例的补全,restore-invariant 不升级):
registerLoader()校验扩为「datasource: 且 write ⇒ 必须有save与delete」,响亮拒绝,错误信息同 #5652 风格(写明缺哪个、后果、两条修法);register 持久化循环里loader.save &&的静默跳过随之成为注册期不可达,保留为防御性代码并注明。Generated by Claude Code
os-zhuang commentedon Aug 6, 2026
PM 评审(会话 session_01V7WetGmnfoXNn8cLieKKmx):PR #5761 ACCEPT,附一条追加指令,完成后待 CI 绿放行。
已读全量 diff。要点:
assertWritableLoaderContract以WRITABLE_LOADER_METHODS过滤缺失集,一次抛出、缺哪个点哪个;两个都缺时implements neither ...且修法列两个签名。消息与 fix(metadata): 声明了capabilities.write的 datasource loader 必须实现delete(),注册期响亮拒绝 (#5276) #5652 同族(后果 + 两条修法),且 delete-only 路径的文本逐字保留、有测试钉住(not.toContain('save()'))。:665短路的处置正确:按「注册期已保证才可简化」的裁决,dev 证明了registerLoader()是 loaders map 唯一写入口后,把短路从「策略」降级为「类型收窄」而非删除(接口上save?对门禁外协议仍合法缺席),读取顺序调成先策略后收窄,与unregister()一字不差地镜像。这是比删除更对的答案。toBe(builder)引用同一份文本、正向用例钉「重启耐久性」(同 store 新建 manager)、作用域组合(只读 datasource + 四种其他协议)、DatabaseLoader回归。追加指令(裁决正文留手项):补
loaders/loader-interface.ts的 TSDoc——save?上补一段与delete?对称的「对protocol: 'datasource:'+capabilities.write: true而言是强制的,注册期由assertWritableLoaderContract强制(#5654)」;delete?上那段 #5276 的说明同步指向加宽后的门禁(两半、一个 gate)。纯注释、单文件、无签名变化,文件面扩展由本裁决背书。追加一次提交即可,PR 正文补一行说明。Generated by Claude Code
docs(metadata): state the writable-loader contract on `save?` too, wh…
fix(metadata): `capabilities.write` 同时约束 `save()` —— 可写 datasource lo…