Skip to content

register() 对「声明 capabilities.write 却没有 save 方法」的 datasource loader 同样静默跳过 —— #5276 只堵了 delete 一侧,save 一侧的同形缺口还在 #5654

Closed

Description

@os-zhuang

发现于 #5276(PR #5652)实现期,不在该单的裁决范围内(PM 明确把方向 1+2 收在 delete 上),故单独立卡。当前无人踩到,按 observation-class 归档:挂 finding,不挂 pm:queue,留给分诊决定。

事实

packages/metadata/src/metadata-manager.ts 的 register() 持久化循环:

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

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 5, 2026
  2. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    发现分诊:晋级 —— 摘 finding 换 pm:queue,domain:engine-core 维持(落点 packages/metadata/src/metadata-manager.ts → 域表 metadata* 行)。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 6, 2026
  4. self-assigned this
    on Aug 6, 2026
  5. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 3 轮(engine-core 车道)
    会话:session_01V7WetGmnfoXNn8cLieKKmx
    分支:claude/issue-5654-loader-save-contract
    Worktree: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

  6. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 评审(会话 session_01V7WetGmnfoXNn8cLieKKmx):PR #5761 ACCEPT,附一条追加指令,完成后待 CI 绿放行。

    已读全量 diff。要点:

    1. 门禁形状对:assertWritableLoaderContract 以 WRITABLE_LOADER_METHODS 过滤缺失集,一次抛出、缺哪个点哪个;两个都缺时 implements neither ... 且修法列两个签名。消息与 fix(metadata): 声明了 capabilities.write 的 datasource loader 必须实现 delete(),注册期响亮拒绝 (#5276) #5652 同族(后果 + 两条修法),且 delete-only 路径的文本逐字保留、有测试钉住(not.toContain('save()'))。
    2. :665 短路的处置正确:按「注册期已保证才可简化」的裁决,dev 证明了 registerLoader() 是 loaders map 唯一写入口后,把短路从「策略」降级为「类型收窄」而非删除(接口上 save? 对门禁外协议仍合法缺席),读取顺序调成先策略后收窄,与 unregister() 一字不差地镜像。这是比删除更对的答案。
    3. 测试面完整:两条注册入口都拒、无半注册、消息 toBe(builder) 引用同一份文本、正向用例钉「重启耐久性」(同 store 新建 manager)、作用域组合(只读 datasource + 四种其他协议)、DatabaseLoader 回归。
    4. 反向验证的偏差处理加分:预测 6 红实测 5 红,第 6 条钉的是 delete-only 消息不变,本就不该红——如实记录并解释,而不是把预测改成 5。
    5. 改名不触及包 barrel,已发布 API 面无变化;changeset 写明 FROM→TO 与影响面(仅第三方自写 loader)。

    追加指令(裁决正文留手项):补 loaders/loader-interface.ts 的 TSDoc——save? 上补一段与 delete? 对称的「对 protocol: 'datasource:' + capabilities.write: true 而言是强制的,注册期由 assertWritableLoaderContract 强制(#5654)」;delete? 上那段 #5276 的说明同步指向加宽后的门禁(两半、一个 gate)。纯注释、单文件、无签名变化,文件面扩展由本裁决背书。追加一次提交即可,PR 正文补一行说明。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions