Skip to content

datasource 账本判定的 20 条死键至今无人处置:三个块整块无人读,其中 readOnly 让一个 shipped 示例的「只读副本」可写(ADR-0049 enforce-or-remove) #4583

Description

@os-zhuang

#4487 把 datasource 纳入活性账本时判了 43 条,其中 20 条 dead,并给每一条写了 authorHint。那次交付的是判定;处置没有人接,至今没有 issue 跟踪。这条就是那个位置。

对比一下同类死键的处置都是一簇一条 issue 立的 —— #3207(object.enable.trash/mru)、#4484(IDataDriver.findStream)、#4579(openApi31)、#4391(crypto.hash)。datasource 这 20 条是全仓最大的一块,也是唯一 20 条全部带 authorWarn 的(全平台 75 条 dead 里只有 29 条带告警),却是唯一没立项的。

处置方向已经定了:全部 remove

20 条的 authorHint 无一例外都是 "Delete it"。这不是本 issue 新做的判断,是 #4487 逐条闭合调用图后的结论 —— 没有一条走 enforce 路线,因为每一条都有一个已经在工作的、不同的机制:

簇 死键 真正被强制执行的是什么
capabilities.* 11 条(整块) 引擎的下推判断读的是运行时 driver 自己的 supports.*(objectql/src/engine.ts:3671, :4529, :4810),和 datasource 元数据是两套机制
retryPolicy.* 4 条(整块) 连接失败走 datasource-connection-service.ts 的启动策略(降级启动 / bootCritical fail-fast),不按计划重试
healthCheck.* 3 条(整块) 连接存活按需探测:driver handle 的 ping() / checkHealth()(contracts/datasource-driver-factory.ts:88-92)
external.label / external.requirePermission 2 条 前者用顶层 label;后者的访问控制走普通对象权限集 + RLS

三个块会整块清空,这比「20 个键」更干净:DatasourceCapabilities(11/11 死)、healthCheck(3/3 死)、retryPolicy(4/4 死)作为 schema 块整体消失,external 块留下但少两个键。

最严重的一条:readOnly 已经在 shipped 示例里造成了它声称要防的那个 bug

examples/app-crm/src/datasources/crm.datasource.ts 里有一个叫 CRM Analytics Read Replica 的 datasource:

export const CrmAnalyticsDatasource = defineDatasource({
  name: 'crm_analytics',
  label: 'CRM Analytics Read Replica',
  driver: 'sqlite',
  config: { filename: ':memory:' },
  capabilities: { readOnly: true },   // ← 没有任何写路径读它
  active: true,
});

