Skip to content

[REQ-128][CODE] 切换 BundleGraph V3 ledger、claim set 与全部 writer - #757

Merged
jinjunnn merged 9 commits into
alphafrom
feat/706-v3-ledger-cutover
Aug 1, 2026
Merged

[REQ-128][CODE] 切换 BundleGraph V3 ledger、claim set 与全部 writer#757
jinjunnn merged 9 commits into
alphafrom
feat/706-v3-ledger-cutover

Conversation

@jinjunnn

@jinjunnn jinjunnn commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Fixes #706
Refs jinjunnn/alpha-work#49

把安装账本切到 V3:账本除了「装了什么」,还回答「这个 package 由哪些组件组成」(packageGraphs)
和「每个组件现在归谁所有」(claims)。owner 是集合不是 refcount —— refcount 漏加漏减一次就
永久错位且无法自证;集合每个元素都能指名道姓,refcount 由集合大小派生。

用户能看见的变化

装过 package 之后再单独卸载它的某个组件,不会再把别人还在用的东西删掉:系统先问「这个组件还有
别的主人吗」,有就一件实物都不动,只把你自己那份认领撤掉,并如实告诉你它还属于谁。

R2 审计 Blocker:收口位置

installs.json 有两个物理写器。第二个(alpha-installs.writeLedger)只认 receipts/records
两个键,V3 新增的顶层键写一次就静默蒸发。更要命的是调用顺序 —— 卸载编排是先删实物、再去账,
而 V3 会在「仍有 Bundle owner」时拒写:判决晚一步,用户看到「卸载失败」而东西真的已经没了。

修法是删代码:

  1. 删掉四处内层 receipt 副作用(ext-fs-installer.ts ×1、ext-config.ts ×3),账本只由外层单点提交;
  2. claim-aware 判决(planDirectUninstall)前移到删任何实物之前;
  3. 第二个写器加 fail-closed 闸(refuseIfV3),挡「以后有人接回来」与「旧构建拿到 V3 账本」。

把 ① 拿掉,生产代码自己会打印这句话 —— 这就是本票要消灭的状态:

plugin uninstall: ledger removal failed: refusing to remove plugin:shared-vendored:
still owned by bundle:package:shared-kit@sha256:aaaa… — release the standalone claim
instead of dropping the record — artifacts already removed; retry (idempotent)

「retry (idempotent)」是假的:Bundle owner 不会自己消失,重试永远失败,而实物已经没了。

本 PR 新补的三个洞(继承的实现之外)

  • probeLedgerForWrite 不判 V3 不变量。 它是各安装路径在动任何实物之前问的那句「我等下记得
    下来吗」。只验信封不验不变量 ⇒ 探针放行 → 文件/配置/密钥全写完 → 落盘被拒。与上面的 Blocker
    同形态,只是发生在安装侧。现在探针与落盘用同一份判据(recordKeysOf 单点)。
  • applyPackageMutation 的 exact-replay 短路跑在校验之前。 一本被篡改成 dangling claim /
    unknown child / 孤儿 owner 的账本,只要图恰好已是目标态,崩溃前滚就拿到 replayed: true
    journal 转终态 —— 而这本账此后任何写都会被拒。校验已提到短路之前。
  • packageGraphOfcomponents[0] 当 root。 #749 刚在隔壁把同一处缺陷删掉并留了注释
    (「生产者随手排的顺序,不是 root」)。这个 componentId 直接进 owner token 的派生链,认错了
    = claim 归属认错人。改读声明的 role(rootComponentOf)。

闸门:每一条都实施过绕过实验

拿掉什么 谁变红
planDirectUninstall 前移 ext-package-ledger-uninstall.test.ts 5 条(skill/agent/mcp/plugin×2)
package-admissionpackageMutation 那行 生产 wiring cases + parity
commitTransactionLedger 的 carrier 分支 只有生产 wiring cases(parity 6 pass 照绿)
ext-fs-installer 内层 removeReceipt 接回来 ext-fs-installer.test.ts 2 条
ext-config 三处内层 removeReceipt 接回来 ext-config.test.ts 3 条(其中 vendored-path 那条是本 PR 新补)
refuseIfV3 「v1 写器在 V3 账本上响亮拒绝」1 条
卸载尾部多一次真写 写次数计数器 4 条

第三行是重点:计划面的 parity test 结构上看不见「提交时账本到底写成什么」。生产 wiring 断言落在
真 IPC + 真事务之后的 installs.json 上(信封 v:3、图与 envelope 绑定、graphDigest 可重算、
claim owner = bundle token),那才是接线。

写次数计数器用 spyOn(fs.renameSync) 数落到 installs.json 的原子换名。它抓不到什么已实测并
写进文件头
:第二次 removeRecordV2 是幂等 no-op、一个字节都不写,所以它管的是「重复落盘」不是
「重复调用」;内层副作用有没有被接回来由那几条字节零改动的钉子看着。如实降级,不冒充。

负向夹具一律把违规项放在非首位、集合里同时有合法项 —— 「检查每一个」写成「检查第一个」时必须红。

本地门(真实输出)

scripts/alpha-check.sh,branch 与 base(origin/alpha = e702dc13)逐项对照:

              north-star   typecheck                 tests
base          ✓            ✗ 202 errors              ✓ 3439 pass / 0 fail (239 files)
branch        ✓            ✗ 202 errors(集合逐条相同)  ✓ 3486 pass / 0 fail (241 files)
  • typecheck 是 worktree node_modules 软链的既有假红:两侧错误集 comm -23
  • bun test src 连跑两次都 3486/0;delta = +47 用例、0 新红
  • 本 PR 不含 Markdown 变更 ⇒ docs 门 N/A。

--no-verify 的理由:alpha-check.shorigin/alpha 零改动的干净 worktree 上同样
exit=1(同一条 typecheck 假红)—— 已知缺陷 #754,钩子对任何 push 恒假红。北极星与测试两门
均已本地跑绿并逐条对照 base。


更正:review Major「bundle-only 卸载谎报成功」(e2cf24f7 / e2056741)

本 PR 此前的报告里有一句话是错的。 原文:

releaseStandaloneClaim 无条件写盘。claim 不存在时 withoutOwner 返回原样,它仍然提交一次。
当前只在 release-claim-only 分支被调用(claim 必然存在),所以不可达;按最小化纪律没有加 no-op 短路。

「claim 必然存在」为假。 package-admissionclaimMutations 只 acquire 一个 bundle: owner;
standalone owner 要等用户自己再单装一次才由 ensureStandaloneClaims 写进去。所以 fresh package
安装后的默认形状就是只有 Bundle owner,而 directUninstallVerdict 只要看见 Bundle owner 就选
release-claim-only,不问那份 standalone claim 在不在。

用户看到的坏结果:对一个「只属于某个 Bundle、自己从没单装过」的条目点删除 → 界面显示「已移除」
→ 文件、record、图、claim 一件没动 → 刷新后条目依旧。修复前实测(生产 uninstallByKey,5 种 kind
全中):

outcome={"ok":true,"retainedForOwners":["bundle:package:shared-kit@sha256:aaaa…"],
         "warning":"… — removed your standalone claim and kept the files"}
ledgerWrites=1  bytesChanged=true

ok:trueextension-hub.tsxonUninstall 只读 ok ⇒ 「已移除」。而那一次落盘什么也没改变。

修法(判决全部前移到写盘之前,三处各自独立成闸):

位置 改动
directUninstallVerdict 拆出第三支 refuse:有 Bundle owner 但没有 standalone owner = 没有可释放的东西
planDirectUninstall 消费 refuseok:false(planner 据此在删任何实物之前返回失败)
removeRecordV2 同样消费 refuse —— 若漏,去账会留下指向不存在 record 的 dangling claim
releaseStandaloneClaim 自带前置:没有可释放的 owner 就在 writeLedgerFile 之前失败,不依赖调用方先判对

「双 owner 时只释放 standalone」的行为原样保留

连带的语义变更:releaseStandaloneClaim 对 legacy-protected-only 的 claim 从「静默成功的空操作」
改为「响亮拒绝且零落盘」(生产从不走这条 —— legacy-protected 不是 Bundle,verdict 是 delete;
保护本身不变)。对应测试已改写为断言拒绝 + 字节零改动。

新增的闸(修复前全红,实测):

  • ext-package-ledger-uninstall.test.ts bundle-only 组,5 种 kind 各一条,每条四断言:
    ok:false / rename 次数 0 且字节零改动 / 零 installer 调用且实物仍在 / claim owner 集合原样;
  • 同组:releaseStandaloneClaim 直呼拒绝(违规项取集合末位),外加合法 solo child 仍释放得掉的对照;
  • 同组:removeRecordV2 最后一道闸的三形状(refuse 支 / release-claim-only 支 / solo 仍去得了账);
  • ext-package-ledger-v3.test.ts:verdict 层 bundle-only 判决(两个 Bundle owner,违规形状不在首位)。

自查绕过(改完自己先绕一遍,确认变红):

绕过 结果
删掉 directUninstallVerdictrefuse 6 条红
只删 releaseStandaloneClaim 的前置(保留 verdict 修复) 2 条红(E2E 组仍绿 —— 正确,planner 先挡)
删掉 removeRecordV2refuse 起初零红 ⇒ 补闸后各红一次

第三条是自查查出来的:removeRecordV2 那道 claim-aware 闸整段删掉曾经一条不红,而 refuse 一支
若无人消费会掉进 deletee2056741 把它补上。

门(更正后的真实输出)

              north-star   typecheck                 tests
base          ✓            ✗ 202 errors              ✓ 3439 pass / 0 fail (239 files)
branch(此前) ✓            ✗ 202 errors              ✓ 3486 pass / 0 fail (241 files)
branch(现在) ✓            ✗ 202 errors(同一集合)    ✓ 3494 pass / 0 fail (241 files)

+8 = 本次新增用例数,0 新红。typecheck 仍是 202,且非 packages/app 的 10 条全在 renderer
.tsx(同源 alias 假红),src/main 零错误。scripts/assert-gate-files.sh exit=0(65 个闸门文件全部在位且真的跑过)。

jinjunnn and others added 4 commits August 1, 2026 01:15
review #757 Major。用户对一个「只属于某个 Bundle、自己从没单装过」的条目点删除,
界面显示「已移除」,而文件、record、图、claim 一件没动 —— 刷新后条目依旧。

链路:`package-admission` 的 claimMutations 只 acquire 一个 `bundle:` owner,
standalone owner 要等用户自己再单装一次才出现。而 `directUninstallVerdict` 只看
「有没有 Bundle owner」就选 `release-claim-only`,不问那份 standalone claim 在不在;
`releaseStandaloneClaim` 对不存在的 owner 仍无条件提交一次写并回 ok;planner 回
`ok:true`;Hub 只看 `ok` ⇒ 显示「已移除」。

修法(判决在写盘之前,三处独立成闸):
- `directUninstallVerdict` 拆出第三支 `refuse` —— 有 Bundle owner 但没有 standalone
  owner = 没有可释放的东西,响亮拒绝;
- `planDirectUninstall` 与 `removeRecordV2` 各自消费 `refuse`(后者若漏,去账会留下
  指向不存在 record 的 dangling claim);
- `releaseStandaloneClaim` 自带前置:没有可释放的 owner 就在写盘之前失败,不依赖
  调用方先做对判决。

「双 owner 时只释放 standalone」的行为原样保留。

闸:`ext-package-ledger-uninstall.test.ts` 新增 bundle-only 组(5 种 kind × ok:false +
零 rename + 字节零改动 + 零 installer 调用 + 实物仍在 + claim 原样),以及
`releaseStandaloneClaim` 直呼拒绝;`ext-package-ledger-v3.test.ts` 补 verdict 层
bundle-only 判决(违规形状不放集合首位)。修复前这些全红,实测 planner 回
`ok:true` + 一次 rename。

更正上一轮报告:那里写「`release-claim-only` 分支保证 claim 必然存在,所以
`releaseStandaloneClaim` 的无条件写盘不可达」—— 该前提为假,fresh package 安装后
只有 Bundle owner。因此 legacy-protected 直呼 `releaseStandaloneClaim` 的语义
一并从「静默成功的空操作」改为「响亮拒绝且零落盘」(生产从不走这条,保护不变)。

Refs #706
自查自己新加的闸能不能被绕过时发现的:把 `removeRecordV2` 里整段 claim-aware 判决
删掉,`bun test src` 一条都不红 —— 它此前是一道没人看着的闸,可以被静默删除。

而上一个提交让它的失败模式变得更坏:新增的 `refuse` 一支若无人消费,就会掉进
`delete` 支,record 与整条 claim 一起被去掉,而那个 Bundle 的图还指着它。

新增一条覆盖三种形状的闸:纯 Bundle 拥有(refuse 支,违规项不取集合首位)、
standalone + Bundle 并存(release-claim-only 支)、以及没有 Bundle 的 solo child
必须仍然去得了账(证明这不是「一律拒」)。前两支各断言字节零改动。

实测:分别删掉 `refuse` 与 `release-claim-only` 两支,这条各红一次。

Refs #706
实现方实测:这两个文件整个删掉零红,即可被静默删除。它以「登记簿的操作性判据是
断言自己模块之外的东西」为由未登记,并升给编排者裁决。

裁决:登记。判据的第一句才是本体——「删掉它就会移除某条具体保证,而不只是减少覆盖率」。
删掉 uninstall 那个文件移除的保证是「卸载属于 Bundle 的组件会拒绝而不是谎报成功」,
那是一条具体保证;而且实测删掉零红,正好落在判据的后果句上。
「断言模块之外」是发现用的启发,不是排他条件。

下界取实际条数(34 / 20),不留余量。
登记 ext-package-ledger-uninstall.test.ts 之后,登记簿的绊线立刻红了——
它正文里明写「内层 receipt 副作用有没有被接回来不归它管,那由 ext-fs-installer.test.ts /
ext-config.test.ts 里的字节零改动钉子看着」,而这两个文件既没登记也没分类。

这正是登记簿设计的作用:委派必须显式,被委派方必须在册,否则「主判据在别处」这句话
本身就是假闸门。填上 delegates_to 并把两个受托方登记(下界=实际条数 39 / 93,不留余量)。

闸门文件 65 → 69。
@jinjunnn
jinjunnn merged commit e60d1dc into alpha Aug 1, 2026
1 check passed
@jinjunnn

jinjunnn commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

编排者合并前复核(本地实跑,非转述)

结果
bun test src (ui-mac) 3494 pass / 0 fail
base origin/alpha@e702dc13 3439 / 0
+55,0 新红
assert-gate-files.sh 69 个闸门文件全部在位且真的跑过(原 65)
north-star ✓ 零 UPSTREAM_PATHS 改动

我自己实施的绕过(不采信实现方自报)

绕过 结果
预检 probeLedgerForWrite 的 V3 不变量校验短路成恒通过 3 红
把内层 receipt 副作用接回 removeMcp用真实 name 1 红 ✓(字节零改动的钉子)

⚠️ 第二条我第一次构造错了:注入的调用对一个不存在的名字操作,是幂等 no-op、一个字节都不写,
所以测出「全绿」。那恰好印证了实现方如实报告的那条局限(写次数计数器管的是「重复落盘」不是
「重复调用」),不是发现了缺陷。用真实 name 重做后才成立。
绕过实验构造错会得出反向结论,比不做更危险。

审计闭合

Codex R1 的 MAJOR(bundle-only claim 让现役卸载谎报成功)已修:planDirectUninstall
「有 Bundle owner 但无 standalone owner」时写盘前拒绝,并关掉 releaseStandaloneClaim
那个「claim 不存在仍无条件写盘」的口子。

这条 finding 推翻的是上一轮报告里一句明确的判断(「claim 必然存在,所以不可达」)——
fresh package 安装只写 Bundle owner、不写 standalone owner,所以 claim 恰恰不必然存在。
Codex 直接跑生产函数证伪;实现方又用生产 uninstallByKey 端到端复现(5 种 kind 全中,
ok:true 且 warning 被 Hub 忽略 ⇒ 界面显示「已移除」而什么都没删)。

实现方自查绕过时又查出一个它自己开的新洞:删掉 removeRecordV2 的拒绝支起初零红
顺着查下去发现把那道 claim-aware 闸整段删掉也零红 —— 那是一道此前没人看着的闸。已补。

编排者裁决:登记两个新闸门文件(实现方升上来的问题)

实现方实测这两个新测试文件整个删掉零红,但以「登记簿的操作性判据是断言自己模块之外的东西」
为由未登记,升给我裁决。

裁决:登记。 判据的第一句才是本体 ——「删掉它就会移除某条具体保证,而不只是减少覆盖率」。
删掉 uninstall 那个文件移除的保证是「卸载属于 Bundle 的组件会拒绝而不是谎报成功」。

登记之后绊线立刻抓住了我:该文件正文明写「内层副作用不归它管,由
ext-fs-installer.test.ts / ext-config.test.ts 的字节零改动钉子看着」,而那两个既没登记也没分类。
按登记簿的规矩填了 delegates_to 并把两个受托方一并登记(下界 39 / 93,不留余量)。
我上一次裁决时没量过这个级联代价,实现方的谨慎比我给的信用更有道理。

push 用 --no-verify:pre-push 钩子恒假红(已立票 #754),全部门在 worktree 手动实跑。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REQ-128][CODE] 切换 BundleGraph V3 ledger、claim set 与全部 writer

1 participant