Skip to content

[Decision] Two SOURCE registrars disagree on a view container whose data.name differs from the derived key — one silently rewrites the author's field, the other hard-fails the whole artifact load #14666

Description

@os-musk

Found while implementing #14399 (PR #14665), which moved the ObjectQL boot loop onto the shared deriveViewContainerObject. Filed unassigned; not fixed there — it is a different site, and picking a direction is a decision rather than a mechanical repair. Re-measured by the triage seat at origin/main 75adf11 (2026-09-02, R+105), after PR #14665 landed.

The reading

Both SOURCE registrars derive the same binding for an aggregated view container. They do not agree on what to do when the container's own name field disagrees with that derived key.

  • packages/objectql/src/engine.ts:5149 registerMetadataCollections reconciles it, silently:
    const toRegister = item.name === itemName ? item : { ...item, name: itemName };
    The stored document's name is overwritten with the derived object key. The author's field is discarded without a diagnostic.

  • packages/metadata/src/plugin.ts:1127 _parseAndRegisterArtifact does not: it calls this.manager.register('view', viewObject, item, { notify: false }) with the item unchanged, so assertMetadataRegisterContract (packages/core/src/metadata-service-contract.ts:170-172, MetadataFacade answers three registerget round-trip cases differently from every other shipped IMetadataService #7378 row 1) refuses:

    IMetadataService.register('view', 'crm_lead'): data.name is 'lead_views', which disagrees with
    the name argument 'crm_lead'. ... (#7378 row 1: refuse loudly, locate the mismatch).
    

    VALIDATION_ERROR / 400, thrown out of _parseAndRegisterArtifact — the entry the boot artifact load and the HMR reload share, so the refusal is a hard failure of the whole artifact, not of the one container.

Reachable divergence

For { name: 'lead_views', object: 'crm_lead', list: { … } } — schema-legal at both doors, since ViewSchema declares an optional name (packages/spec/src/ui/view.zod.ts:3603):

  • through the boot loop: registers under crm_lead, with the document's name silently rewritten from lead_views to crm_lead;
  • through the artifact/HMR door: nothing registers and the artifact load throws.

Same document, two SOURCE registrars, and the outcomes are not merely different — one is a silent authorial rewrite and the other is a boot failure.

The candidate directions

  1. Make the artifact door reconcile too (mirror the boot loop's toRegister line). Cheap and makes both registrars accept the shape — but it spreads a SILENT rewrite of an author-written field, which is what MetadataFacade answers three registerget round-trip cases differently from every other shipped IMetadataService #7378 row 1 exists to refuse.
  2. Make the boot loop refuse too, matching MetadataFacade answers three registerget round-trip cases differently from every other shipped IMetadataService #7378 row 1. Consistent with the standing ruling and the safer default for AI-authored metadata, but it turns a currently-silent path into a loud one, and the boot loop's toRegister line predates the ruling and serves the ordinary no-name container as well — narrowing it correctly is not mechanical.
  3. Refuse ViewSchema.name outright for object-scoped containers, since its own .describe() says "for an object-scoped container it is the object name" — a convention no door enforces. This one moves a published packages/spec contract.

✅ Now pinned on main (R+104's caveat is discharged)

packages/objectql/src/view-container-divergent-name-registrars.test.ts has landed with PR #14665. It pins the current behaviour of both sides, including the refusal's code/status, so neither door can move without a red. Nothing asserts which direction is right — that is still what this card asks.

Nothing shipped moves today

Of the 54 non-test sources that author or carry view containers, zero declare a name that differs from the object they bind to. The latent form is what is bad — an author who writes one gets a silent rewrite or a boot failure depending on how their package is loaded.

<!-- os-decision-facets -->

四棱分析(分诊席出具,供裁决)

① 长期健全性(权重 ≥50%,主导本建议)—— 指向 2。
最深的缺陷不是「哪个方向对」,而是两个 SOURCE 注册器对同一份文档给出相反结局。无论裁哪个方向,两扇门必须收敛 —— 否则「这份元数据合不合法」取决于它是被 boot 还是被 artifact/HMR 加载的,而作者看不见这个区别。方向 1 尤其要小心:#7378 row 1 已是既有裁决,其原话就是「resolving it silently in either direction can file the item under a key the caller never wrote」。方向 1 把那条裁决明令拒绝的东西复制到第二扇门,是在既有裁决上开倒车。

② 真实业务拉力 —— 零,但潜伏形状是真的。
54 个非测试来源里 0 个声明了发散的 name。没有用户在等这个。⭐ 但也正因为零,方向 2 今天不会弄坏任何东西 —— 这是「把静默路径改响」通常要付而这里恰好不必付的代价。真正的成本在未来:第一个写出发散 name 的作者,拿到的是静默改写还是启动失败,取决于加载路径,从他的视角看是不确定的。

③ 防 AI 编码错误 —— 强烈指向 2。
name: 'lead_views' 恰恰是 AI 作者会写出来的东西:一个像样的、人类风格的容器名。静默改写意味着他永远学不到这条约定;响亮拒绝会指名不匹配的两个值并定位它 —— 那正是 #7378 row 1 的设计意图。⚠️ 附带一条:当前的发散还意味着穿一扇门的测试证明不了另一扇门的行为;PR #14665 的钉子已落地,两边现在都钉住了当前行为,但没有任何断言说哪个方向是对的

④ 创业阶段不增殖 —— 指向 2,反对 3。
方向 2 不新增概念,只是把一条已有裁决贯彻到第二处。方向 3 要在 packages/spec 加一条新的 schema 规则和一类新的拒绝,并动已发布契约 —— 那是本轴要避免的增殖,且触人类底线(published contract)。方向 1 不增殖但会固化「发散 name 会怎样」的两种拼法。

建议:2 —— 让 boot 循环也按 #7378 row 1 拒绝;⛔ 收敛必须只针对发散的情形,不得波及普通的无 name 容器(那正是卡面说「narrowing it correctly is not mechanical」的地方,也是实施的主要风险点)。

回退: 若维护者判断 boot 循环的静默对某条普通路径是承重的,则退为「两扇门都接受,但发散时记一条 warn 并指名两个值」—— 保留可加载性,同时终结无声改写。⛔ 三者之中唯一不可接受的是维持现状:两扇门对同一份文档给出相反结局,是本卡真正要消灭的东西。

⚠️ 置信缺口(必录): 我只测了本仓的 54 个非测试来源。没有测下游 —— objectui / hotcrm / 各 examples 是否有作者写出发散 name,我读不到(跨仓)。若下游存在这样的文档,方向 2 会把它们从「静默通过」变成「启动失败」,那就不再是零代价。裁决前应由 repo:objectui 与 hotcrm 侧各跑一次同样的计数。方向 3 是否可行同样取决于这个数字。

Related: #14399 · PR #14665(已落地)· #7378 · #13912

Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions