@botiverse/k-carrier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. package/package.json +43 -0
@@ -0,0 +1,100 @@
1
+ # K (k-carrier) 测试计划 v1
2
+
3
+ > 跑在 `harness-design.md` 设计的测试框架上;harness 先于功能层(executable-spec 顺序),本计划的每颗齿都进 harness 的 teeth 注册表。
4
+
5
+ 对应 design v1.2 §3 的教义,这里是**可执行计划**:按里程碑排、每格给"测什么 / 怎么算过 / 必须会红的例子(must-red)"。规矩承自 mutation-runner 契约:**每颗齿声明时同时声明它的 must-red;全绿或全红都不发结论;先跑已知红/已知绿自验,harness 自己不合格不准验别人。**
6
+
7
+ 里程碑与 profile 绑定:**每个里程碑的出口 = 对应 example demo 变绿**(没绿 demo 就没那档的支持 claim)。
8
+
9
+ ---
10
+
11
+ ## M0 — harness 自举(先于一切功能层)
12
+
13
+ | 测什么 | 怎么算过 | must-red |
14
+ |---|---|---|
15
+ | fake-host(实现 HostAdapter 的最小假宿主,带可注入故障开关) | 五方法可被编排调用、故障开关能让任一方法定点失败 | 关掉故障开关注入 ⇒ 对应齿必须转绿(证明齿测的是故障不是常态) |
16
+ | fake 静态 server(manifest+工件,认 Range,可篡改) | 正常链路可走通 | 篡改任一字节 ⇒ 下游校验齿红 |
17
+ | **harness 自验**(mutation 契约 §自验承重墙) | 内置已知红/已知绿样例各≥1 + **1 个对抗样例**(结构过 fixture、违真 oracle) | 对抗样例被判 EFFECTIVE ⇒ harness 不上线 |
18
+ | profile 分档执行器 | `--profile swap|daemon|managed` 只跑该档齿集 | cli 档误跑 L2 齿 ⇒ 计划红(档界齿) |
19
+
20
+ ## M1 — L1 事务 + L0 工件(出口:`examples/swap-tool` 绿 = cli 档成立)
21
+
22
+ | 测什么 | 怎么算过 | must-red |
23
+ |---|---|---|
24
+ | 状态机合法迁移 | 7 相全部合法路径逐条走通 | 非法迁移(如 idle→readback)被拒 |
25
+ | **崩溃注入矩阵**(承重) | **迁移边×kill 点由脚本枚举生成**(禁手列);每点 kill -9 → 重启后 = 恢复 stable 或完成迁移 | 任一点出现双跑(两 incarnation 同活)或砖(起不来)⇒ 红;**journal 写后动作前的窗口必须被覆盖** |
26
+ | journal 性质 | append-only、意图先于动作(WAL)、重放幂等 | 乱序/覆写 journal ⇒ 重放拒绝 |
27
+ | 回滚对称性 | rolled-back 后 stable 完整可跑、experiment 槽清空、原因入 journal | 回滚后 experiment 残留可执行 ⇒ 红 |
28
+ | config 同轨 | 配置 experiment/promote/rollback 走同一状态机 | config 绕过状态机直写 ⇒ 红 |
29
+ | L0 校验+原子换 | sha256 不符拒装;换字节原子(半写不可见);Windows 运行中自替换 | 篡改工件 ⇒ 拒;swap 中途 kill ⇒ 旧字节完好 |
30
+ | cli 档端到端 | swap-tool demo:升级→下次运行是新版;`held/rolled-back/up-to-date` 四态出口都可构造 | — |
31
+
32
+ 已落地齿以 `k-harness --list` 为准——本文件**不手抄齿名**(手抄清单是 `--list` 的副本,只会漂向'少列一颗';ratchet 7 只能查'写下来的存在',查不了'该写的没写')。逐颗齿(层/档/must-red/定义位置)直接 `k-harness --list`。 本层判据形状:篡改工件 ⇒ 拒装;原子换(半写不可见、中途 kill 旧字节完好);未知平台/指名版本 ⇒ 拒;cli 档闭环(真升级 → 下次运行新版本 → state promoted);坏版本 ⇒ 自动回滚 + 旧可用 + experiment 清空;下载中途死 ⇒ Range 续传 + 全量验证。**下载层 8 洞**(L0 接入方挖出,每洞一齿):deadline **竞速**而非仅信号(注入不理会 AbortSignal 的 fetch 也必须超时);Rosetta 下 platform key **问硬件**(x64 Node 在 arm64 硬件选 arm64 target,探针只在 darwin+x64 被问);无 resumeDir 进度也必须动(单调收尾到全量);无 body 响应**两臂**(内存 + resume)都报 typed "no readable body"、绝不当作空前缀;静默被限界不是总时长(慢而正常存活、卡死点名 stall);主动放弃的 stall 是 typed DOWNLOAD_FAILED 点名原因;mid-body 的 stall 说 mid-body。
33
+
34
+ ## M2 — L0.5 供应链:**不做**
35
+
36
+ L0.5 已于 2026-08-06 移除(决定:不支持签名)。K 只验完整性(sha256 +
37
+ size),不验来源真实性;原两级签名链、`m2.*` 四颗齿与 harness 的测试密钥链一并
38
+ 删除。留一个没人接的签名接口比没有更糟——接入方会以为来路已经有人管了。理由,
39
+ 以及它与 OS 代码签名的区别,见 `docs/design-v1.md` §L0.5。
40
+
41
+ **防回滚不在这层**:manifest 版本低于当前且非 pinned ⇒ 默认拒,这是 L0 的
42
+ `source-fails-closed` 管的,与签名无关。
43
+
44
+ ## M3 — L2 生命周期 + L3 收敛(出口:`examples/service-daemon` 绿 = daemon 档成立)
45
+
46
+ | 测什么 | 怎么算过 | must-red |
47
+ |---|---|---|
48
+ | **HostAdapter 契约一致性套件**(对任意 adapter 可跑,接入方复用) | quiesce↔resume 状态等价(含 **rolled-back 后 resume**);probe 证据同进程(pid+startId) | resume 后负载状态漂移 ⇒ 红;probe 返回缓存/文件拼的证据 ⇒ 红(换 pid 不换 startId 的假 probe 必须被抓) |
49
+ | 交接顺序 | journal 意图→交接→新进程自证→才清旧,顺序断言 | 颠倒任两步 ⇒ 红 |
50
+ | `binary_at_target` | same-PID probe:version+startId 绑同一 incarnation | 旧进程活着报新版本号 ⇒ 不绿(same-PID 就绪核反假绿) |
51
+ | `host_lifecycle_converged` | 点名面读回一致;**面在 allowlist 才可作证** | 用不可读面(模拟 System Events 类)自称 same-source ⇒ 拒 |
52
+ | **禁投影齿** | version/channel/升级次数灌真值、谓词面造假 ⇒ 必须不绿 | 任一元数据字段能把谓词转绿 ⇒ 红(version⊥state 实测教训) |
53
+ | fail-closed 退役序 | 未过 host_lifecycle_converged 前退旧管理器 ⇒ 拒 + typed HOLD | 强行退役路径存在 ⇒ 红 |
54
+ | ownership 检测 | 受管标记存在 ⇒ `held: managed-elsewhere`(typed、指向管理者) | 受管副本完成自升 ⇒ 红 |
55
+
56
+ 已落地齿以 `k-harness --list` 为准——本文件**不手抄齿名**(手抄清单是 `--list` 的副本,只会漂向'少列一颗';ratchet 7 只能查'写下来的存在',查不了'该写的没写')。逐颗齿(层/档/must-red/定义位置)直接 `k-harness --list`。 本层判据形状:quiesce↔resume 账本逐字节等价(含回滚后 resume);probe 证据**绑定活化身**(探针说谎/报旧 startId ⇒ 红);每开关故障关掉齿必须绿;service 升级两种宿主形状(spawn 自起 / respawn 交给 owner)——真停旧、真起新、旧 pid 验证死、新化身 fresh startId;坏版本 ⇒ 旧版**真的拉回来在跑**(不是槽位回退);卡死 driver ⇒ 宿主调用预算超时 → 锁释放 → successor 凭**证据**(v2 + fresh startId)判交接完成,凭标志不恢复。`host_lifecycle_converged` / 禁投影 / 退役序 → M5 齿。
57
+
58
+ ## M4 — L4 同意与通知
59
+
60
+ | 测什么 | 怎么算过 | must-red |
61
+ |---|---|---|
62
+ | 策略门 | confirm 未答 ⇒ 零副作用;notify-only ⇒ 只通知不动 | confirm 前有任何盘面写 ⇒ 红 |
63
+ | **通知可验齿**(Hipp 判据原样) | 构造真实失败(迁移写失败/readback 不一致)⇒ sink **真收到**结构化事件 | 删通知调用 ⇒ 此齿必须红;"代码调用了通知"但 sink 没收到 ⇒ 红 |
64
+
65
+ 已落地齿以 `k-harness --list` 为准——本文件**不手抄齿名**(手抄清单是 `--list` 的副本,只会漂向'少列一颗';ratchet 7 只能查'写下来的存在',查不了'该写的没写')。逐颗齿(层/档/must-red/定义位置)直接 `k-harness --list`。 本层判据形状:confirm 未答 ⇒ **磁盘零副作用**(无 journal/slots/incoming,不是"没 promote"是"没 staged");同意只装**当初同意的那个版本**(中途服务器换版 ⇒ 拒装,不装"当前版");notify-only 通知带**真能装的那个版本** + 零副作用。
66
+
67
+ ## M5 — platform 适配器 + managed 档(出口:`examples/hosted-service` 绿)
68
+
69
+ | 测什么 | 怎么算过 | must-red |
70
+ |---|---|---|
71
+ | mac/linux/windows 适配器 | 各平台读回面 allowlist 注册齐 + CI 矩阵跑(linux 真跑;mac/win 至少接口级+Testbed 真机轮) | 未注册面被引用 ⇒ 拒 |
72
+ | managed 端到端 | hosted-service demo:带活"会话"的完整升级→会话保留断言→回滚路径同样保留 | 升级后会话丢失/回滚后会话丢失 ⇒ 红 |
73
+ | ownership 迁移场景 | **DEFERRED(v0 只假设官方 installer 安装,不做 deb/RPM 接管)**——PM 装的副本走 ownership 检测 → `held: managed-elsewhere` 即为正确终态(有齿,M3);接管(adopt)留给将来需要时再立项 | —(deferred) |
74
+
75
+ 已落地齿以 `k-harness --list` 为准——本文件**不手抄齿名**(手抄清单是 `--list` 的副本,只会漂向'少列一颗';ratchet 7 只能查'写下来的存在',查不了'该写的没写')。逐颗齿(层/档/must-red/定义位置)直接 `k-harness --list`。 本层判据形状:**面在 allowlist 才可作证**(未注册面被引用 ⇒ typed UNREGISTERED_SURFACE 拒);读回新工件路径才 promote(读回旧路径仍 promote ⇒ 红);**禁投影**——版本串/元数据永远不能绿收敛谓词;**退役序**——未过收敛前 `retireLegacyManager()` 是 typed HOLD(无条件退役 ⇒ 红)。`ConvergenceReport.hostLifecycleConverged` 为 `PredicateResult | null`——**未声明面 = null = 从未被观测 = 不等于通过**(沉默不能当证据花)。
76
+
77
+ ## M6 — L5 drive(可选层,最后)
78
+
79
+ | 测什么 | 怎么算过 | must-red |
80
+ |---|---|---|
81
+ | 远程命令过策略门 | drive 的 stage/promote/rollback 全部经 L4 | drive 绕过 confirm 直接动 ⇒ 红(设备主人永远赢) |
82
+ | 状态上报 | {stable, experiment, 两谓词, 策略} 与本地读回一致 | 上报值可与本地不一致 ⇒ 红 |
83
+ | provenance journal | forward-only 记 reconcile 来路;**"已记录"与 NOT_OBSERVED 机制上不可合并** | 存量机被计入"已记录" ⇒ 红 |
84
+
85
+ **已落地齿**(三包全合,main 296 绿 / 56 齿):以 `k-harness --list` 为准,本文件不手抄(手抄是 `--list` 的副本,只会漂向少列一颗)。本层判据形状:
86
+ - provenance:journal **三态** genesis/observed/unreadable(只有 ENOENT 是 genesis;unreadable 上 append 拒——截断视图绝不能重发 seq);记录 {who, carrier, when, version} **写前**(回滚的 reconcile 也留痕,证明写前);聚合把 genesis 与 NOT_OBSERVED 机械分离("没数据"≠"没记录")。
87
+ - status:机器自报是**读回不是发明**;谓词带**版本戳 join key**(真结论贴错版本比造假更难看出);跨重启持久化("观测过、只是我重启了"≠"从没观测过");读不了 ≠ 从没有(第三态)。
88
+ - drive + 政策门:服务器下发的命令和本地升级走**同一套门**;**ownership 门画在动作性质上**——settle 在飞事务永远允许(在飞 + ownership 翻转必须收敛,不许 held——held 在开了头的机器上是砖),只有"休息态 + managed-elsewhere"的新改装才 typed held(三个终态 idle/promoted/rolled-back 都断言);已 promote 版本的 push-rollback 在 confirm 下必须 HOLD(安全方向是字节安全不是权威);K 自己的 in-transaction 自动回滚**绝不问同意**(配对互相控制)。
89
+
90
+ ## 跨里程碑(一直在跑)
91
+
92
+ - **跨版本矩阵**:`STATE_FORMAT_VERSION` 升档后旧 core 读新状态 ⇒ fail-closed 拒 + 指引;新 core 收养旧布局 ⇒ 无损;混合窗口显式建模。
93
+ - **mutation-runner**(Lincan 工具就绪即接):对本计划全部齿跑变异;换说法+整段删两变体默认;杀不掉先排除"没杀对"再删守卫。
94
+ - **断言纪律标注**:每个测试文件头标 `@invariant` 或 `@baseline(failure-condition: ...)`;CI 检查无标注的 implementation-locking 断言(OS-supervisor 退役设计 二分的机械化)。
95
+ - **真机轮**(Testbed):每里程碑收口跑一轮真机抽样;个人真机仅 consent 后读回抽样。
96
+ - **DST**(§1.45,已实现):`k-harness sim` 在 PR/profile 门跑固定 smoke seeds;`DST Nightly` 从 workflow run id 起跑 50,000 个顺序 seed。每个 journal/slot/host/predicate effect 后跑共享 invariant;失败自动留下 seed + transcript hash + `k-harness sim --seed X --json` 重放命令,并进入失败语料库后转成枚举矩阵固定格。
97
+ - **时间戳锚定断言**(借 Datadog):scenario receipt 的事件断言一律"标记之后发生了 X"(取 marker → assert-since),防旧事件/上一场景残留冒充新证据。
98
+
99
+ ## 完成定义
100
+ 计划本身的验收 = **三个 example demo 全绿 + M0 harness 自验含对抗样例 + 崩溃矩阵零人工枚举**。任何"支持 X"的 README claim 若无对应绿齿,按"没绿 demo 就没 claim"规则视为未支持。
@@ -0,0 +1,20 @@
1
+ # K harness
2
+
3
+ The generic acceptance bed runs the same registered teeth against K, a real
4
+ binary, or an adopter's HostAdapter. It also owns K's deterministic simulator.
5
+
6
+ ```sh
7
+ k-harness --list
8
+ k-harness --profile service
9
+ k-harness sim # fixed PR smoke corpus
10
+ k-harness sim --seed 3737844653 --json # exact replay
11
+ k-harness sim --start-seed 1 --seeds 50000
12
+ ```
13
+
14
+ Simulation uses the real `UpgradeEngine` over an in-memory `TxnEffects` and
15
+ HostAdapter. Every journal, slot, host and predicate effect is a seeded fault
16
+ point. A failure prints its exact replay command and is atomically merged into
17
+ `.k-harness/sim-failures.json`; the nightly workflow uploads that corpus.
18
+
19
+ The simulator covers transaction/convergence logic. It does not replace the
20
+ real-process crash matrix or real-OS test beds.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * adapter.service-probe-* — the evidence checks, for a REAL adopter host.
3
+ *
4
+ * These exist because the contract-subset versions in fake-host/checks.ts are
5
+ * written against the in-process fake host: they call `start("stable")` on a
6
+ * host that invents its slots, and they were gated on the adapter declaring a
7
+ * workload driver (doWork/ledger) — a marker with nothing to do with what they
8
+ * assert. The effect was an inverted incentive: example-host honestly declines
9
+ * to claim session continuity, so it lost the two checks that most directly pin
10
+ * its evidence mapping. An adopter should be checked on what it claims to do,
11
+ * never checked LESS for declining to claim something.
12
+ *
13
+ * So the property is re-asserted here against real slots, seeded by a real
14
+ * upgrade, and gated on being a service host — which is what it actually needs.
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import { type ToothContext } from "../teeth/registry.ts";
18
+ import { serveRelease } from "../artifact/m1.ts";
19
+ import { processAlive } from "../fake-host/daemon.ts";
20
+ import {
21
+ makeUpgrader,
22
+ releaseSourceFor,
23
+ stateDir,
24
+ type ServiceAdapterFactory,
25
+ } from "./serviceChecks.ts";
26
+
27
+ /**
28
+ * The probe must describe the process that is running NOW.
29
+ *
30
+ * Seeded by a real upgrade, then the live incarnation is SIGKILLed behind the
31
+ * adapter's back. A probe that reads a cached value, a version file, or a
32
+ * pidfile it never cross-checks will happily keep reporting the dead
33
+ * incarnation's evidence — and every convergence predicate built on it becomes
34
+ * a statement about a process that no longer exists.
35
+ *
36
+ * That is the un-fakeable part: after the kill there is nothing truthful to
37
+ * say, so the only correct behaviours are to throw or to report a DIFFERENT
38
+ * live process. Returning the old evidence is the failure.
39
+ */
40
+ export async function checkAdapterProbeBindsLiveProcess(
41
+ ctx: ToothContext,
42
+ adapterFactory: ServiceAdapterFactory,
43
+ mutate?: { skipKill?: boolean },
44
+ ): Promise<void> {
45
+ const adapter = adapterFactory(stateDir(ctx));
46
+ const seed = await serveRelease(ctx, {
47
+ version: "1.0.0",
48
+ behavior: "ok",
49
+ name: "adapter-probe",
50
+ source: releaseSourceFor(adapter),
51
+ });
52
+ try {
53
+ const outcome = await makeUpgrader(ctx, adapter, seed.url).upgrade();
54
+ assert.equal(outcome.result, "promoted", `seed upgrade must promote (${outcome.result})`);
55
+
56
+ const live = await adapter.healthProbe();
57
+ assert.equal(live.version, "1.0.0", "probe must report the running slot's version");
58
+ assert.ok(processAlive(live.pid), "probe must report a pid that is actually alive");
59
+ assert.ok(live.startId, "probe must report a startId");
60
+
61
+ // mutation: leave the incarnation alive — the assertion below must then
62
+ // fail, which is what proves the kill (not some unrelated flakiness) is
63
+ // what makes the probe stop answering.
64
+ if (mutate?.skipKill !== true) {
65
+ process.kill(live.pid, "SIGKILL");
66
+ const deadline = Date.now() + 5_000;
67
+ while (processAlive(live.pid) && Date.now() < deadline) {
68
+ await new Promise((r) => { setTimeout(r, 25); });
69
+ }
70
+ assert.ok(!processAlive(live.pid), "the incarnation must actually be dead before probing again");
71
+ }
72
+
73
+ let stale: { version: string; pid: number; startId: string } | null = null;
74
+ try {
75
+ stale = await adapter.healthProbe();
76
+ } catch {
77
+ return; // refused to answer: correct — nothing is running
78
+ }
79
+ assert.notEqual(
80
+ stale.startId,
81
+ live.startId,
82
+ "probe returned the DEAD incarnation's evidence — it is reading a cache, " +
83
+ "a file, or a pid it never verified, not the live process",
84
+ );
85
+ assert.ok(
86
+ processAlive(stale.pid),
87
+ "probe reported a pid that is not alive; evidence must come from a running process",
88
+ );
89
+ } finally {
90
+ await adapter.stop("experiment").catch(() => {});
91
+ await seed.stop();
92
+ }
93
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * adapter.service-release-knob-bites — prove the ADOPTER'S negative controls are real
3
+ * before any check that depends on them is believed.
4
+ *
5
+ * Split out from serviceChecks.ts because it is a different subject: those
6
+ * check the upgrade, this one checks that the way we make an upgrade fail
7
+ * actually makes it fail.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { type ToothContext } from "../teeth/registry.ts";
11
+ import { serveRelease } from "../artifact/m1.ts";
12
+ import { PLAIN_DAEMON_SOURCE } from "../../../examples/service-daemon/source.ts";
13
+ import {
14
+ makeUpgrader,
15
+ releaseSourceFor,
16
+ stateDir,
17
+ type ServiceAdapterFactory,
18
+ } from "./serviceChecks.ts";
19
+
20
+ /**
21
+ * Prove the adopter's release source honours `__K_BEHAVIOR__` before any tooth
22
+ * relies on it.
23
+ *
24
+ * Every negative control here serves a `crash-on-start` release and expects the
25
+ * upgrade to roll back. If the adopter's source ignores the knob, the "bad"
26
+ * release comes up healthy, the upgrade promotes, and the mutation the tooth
27
+ * declares it would catch quietly stops being a mutation at all -- the tooth
28
+ * passes identically whether or not the property holds. A positive control that
29
+ * cannot be shown to fire is not a control.
30
+ *
31
+ * Adopters on the demo daemon are exempt: the demo's knob is pinned by its own
32
+ * teeth, so re-proving it here would only re-test the harness.
33
+ */
34
+ export async function checkAdapterReleaseKnob(
35
+ ctx: ToothContext,
36
+ adapterFactory: ServiceAdapterFactory,
37
+ mutate?: { ignoreKnob?: boolean },
38
+ ): Promise<void> {
39
+ const adapter = adapterFactory(stateDir(ctx));
40
+ const source = releaseSourceFor(adapter);
41
+ if (source === PLAIN_DAEMON_SOURCE) return;
42
+
43
+ const bad = await serveRelease(ctx, {
44
+ version: "2.0.0",
45
+ // mutation: build a healthy release while calling it bad — this is what an
46
+ // adopter who ignores the knob effectively does.
47
+ behavior: mutate?.ignoreKnob === true ? "ok" : "crash-on-start",
48
+ name: "adapter-knob",
49
+ source,
50
+ });
51
+ try {
52
+ const up = makeUpgrader(ctx, adapter, bad.url);
53
+ const outcome = await up.upgrade();
54
+ assert.notEqual(
55
+ outcome.result,
56
+ "promoted",
57
+ "a crash-on-start release of the adopter's app must NOT promote — " +
58
+ "if it does, the source ignores __K_BEHAVIOR__ and every negative " +
59
+ "control in adapter mode is a no-op",
60
+ );
61
+ } finally {
62
+ await adapter.stop("experiment").catch(() => {});
63
+ await bad.stop();
64
+ }
65
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Adapter-mode service checks — the SAME assertions as the service-profile
3
+ * teeth (m3.service-upgrade / m3.service-rollback, m5.lifecycle-converged-
4
+ * promotes), but with the HOST SWAPPED for an external adopter adapter
5
+ * (same teeth, a different host implementation). The teeth use ONLY the five
6
+ * HostAdapter responsibilities + the app-declared lifecycle surfaces — no
7
+ * test backdoors (transparency §1.8).
8
+ *
9
+ * These drive createUpgrader in-process (the library plane) against the
10
+ * adapter's host + a real fake-server release.
11
+ *
12
+ * The release is the ADOPTER'S app when they supply `releaseSource`, and the
13
+ * demo daemon otherwise. That hook is load-bearing, not a convenience: the
14
+ * teeth assert the successor's version through the ADOPTER'S probe, so an
15
+ * adopter whose probe reads its own evidence format (example-host reads its
16
+ * machine-attestation IPC) can never pass against a demo binary. Swapping the
17
+ * host while pinning the artifact only ever accepted adopters that happened to
18
+ * speak the demo's evidence format -- which is not a contract, it is a
19
+ * coincidence.
20
+ */
21
+ import assert from "node:assert/strict";
22
+ import * as path from "node:path";
23
+ import { type ToothContext } from "../teeth/registry.ts";
24
+ import { createUpgrader } from "../../../core/src/createUpgrader.ts";
25
+ import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
26
+ import type { HostAdapter } from "../../../core/src/lifecycle/hostAdapter.ts";
27
+ import type { HostDriver } from "../fake-host/inproc.ts";
28
+ import type { ReadbackSurface } from "../../../core/src/converge/predicates.ts";
29
+ import { slotArtifactPath } from "../../../core/src/txn/fileEffects.ts";
30
+ import { processAlive } from "../fake-host/daemon.ts";
31
+ import { serveRelease } from "../artifact/m1.ts";
32
+ import { PLAIN_DAEMON_SOURCE } from "../../../examples/service-daemon/source.ts";
33
+
34
+ /** The adopter module's factory contract for the service tier. */
35
+ export type ServiceAdapterFactory = (stateDir: string) => HostDriver & {
36
+ lifecycleSurfaces?: () => ReadbackSurface[];
37
+ /**
38
+ * The SOURCE TEXT of a release of the ADOPTER'S app (not a path to it — the
39
+ * artifact factory stamps text, exactly as it does for the demos). The
40
+ * harness substitutes `__K_VERSION__` and `__K_BEHAVIOR__` and serves the
41
+ * result. Omit to be tested against the demo daemon.
42
+ *
43
+ * `__K_BEHAVIOR__` is not optional to honour. Every negative control in
44
+ * this file works by serving a `crash-on-start` release, so a source that
45
+ * ignores the knob turns all of them into no-ops -- the tooth would pass
46
+ * whether or not the property held. checkAdapterReleaseKnob proves the
47
+ * knob bites before any of them is trusted.
48
+ */
49
+ releaseSource?: () => string;
50
+ };
51
+
52
+ export function stateDir(ctx: ToothContext): string {
53
+ return path.join(ctx.sandboxDir, "state");
54
+ }
55
+
56
+ /** The adopter's own app source TEXT when supplied, else the demo daemon. */
57
+ export function releaseSourceFor(adapter: { releaseSource?: () => string }): string {
58
+ return adapter.releaseSource ? adapter.releaseSource() : PLAIN_DAEMON_SOURCE;
59
+ }
60
+
61
+ export function makeUpgrader(ctx: ToothContext, adapter: HostAdapter, baseUrl: string, surfaces?: ReadbackSurface[]) {
62
+ const opts: import("../../../core/src/createUpgrader.ts").CreateUpgraderOptions = {
63
+ host: adapter,
64
+ source: staticManifestSource({ baseUrl }),
65
+ policy: "auto",
66
+ notificationSink: async () => {},
67
+ stateDir: stateDir(ctx),
68
+ };
69
+ if (surfaces !== undefined) opts.lifecycleSurfaces = surfaces;
70
+ return createUpgrader(opts);
71
+ }
72
+
73
+ /** The running successor's evidence, read through the adapter's probe. */
74
+ async function successorEvidence(adapter: HostAdapter) {
75
+ return adapter.healthProbe();
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // adapter.service-upgrade
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export async function checkAdapterServiceUpgrade(
83
+ ctx: ToothContext,
84
+ adapterFactory: ServiceAdapterFactory,
85
+ opts: { serveBadVersion?: boolean } = {},
86
+ ): Promise<void> {
87
+ const adapter = adapterFactory(stateDir(ctx));
88
+ const seed = await serveRelease(ctx, {
89
+ version: "1.0.0",
90
+ behavior: "ok",
91
+ name: "adapter-seed",
92
+ source: releaseSourceFor(adapter),
93
+ });
94
+ const target = await serveRelease(ctx, {
95
+ version: "2.0.0",
96
+ behavior: opts.serveBadVersion ? "crash-on-start" : "ok",
97
+ name: "adapter-target",
98
+ source: releaseSourceFor(adapter),
99
+ });
100
+ try {
101
+ // seed: a real upgrade with the adapter host lands stable 1.0.0
102
+ const seedUp = makeUpgrader(ctx, adapter, seed.url);
103
+ const seeded = await seedUp.upgrade();
104
+ assert.equal(seeded.result, "promoted", `seed upgrade must promote (${seeded.result})`);
105
+
106
+ const old = await successorEvidence(adapter);
107
+ assert.ok(processAlive(old.pid), "the seeded successor must be alive");
108
+
109
+ // the real upgrade: same adapter, new release
110
+ const up = makeUpgrader(ctx, adapter, target.url);
111
+ const outcome = await up.upgrade();
112
+ if (opts.serveBadVersion) {
113
+ // mutation: a bad version — the adapter's probe fails, auto-rollback,
114
+ // so the promote assertion goes RED
115
+ assert.equal(
116
+ outcome.result,
117
+ "promoted",
118
+ "a good upgrade must promote (serveBadVersion mutation => RED)",
119
+ );
120
+ return;
121
+ }
122
+ assert.equal(outcome.result, "promoted", `the adapter-hosted upgrade must promote (${outcome.result})`);
123
+ assert.ok(!processAlive(old.pid), "the OLD incarnation must be verified dead");
124
+ const fresh = await successorEvidence(adapter);
125
+ assert.equal(fresh.version, "2.0.0", "the successor must run the new version");
126
+ assert.ok(processAlive(fresh.pid), "the new successor must be alive");
127
+ assert.notEqual(fresh.startId, old.startId, "the successor is a fresh incarnation (new startId)");
128
+ assert.equal(outcome.report?.binaryAtTarget.passed, true, "the report carries the real predicate");
129
+
130
+ const st = await up.state();
131
+ assert.equal(st.stableVersion, "2.0.0");
132
+ assert.equal(st.phase, "promoted");
133
+ } finally {
134
+ await adapter.stop("experiment").catch(() => {});
135
+ await seed.stop();
136
+ await target.stop();
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // adapter.service-rollback
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export async function checkAdapterServiceRollback(
145
+ ctx: ToothContext,
146
+ adapterFactory: ServiceAdapterFactory,
147
+ opts: { serveGoodVersion?: boolean } = {},
148
+ ): Promise<void> {
149
+ const adapter = adapterFactory(stateDir(ctx));
150
+ const seed = await serveRelease(ctx, {
151
+ version: "1.0.0",
152
+ behavior: "ok",
153
+ name: "adapter-seed",
154
+ source: releaseSourceFor(adapter),
155
+ });
156
+ const target = await serveRelease(ctx, {
157
+ version: "2.0.0",
158
+ behavior: opts.serveGoodVersion ? "ok" : "crash-on-start",
159
+ name: "adapter-target",
160
+ source: releaseSourceFor(adapter),
161
+ });
162
+ try {
163
+ const seedUp = makeUpgrader(ctx, adapter, seed.url);
164
+ const seeded = await seedUp.upgrade();
165
+ assert.equal(seeded.result, "promoted", `seed upgrade must promote (${seeded.result})`);
166
+ const old = await successorEvidence(adapter);
167
+
168
+ const up = makeUpgrader(ctx, adapter, target.url);
169
+ await up.upgrade();
170
+ const st = await up.state();
171
+ if (opts.serveGoodVersion) {
172
+ // mutation: a GOOD version — it promotes, so the rollback expectation
173
+ // goes RED
174
+ assert.equal(st.phase, "rolled-back", "a bad version must roll back (good => RED)");
175
+ return;
176
+ }
177
+ assert.equal(st.phase, "rolled-back", "the adapter-hosted rollback must land rolled-back");
178
+ assert.equal(st.stableVersion, "1.0.0");
179
+ assert.equal(st.experimentVersion, null, "experiment slot must be cleared");
180
+ assert.ok(!processAlive(old.pid), "the pre-upgrade incarnation must stay dead");
181
+ // the old version must be pulled back AND actually running
182
+ const running = await successorEvidence(adapter);
183
+ assert.equal(running.version, "1.0.0", "the pulled-back service must be the old version");
184
+ assert.ok(processAlive(running.pid), "the pulled-back service must be ACTUALLY running");
185
+ } finally {
186
+ await adapter.stop("experiment").catch(() => {});
187
+ await seed.stop();
188
+ await target.stop();
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // adapter.lifecycle-converged
194
+ // ---------------------------------------------------------------------------
195
+
196
+ export async function checkAdapterLifecycleConverged(
197
+ ctx: ToothContext,
198
+ adapterFactory: ServiceAdapterFactory,
199
+ opts: { staleSurface?: boolean } = {},
200
+ ): Promise<void> {
201
+ const adapter = adapterFactory(stateDir(ctx));
202
+ const surfaces = adapter.lifecycleSurfaces?.() ?? [];
203
+ assert.ok(surfaces.length > 0, "the service adapter must declare lifecycle surfaces");
204
+ const surfacesToUse: ReadbackSurface[] = opts.staleSurface
205
+ ? [
206
+ {
207
+ id: "adapter.autostart-stale",
208
+ read: async () => ({
209
+ value: slotArtifactPath(stateDir(ctx), "stable"),
210
+ source: "adapter.autostart-stale",
211
+ }),
212
+ },
213
+ ]
214
+ : surfaces;
215
+
216
+ const seed = await serveRelease(ctx, {
217
+ version: "1.0.0",
218
+ behavior: "ok",
219
+ name: "adapter-seed",
220
+ source: releaseSourceFor(adapter),
221
+ });
222
+ const target = await serveRelease(ctx, {
223
+ version: "2.0.0",
224
+ behavior: "ok",
225
+ name: "adapter-target",
226
+ source: releaseSourceFor(adapter),
227
+ });
228
+ try {
229
+ const seedUp = makeUpgrader(ctx, adapter, seed.url, surfacesToUse);
230
+ const seeded = await seedUp.upgrade();
231
+ assert.equal(seeded.result, "promoted", `seed upgrade must promote (${seeded.result})`);
232
+
233
+ const up = makeUpgrader(ctx, adapter, target.url, surfacesToUse);
234
+ const outcome = await up.upgrade();
235
+ if (opts.staleSurface) {
236
+ // mutation: the surface reads back the OLD path — convergence fails,
237
+ // so the promote assertion goes RED
238
+ assert.equal(
239
+ outcome.result,
240
+ "promoted",
241
+ "convergence requires the surface to read back the new artifact (staleSurface => RED)",
242
+ );
243
+ return;
244
+ }
245
+ assert.equal(outcome.result, "promoted", `the converged upgrade must promote (${outcome.result})`);
246
+ assert.equal(outcome.report?.hostLifecycleConverged?.passed, true, "the report carries the real convergence");
247
+ assert.match(outcome.report?.hostLifecycleConverged?.source ?? "", /adapter\.autostart/);
248
+ // the surface registered the auto-start for the artifact that was
249
+ // promoted (the experiment path the convergence verified)
250
+ const surfaceRead = await surfaces[0]!.read();
251
+ assert.ok(
252
+ surfaceRead.value.includes(slotArtifactPath(stateDir(ctx), "experiment")),
253
+ "the autostart must reference the promoted artifact",
254
+ );
255
+ } finally {
256
+ await adapter.stop("experiment").catch(() => {});
257
+ await seed.stop();
258
+ await target.stop();
259
+ }
260
+ }