Skip to content

metadata-protocol: the three recovery doors run no ADR-0094 mutation projector — a rollback restores the row and leaves the derived read-model on the rolled-back-FROM state #14415

Description

@os-musk

Found while implementing #14179 (the same three doors' missing onMetadataMutation emit). Out of that card's scope — its own body says so explicitly: "ADR-0094 projectors are separate" — so it is recorded here rather than folded in.

Measurement, on origin/main @ 5c9e40ad9

git grep -n "this.runMutationProjector(" -- packages/metadata-protocol/src/protocol.ts returns THREE call sites, and every one of them is a door that also emits:

line enclosing method projector emit
:15200 saveMetaItem yes yes
:16433 runPublishSideEffects yes yes
:20217 deleteMetaItem, repository branch yes yes

The three doors #14179 repairs run NO projector:

  1. rollbackMetaItem — after its registry write-through
  2. revertCommit — both limbs (per-item restore, per-item soft-remove)
  3. deleteMetaItem's legacy raw-engine exit

So the projector coverage and the mutation-event coverage were the SAME hole, at the SAME four call sites, and #14179 repairs only the event half. After that card lands, emitMetadataMutation reaches 7 call sites while runMutationProjector stays at 3.

Consequence

ADR-0094 projectors keep a derived read-model consistent with the metadata row — the shipped example is permission projecting into sys_permission_set, and saveMetaItem's own comment states the contract: "a derived read-model (e.g. sys_permission_set) is already consistent when this save returns".

Roll a permission row back to a previous version and that sentence stops being true: the stored row and the in-memory registry both serve the restored body, while the projected record still holds the rolled-back-FROM state. Same for a commit revert (either limb), and same for a code-only delete on a control-plane bootstrap kernel. The projector is awaited precisely so callers can rely on it, which makes the gap silent rather than eventually-consistent — nothing re-projects until an unrelated save/publish/delete on the same name, or a rebuild.

Note the delete side already has the right shape to copy: deleteMetaItem's repository branch calls the projector with state: 'deleted' and its comment says "a delete may retire the derived record OR reset it to the artifact baseline — the projector re-reads the layered state and decides", which is exactly what a revert's soft-remove limb needs too.

Suggested shape

The same one-edit-per-door repair #14179 makes for the event, applied to the projector, at the same four call sites and in the established order (awaited projection BEFORE the fire-and-forget listeners, per saveMetaItem's comment): state: 'active' with the restored body for the two restore doors, state: 'deleted' for the soft-remove limb and the legacy delete exit.

Worth deciding rather than assuming, and the reason this is a card and not a rider: whether a recovery write should project at all is an ADR-0094 question, not a mechanical symmetry argument. Projecting is what makes the read-model honest; not projecting is defensible only if some other seam re-projects, and no such seam was found.

Dedup

Public issue-search sweep on runMutationProjector, projector rollbackMetaItem, ADR-0094 projector revert and runMutationProjector recovery (open issues), with rollbackMetaItem as the positive control returning 12 cards including #13331 and #14179. The only hits are #14179 itself and two PM seat-post cards; no card names this gap.

Related, not a duplicate: #14179 repairs the mutation-event half of the same four call sites.


Triage — the prediction is now the present tense, and one claim is overstated

Re-measured at origin/main d80140c4c7.

#14179 has LANDED — commit bd4096f, "fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point", via PR #14411. So the asymmetry this card forecast is live on main today rather than prospective: runMutationProjector still has exactly three call sites (:15223, :16456, :20240 — the lines drifted, the census did not), while emitMetadataMutation now appears 8 times in the same file.

⭐ Correction — the seam the card could not find is in ADR-0094 itself.

The card's closing argument is:

not projecting is defensible only if some other seam re-projects, and no such seam was found.

ADR-0094 D3 is that seam, and D2 names it as the designed healing path:

Boot reconciliation + one-time backfill. At kernel:ready the projection is re-derived from metadata (metadata wins)…

A projector failure is surfaced on the write's response … and logged — never thrown, the metadata write itself succeeded and boot reconciliation heals on next start.

So the stale read-model is bounded, not permanent: it heals at the next kernel start. The card half-knew this — its own sentence ends "…or a rebuild" — but filed it as an afterthought rather than as the ADR's stated answer to exactly this staleness. That moves the exposure from "silently wrong forever" to "silently wrong until restart", which is a real defect on a long-lived process and a materially smaller one than the card argues.

And the card is right that this is an ADR question. ADR-0094 D2 enumerates the doors explicitly — saveMetaItem (active saves), publishMetaItem, deleteMetaItem — and the recovery doors are simply not in that list. Extending the projector to them amends D2's enumeration. That is above the seat, which is why this grades as a decision rather than as the mechanical mirror of #14179.

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

  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— ADR-0094 的核心不变量是一句话:「投影器是这条记录的唯一写者」。它点名了三扇门。而恢复类的门(回滚、还原提交、legacy 删除出口)改的是同一行元数据,却不在名单里 —— 同一个不变量,一半的门守着、一半的门不守。长远终态只有两种自洽形态:名单覆盖所有会改这行元数据的门,或者 ADR 明说恢复类的门有意不投影、由启动重建兜底。①指向把名单补全,而不是让「哪些门算数」这件事继续靠读代码才知道。
  • ② 实际业务拉动 —— 未量到有客户撞上,而且陈旧是有界的:D3 的启动重建会在下次 kernel:ready 把投影重新推导出来(元数据为准)。所以这不是「永久错」,是「撑到下次重启」。低拉动。
  • ③ 防 AI 犯错 —— 最响的一棱:把一个 permission 回滚到上一版,元数据行和内存注册表都给出还原后的内容,派生记录却停在回滚前的状态,没有任何报错 —— 运维以为权限已经回滚了,实际派生面还是旧的。⚠️ 但要诚实:metadata-protocol: rollbackMetaItem / revertCommit registry write-throughs never emit onMetadataMutation — boot-cached rebinds and (post-#13331) cluster fan-out miss recovery writes #14179 落地后事件面已经在这四个点广播了,订阅者能看见「写发生过」;缺的是被 await 的投影,不是全无信号。
  • ④ 创业阶段不扩散 —— 补全名单不引入任何新概念:同一个已注册的钩子,在四个已存在的调用点各跑一次。不是 declare-and-maintain,是让已声明的东西在它本就该生效的地方生效。

推荐:A —— 把 D2 的门名单扩到恢复类的三扇门,按卡面给出的形状(两扇还原门 state: 'active' + 还原后的 body;软删除限与 legacy 删除出口 state: 'deleted',awaited 投影排在 fire-and-forget 监听之前),并同批修订 ADR-0094 D2 的枚举 —— ⛔ 只改代码不改 ADR,就是把下一个读 ADR 的人送进同一个坑。①③同向,④不反对,②虽低但 A 的代价同样低。
回退:B —— 不扩,改为在 ADR-0094 里明写恢复类的门有意不投影,由 D3 启动重建兜底,并把「重启前派生记录可能陈旧」写进这三扇门的文档与响应。若维护者认为 await 投影只属于正常写路径、恢复是运维动作、重启兜底已经够,走这条 —— 它同样关掉①的不一致,只是往另一个方向。
置信缺口(本分析看不见什么): 没有量长驻进程实际多久重启一次 —— 那决定 ② 的「有界陈旧」是几分钟还是几周,也就决定 B 的真实代价。另外目前只有 permission 一个投影器注册在案(plugin-security),这个缺口会随将来注册的类型数放大;将来会有几个,本轮没量。

Generated by Claude Code

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