11 KiB
ADR-0138:耐久 Plugin Package Lock 与精确本地激活
- 状态:Accepted(完整 PackageLock 原子持久化、双 staging evidence、activation intent、 fresh/recovery coordinator、本地 POSIX publisher、SQLite 端到端组合与 PostgreSQL 四角色持久化验证已完成;ADR-0139 已补 Cluster publisher,ADR-0140 已补调用方驱动 启动恢复、本机 application gate、标准 OCI resolver、Cluster admin process、 独立镜像与 Job/RBAC;ADR-0149 已补原子资源 generation identity/source,具体语义 materializer 与私有 registry 仍未开放)
- 日期:2026-07-24
- 关联 RFC:QL-RFC-0001 D-08、D-09、D-132 至 D-136
- 关联 ADR:ADR-0087、ADR-0134、ADR-0135、ADR-0136、ADR-0137
背景
ADR-0134 至 ADR-0137 已冻结 PackageLock、安装状态机、确定性 staging 和两种数据库 Repository,但实现审计发现三个不能留给产品层修补的缺口:
- Repository 只保存 record JSON 与 lock digest。进程重启虽能判断
resume_stage | resume_activation | inspect_activation,却无法重建被审批的完整 PackageLock; - POSIX staging adapter 返回
receipt.json的真实 SHA-256,而领域stage_completed又生成另一个 receipt digest。后者没有保存前者,激活时无法证明 正在发布的仍是已验签、已 fsync 的字节; - activation receipt 只绑定 generation 和 content。相同 generation/content 可在不同
installation 或不同 previous pointer 之间误重放;
activating的 fresh publish、 response-loss 和 restart recovery 也没有唯一协调器。
这些问题在低配路由设备上不能靠常驻 watcher 或额外数据库解决,在 Cluster 多副本上 更不能靠进程内锁解决。修复必须继续服从 ADR-0087:能力应优先成为现有包的显式 subpath,不能因为一个 adapter 或 coordinator 再创建单文件 workspace package。
决策
1. 完整 PackageLock 与 queued record 原子保存
PluginPackageInstallCreate 必须携带规范化后的完整 PackageLock。Repository create
在同一个数据库 transaction 中保存 lock 与 queued record,并逐项复验
installation、Project、Package、generation、previous active pointer 和 lock digest。
Repository port 新增 findLock(lockDigest)。读取必须重新运行 PackageLock normalizer、
重算 digest,并拒绝 JSON、索引列或 record 之间的任何漂移;未知 digest 返回 null,
corrupt durable fact 返回 unavailable。
- SQLite
0037使用lock_json TEXT NOT NULL,CHECK 固定 JSON object、lock digest、 Project 和 Package; - PostgreSQL
pg-0018使用lock_json jsonb NOT NULL,除同样绑定外还限制 JSON object 和大小; - 两种 adapter 继续由同一 shared repository contract 驱动。
不单建 lock 表:PackageLock 与 installation 同生且不可变,一对一列存储能在不增加 join、表、migration version 或 importer 的前提下保持事务原子性。
2. staging 同时保存领域回执和外部字节证据
PluginPackageStageReceipt 增加 evidenceDigest。领域 receipt digest 继续绑定
lock、artifact、Manifest、content 和 stage reference;evidenceDigest 则精确保存
adapter 可重新检查的外部事实:
- 本地 staging 为 canonical
receipt.json原始字节的 SHA-256; - 未来 OCI/Kubernetes adapter 必须提供同等不可变、可 inspect 的证据摘要。
激活 publisher 必须同时检查领域 stage receipt 和外部 evidence,不能把“状态机曾进入 staged”当作“当前落盘字节仍可信”。
3. activation intent 绑定完整切换事实
ADR-0149 将其升级为 qinglong/plugin-package-activation-intent@v2,除下列切换事实外
还完整携带并摘要绑定受限的 resource generation:
- installation、Project、Package 和 PackageLock;
- target generation 与 exact previous active lock;
- stage reference、领域 stage receipt digest、外部 evidence digest;
- content digest 与 domain-separated intent digest。
activation receipt 必须携带并匹配 intent digest。这样即使 content 和 generation 相同,也不能跨 installation、Project/Package、previous pointer 或 stage evidence 重放。
4. fresh publish 与 recovery inspect 严格分离
PluginPackageActivationCoordinator 只有两条路径:
- fresh:先 CAS 保存
activating,再调用 publisherpublish;得到 exact receipt 后 才 CASactive; - recovery:只调用 publisher
inspect。exact published fact 提交active,not_published提交受审 failure,冲突提交activation_fact_conflict。
publish 抛出 availability 或响应丢失时,durable record 保持 activating,调用者不得
在同一不确定路径重新 publish。terminal exact retry 只返回已保存事实。
PluginPackageInstallationCoordinator 固定
Approved Action consume → durable create/findLock → stage → staged CAS → activation
coordinator 的顺序。Approved Action consumer 是必须由产品层提供的受信、exact-replay、
带 fence capability;当前不提供默认或弱化实现。
5. 本地 publisher 使用单一私有原子 pointer
@qinglong/local-admin/package-activation 只接受两个预创建、当前 UID 拥有、真实
0700 且 device/inode 稳定的独立目录。每次 inspect/publish 都重新验证 authority,
再逐项验证 staging receipt、blob exact set、owner、mode、size 和 digest。
active pointer 名称由 Project/Package 的 domain-separated SHA-256 派生,内容为
canonical intent + receipt,权限固定 0600。ADR-0149 将 pointer 升级为 v2,使资源
generation 与 package pointer 同一次 rename 生效。publication 使用:
- per-pointer
O_CREAT | O_EXCL | O_NOFOLLOW的0600锁; - 写入并 fsync 私有临时文件;
- 同目录 atomic rename;
- fsync activation directory;
- 重新 inspect 最终 pointer。
升级必须匹配 exact previous pointer;stale writer 冲突失败关闭。publisher 记录自己 创建的锁 inode/device,finally 只删除身份仍匹配的 owned lock;若另一 publisher 已持锁 或路径被替换,绝不按文件名删除。
6. 组合继续留在既有包
- runtime-core 增加显式
plugin-package-activation与plugin-package-installationsubpath; - local-admin 增加显式
package-activation与package-installationsubpath; - local-sqlite、cluster-postgres 只扩充原 repository subpath;
- 不新增 workspace package、第三方依赖、timer、watcher、socket、sidecar 或数据库 connection。
本地组合已用 SQLite Repository、真实 staging evidence 和 POSIX publisher 完成端到端 测试,但没有从 package root 或 production application root 导出。
7. Cluster 恢复已组合,产品安装入口继续失败关闭
ADR-0140 已把 allowlisted OCI source resolver、可选 exact-registry credential provider、同一 PostgreSQL lock authority 驱动的 stage verifier、独立 cluster-admin image 与最小权限 Job/RBAC 组合完成。 它仍不开放:
- 具体 Approved Action durable consumer、管理 API/CLI/UI;
- operator repair workflow;
- ADR-0149 已完成的原子 generation identity/source 之上的 Task/Workflow/Prompt/Tool/Trigger 语义 materializer 与旧代 GC;
- object-store stage;
- publisher revoke/index、Runtime/UI Extension 或动态代码加载。
因此“本地端到端组合可执行”不等于 QingLong 3.0 已允许用户安装插件;生产入口必须等 上述 authority、Audit、recovery 与资源语义 materializer 闭环完成后另行评审。
拒绝的方案
- 为 activation、installation coordinator 或每种 publisher 各建 workspace package: 拒绝;这些边界由 subpath 和 dependency audit 足以隔离,拆包只增加 importer、 build、发布和路由设备安装元数据成本。
- 只保存 lock digest,恢复时从 OCI/tag/文件重新生成 lock:拒绝;来源可能不可用, 也无法重建已消费审批和当时环境。
- staging receipt 只保存领域摘要:拒绝;无法复验 adapter 已落盘的具体字节。
activating重启后再次 publish:拒绝;响应丢失时会重复外部副作用。- 用无条件
unlink(lockPath)清理竞争锁:拒绝;文件名不证明所有权。 - 让 local-admin 默认创建 Approved Action consumer:拒绝;会绕过身份、Policy、 Approval ledger 和 Audit 的产品 ceremony。
影响
- edge/standalone 仍使用单 SQLite authority、短 transaction 和调用时 I/O;禁用路径 不加载安装/激活 subpath,常驻成本不增加。
- Cluster Repository 已能耐久恢复完整 lock,ADR-0139 也已提供 ConfigMap publisher; stage verifier、短生命周期 process 和 RBAC 已组合;ADR-0149 已组合原子 resource generation identity/source,但语义 materializer 与 Approved Action 产品入口仍未 组合,默认控制面仍不可安装 Package。
- 两个方言和未来 publisher 共用同一 intent/recovery 语义;数据库与部署差异留在 adapter。
- workspace importer 保持 21 个;本切片没有新增 package 或第三方 dependency,回应 “packages 是否拆得过细”的原则是:单文件能力先并入职责相符的包并用显式 subpath 隔离,只有出现独立发布、独立运行或真正不同依赖生命周期时才考虑拆包。
验证
必须覆盖:
- SQLite/PostgreSQL lock 与 record 原子保存、
findLockexact replay 和 corruption; - shared repository contract 的缺失 lock、head replacement、CAS 和 recovery page;
- staging 外部 evidence 进入 durable receipt 并参与 activation intent;
- fresh activation 在 publish 前已保存
activating; - publish response loss 后只 inspect、不重复 publish;
- external fact absent/conflict 的稳定 failed reason;
- 本地 pointer exact replay 不推进时钟;
- previous pointer replacement 和 stale writer conflict;
- stage blob、receipt、pointer、root/symlink tamper fail-closed;
- 预先存在的另一 publisher lock 不被删除;
- Approved Action → SQLite lock → stage → POSIX active 的端到端组合;
- explicit subpath/root isolation、依赖方向、六 Profile artifact 与 edge import;
- PostgreSQL 四角色最小权限与物理 HA。
当前针对性结果为 runtime-core 224/224、local-sqlite 68/68、local-admin 51/51、
cluster-postgres 非数据库 124 pass/1 条件 skip,以及 PostgreSQL 18 四角色真实集成
28 pass/1 条件 skip。21-package clean build/聚合测试退出 0;dependency/source
boundary 与 edge import 通过且 findings=[];ADR-0140 production wiring 后六种
Profile 全部通过,最小 Profile 仍为 39 loaded modules,最大 application 为
2,849,491 bytes/439 files/78 loaded modules,最大 RSS delta 13,090,816 bytes;
PostgreSQL 18 HA 的 21 个具体 gate 与总 passed 全为 true。