它上面的注释自己写着(#4410 留下的):

readOnly 是 datasource 能力,不是 sqlite 配置。它本来在 config 里,直到 #4410 给那个槽加了闸门 —— 一个没有驱动读的键,于是「读副本」是可写的,而每一个信号都说它不是。

搬家没有修好它。 #4410 把 readOnly 从 config(无闸门)挪到 capabilities(有 schema 闸门但仍无人读),注释描述的那个 bug 原封不动地活到今天:这个「只读副本」现在照样可写。

而且这里有个真实的能力缺口,不能靠改处方绕过

账本的 authorHint 给的替代是 external.allowWrites: false(objectql/src/engine.ts 的 assertWriteAllowed)。但读一下那个闸门:

const ds = this.datasourceDefs.get(dsName);
// No recorded definition, or an explicitly managed one ⇒ allow.
if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') return;

它只对 external / 联邦 datasource 生效。 crm 那个是本地 sqlite、没声明 schemaMode ⇒ 落进 managed 默认 ⇒ 闸门在第 1904 行直接放行。

也就是说:对一个 managed datasource,目前根本不存在任何被强制执行的只读机制。capabilities.readOnly 是唯一长得像的那个,而它什么也不做。

删掉这个键是对的(它是假合规),但要同时诚实地承认:这不是把作者指向另一个键就能了结的,那个键对他的场景不适用。建议本 issue 只做删除 + 把示例改成不再声称只读,并另立一条 feature issue 讨论 managed datasource 要不要有只读闸门 —— 参照 #4479(readReplicas 移除后另立读写分离立项)的先例,不要在退役 PR 里顺手发明一个机制。

影响半径(已实测)

  • DatasourceCapabilities 全仓引用只有 datasource.zod.ts 自己两处(:370 driver-definition、:544 datasource)+ 它自己的 datasource.test.ts。零运行时消费者,判定可复核。
  • 唯一的 authoring 站点是上面那个 app-crm 示例(capabilities:)。healthCheck / retryPolicy 无人 authoring。
  • 手写文档没有教这三个块(content/docs/data-modeling/*.mdx 零命中)。healthCheck / retryPolicy 在 docs 里的命中全是别的类型的生成参考页(model-registry、plugin-rest-api、events-queue/bus、startup-orchestrator)—— 就是 活性账本覆盖 worklist:9 个已注册 metadata type 仍未治理(#4487 建立闸门后的剩余债务) #4488 点名的「同名不同类型」陷阱,不要照着改。
  • DatasourceCapabilitiesType 是导出类型,在公共 API 面上 ⇒ check:api-surface 会参与。

路线:strict-removal(账本行删除,不是墓碑)

三个块全部是 .strict()(datasource.zod.ts:427/551/568),按 .claude/skills/spec-property-retirement §2 走删除 + *_RETIRED_KEY_GUIDANCE 处方路线 —— 键离开 walked shape,所以账本行要一并删掉(留着会被 orphans.mts 判 ORPHAN)。反过来(留墓碑却删账本行)会判 UNCLASSIFIED。两个方向都会红。

DatasourceCapabilities 整块消失时,driver-definition 上那处 capabilities(:370)需要单独决定:它不在本账本治理范围内(账本治的是 datasource),但用的是同一个 schema。判它之前先闭合它自己的调用图,不要因为 datasource 侧判死就顺手判死。

建议的批次(可并行认领)

批 内容 备注
A capabilities.* 11 条 + DatasourceCapabilities 整块 + app-crm 示例 含安全形状,优先
B retryPolicy.* 4 条(整块) 注意别碰 hook.retryPolicy / job.retryPolicy,那两个是被强制执行的,且拼写不同(backoffMs vs baseDelayMs)—— #4488 点名的最险的一个坑
C healthCheck.* 3 条(整块) 同名不同类型命中 20 处,没有一处属于 datasource
D external.label / external.requirePermission 2 条 块留下,只删两个键

每批一个 changeset,FROM → TO 写清楚。批 A 的 changeset 必须明说:删除 readOnly 不等于给了替代方案,managed datasource 的只读能力是另一条 issue。

验收

  • 四个簇的键从 schema 消失,*_RETIRED_KEY_GUIDANCE 给出处方
  • packages/spec/liveness/datasource.json 对应行删除(不是改状态),README 计数按现有口径重算
  • check:liveness / check:api-surface / check:generated / 全仓 build + test 绿
  • app-crm 示例不再声称自己只读
  • CLI lint-liveness-properties 的 datasource 相关期望更新(20 条告警归零)

参考

Activity

  1. self-assigned this
    on Aug 2, 2026
  2. added a commit that references this issue on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    四批全部完成,datasource 死键 20 → 0。

    批 内容 PR
    A capabilities 11 键 + 整块(含 DriverDefinition.capabilities) #4601 ✅
    B/C/D retryPolicy ×4、healthCheck ×3、external.label / external.requirePermission #4629 ✅

    全部走 remove,理由是同一条

    九个(+11 个)键没有一个是「用得少」——是每一个都已经有另一套 live 机制在做它看起来在配置的那件事。下推判断读运行时 driver 自己的 supports.*;连接失败归启动策略(降级启动 / bootCritical);存活是 driver handle 的 ping() / checkHealth() 按需探测;联邦显示名是顶层 label;联邦访问由普通对象权限集 + RLS 管。

    对面都已经有人了,所以没有可建的桥。这和同批 #4509 里 email_template 走 enforce 是同一个判据的两侧:看的是「这个形状能不能承载这个功能」,不是「它是不是死的」。

    两处值得记下来的

    capabilities.readOnly(批 A) —— 它读起来像安全属性,也确实被当作安全属性写:showcase 里一个标着 "CRM Analytics Read Replica" 的 datasource 就靠它声称只读,而它照常接受写入。这个键已经被搬过两次(#4410 移出 config,#4465 移进 capabilities),每个地址上都是惰性的。这次是删掉,而不是搬第三次。同时改掉了所有 SQL driver 共享的 READ_ONLY_BELONGS_ON_DATASOURCE —— 它当时仍在把作者指向这个键。

    retryPolicy(批 B) —— 报错刻意不提供改名建议。hook.retryPolicy / job.retryPolicy 是真被强制执行的,但它们是另一个类型上的另一个键,延迟拼作 backoffMs 而非 baseDelayMs。而这个拼写不一致本身就是「没人读 datasource 那个」的证据——全仓没有代码同时读两种拼法。有测试钉住报错文案必须同时包含 backoffMs 与 hook/job:一条把作者引向另一个类型同名键的处方,比没有处方更糟。

    刻意没做的事 → #4584

    删掉 readOnly 并没有给作者替代方案。external.allowWrites: false 是唯一被强制执行的写闸门,但 assertWriteAllowed 对 managed(或未声明 schemaMode)的 datasource 直接放行——本地库目前根本没有只读闸门。

    按 #4479 的先例,退役 PR 不就地发明机制:这个缺口立为 #4584,并在墓碑文案里明说它不适用于哪种情况、建议改用数据库账号 GRANT。把作者从一个惰性键指向另一个惰性键,只是把本次要退役的缺陷洗一遍再发出去。

    沿途修补

    关闭。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions