@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,201 @@
1
+ /**
2
+ * M4 consent & notification acceptance checks (the M4 spec).
3
+ *
4
+ * - confirm without approval: policy=confirm holds BEFORE any disk side
5
+ * effect — nothing is staged, not just nothing promoted.
6
+ * - consent binds a SPECIFIC version: the continuation after approval
7
+ * installs exactly the version that was offered; if the publisher moved
8
+ * on, the continuation refuses instead of installing whatever is
9
+ * current now. Consent is to a version, never to "the upgrade".
10
+ * - notify-only: the notification carries the version that is ACTUALLY
11
+ * installable, and nothing is staged.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import * as path from "node:path";
15
+ import { promises as fs } from "node:fs";
16
+ import { type ToothContext } from "../teeth/registry.ts";
17
+ import { FakeServer } from "../fake-server/server.ts";
18
+ import { runCommand } from "../artifact-factory/run.ts";
19
+ import { buildSwapTool, coreUpgraderUrl, readState, serveRelease } from "./m1.ts";
20
+ import { CLI_TOOL_SOURCE } from "../../../examples/swap-tool/source.ts";
21
+ import { ArtifactFactory } from "../artifact-factory/factory.ts";
22
+ import { currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
23
+
24
+ function swapEnv(ctx: ToothContext, baseUrl: string, servers: FakeServer[], extra: Record<string, string> = {}): Record<string, string> {
25
+ return {
26
+ K_RELEASE_BASE: baseUrl,
27
+ K_STATE_DIR: path.join(ctx.sandboxDir, "state"),
28
+ K_CORE_UPGRADER: coreUpgraderUrl(),
29
+ ...extra,
30
+ };
31
+ }
32
+
33
+ /** Assert the stateDir has NO transaction side effects at all (not staged). */
34
+ async function assertZeroDiskSideEffects(ctx: ToothContext): Promise<void> {
35
+ const dir = path.join(ctx.sandboxDir, "state");
36
+ let exists = true;
37
+ try {
38
+ await fs.access(dir);
39
+ } catch {
40
+ exists = false;
41
+ }
42
+ if (!exists) return;
43
+ await assert.rejects(fs.access(path.join(dir, "journal.jsonl")), "journal must not exist");
44
+ await assert.rejects(fs.access(path.join(dir, "slots")), "slots must not exist");
45
+ await assert.rejects(fs.access(path.join(dir, "incoming")), "incoming must not exist");
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // m4.confirm-no-consent-zero-side-effects
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export async function checkM4ConfirmNoConsentZeroSideEffects(
53
+ ctx: ToothContext,
54
+ opts: { policy?: "confirm" | "auto" } = {},
55
+ ): Promise<void> {
56
+ const binPath = await buildSwapTool(ctx);
57
+ const target = await serveRelease(ctx, {
58
+ version: "2.0.0",
59
+ behavior: "ok",
60
+ name: "target",
61
+ source: CLI_TOOL_SOURCE,
62
+ });
63
+ try {
64
+ const env = swapEnv(ctx, target.url, [target], { K_POLICY: opts.policy ?? "confirm" });
65
+ const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
66
+ assert.equal(
67
+ up.code,
68
+ 0,
69
+ `confirm-held must exit 0 (${up.stderr.trim()}; auto mutation => RED)`,
70
+ );
71
+ assert.match(up.stdout, /held: policy requires confirmation/, "confirm must hold, not install");
72
+ assert.match(
73
+ up.stderr,
74
+ /notify confirm-request v2\.0\.0/,
75
+ "a confirm-request must name the offered version",
76
+ );
77
+ // the whole point: nothing was staged, not just nothing promoted
78
+ await assertZeroDiskSideEffects(ctx);
79
+ } finally {
80
+ await target.stop();
81
+ }
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // m4.consent-binds-version
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export async function checkM4ConsentBindsVersion(
89
+ ctx: ToothContext,
90
+ opts: { serverSwitched?: boolean; expectInstalled?: boolean } = {},
91
+ ): Promise<void> {
92
+ const binPath = await buildSwapTool(ctx);
93
+ const target = await serveRelease(ctx, {
94
+ version: "2.0.0",
95
+ behavior: "ok",
96
+ name: "target",
97
+ source: CLI_TOOL_SOURCE,
98
+ });
99
+ try {
100
+ const env = swapEnv(ctx, target.url, [target], { K_POLICY: "confirm" });
101
+
102
+ // 1) offer: confirm-request names 2.0.0
103
+ const offer = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
104
+ assert.equal(offer.code, 0, `offer must hold (${offer.stderr.trim()})`);
105
+ assert.match(offer.stdout, /held: policy requires confirmation/);
106
+ assert.match(offer.stderr, /notify confirm-request v2\.0\.0/);
107
+
108
+ // 2) the publisher moves on (or not) before the user answers
109
+ if (opts.serverSwitched) {
110
+ // the SAME server now serves 3.0.0 — the continuation must refuse the
111
+ // approved 2.0.0, not silently install whatever is current
112
+ // 3.0.0 must be a REAL, RUNNABLE release. Publishing garbage bytes here
113
+ // made this tooth pass for the wrong reason: with version binding
114
+ // removed, the continuation DID fetch 3.0.0 -- it just could not run, so
115
+ // the probe failed and nothing was installed, which is exactly what the
116
+ // oracle checks. The tooth then proves "the payload was unrunnable", not
117
+ // "consent is bound to a version". (Found in review by removing the
118
+ // binding and watching this stay green.)
119
+ const switchedFactory = new ArtifactFactory({
120
+ cacheDir: path.join(ctx.sandboxDir, "cache-switched"),
121
+ demoSource: CLI_TOOL_SOURCE,
122
+ });
123
+ await switchedFactory.makeRelease({
124
+ version: "3.0.0",
125
+ behavior: "ok",
126
+ store: target.store,
127
+ platform: currentPlatformKey(),
128
+ });
129
+ try {
130
+ const continueRun = await runCommand(binPath, ["confirm", "upgrade", "2.0.0"], {
131
+ env,
132
+ timeoutMs: 30000,
133
+ });
134
+ const v = await versionOf(binPath);
135
+ const st = await readState(env);
136
+ const nothingInstalled = v === "1.0.0" && st.stableVersion === "0.0.0";
137
+ if (opts.expectInstalled === true) {
138
+ // mutation expectation: the binding was lost and the continuation
139
+ // installed something — the zero-install assertion must go RED
140
+ assert.ok(!nothingInstalled, "the binding must have installed the offered version (expectInstalled => RED)");
141
+ } else {
142
+ assert.ok(nothingInstalled, "the continuation must refuse when the approved version is no longer served");
143
+ assert.match(continueRun.stdout, /held/, "the refusal must be a typed held, not a silent install");
144
+ assert.match(continueRun.stdout, /no longer served|nothing was installed/);
145
+ }
146
+ return;
147
+ } finally {
148
+ // nothing extra to stop: the switched release lives on `target`
149
+ }
150
+ }
151
+
152
+ // 3) green: the continuation installs EXACTLY the offered version
153
+ const cont = await runCommand(binPath, ["confirm", "upgrade", "2.0.0"], {
154
+ env,
155
+ timeoutMs: 30000,
156
+ });
157
+ assert.equal(cont.code, 0, `continuation must exit 0 (${cont.stderr.trim()})`);
158
+ assert.match(cont.stdout, /upgraded to 2\.0\.0/);
159
+ assert.equal(await versionOf(binPath), "2.0.0", "the continuation must install the OFFERED version");
160
+ const st = await readState(env);
161
+ assert.equal(st.stableVersion, "2.0.0", "stable must hold the approved version");
162
+ assert.equal(st.phase, "promoted");
163
+ } finally {
164
+ await target.stop();
165
+ }
166
+ }
167
+
168
+ async function versionOf(binPath: string): Promise<string> {
169
+ const v = await runCommand(binPath, ["--version"], { timeoutMs: 30000 });
170
+ assert.equal(v.code, 0, `version command failed (${v.stderr.trim()})`);
171
+ return v.stdout.trim();
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // m4.notify-only-reports-installable-version
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export async function checkM4NotifyOnlyReportsInstallableVersion(
179
+ ctx: ToothContext,
180
+ opts: { policy?: "notify-only" | "confirm" } = {},
181
+ ): Promise<void> {
182
+ const binPath = await buildSwapTool(ctx);
183
+ const target = await serveRelease(ctx, {
184
+ version: "2.0.0",
185
+ behavior: "ok",
186
+ name: "target",
187
+ source: CLI_TOOL_SOURCE,
188
+ });
189
+ try {
190
+ const env = swapEnv(ctx, target.url, [target], { K_POLICY: opts.policy ?? "notify-only" });
191
+ const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
192
+ assert.equal(up.code, 0, `notify-only must exit 0 (${up.stderr.trim()}; confirm mutation => RED)`);
193
+ assert.match(up.stdout, /held: policy is notify-only/, "notify-only must hold, not install");
194
+ // the notification must name the version that is ACTUALLY installable
195
+ assert.match(up.stderr, /notify held v2\.0\.0: notify-only/);
196
+ await assertZeroDiskSideEffects(ctx);
197
+ } finally {
198
+ await target.stop();
199
+ }
200
+ }
201
+
@@ -0,0 +1,239 @@
1
+ /**
2
+ * M5 platform-surface / lifecycle-convergence acceptance checks
3
+ * (test-plan M5 rows: 点名面读回一致;面在 allowlist 才可作证;未注册面被引用
4
+ * ⇒ 拒; fail-closed 退役序; 禁投影).
5
+ *
6
+ * These drive createUpgrader in-process (the library plane — the converge
7
+ * machinery is exactly the kind of internal tooth the black-box plane
8
+ * cannot reach).
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import * as path from "node:path";
12
+ import { type ToothContext } from "../teeth/registry.ts";
13
+ import { createUpgrader, type CreateUpgraderOptions } from "../../../core/src/createUpgrader.ts";
14
+ import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
15
+ import {
16
+ buildSurfaceAllowlist,
17
+ readAllowlisted,
18
+ evaluateLifecycleConvergence,
19
+ } from "../../../core/src/converge/lifecycle.ts";
20
+ import type { ReadbackSurface } from "../../../core/src/converge/predicates.ts";
21
+ import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
22
+ import { slotArtifactPath } from "../../../core/src/txn/fileEffects.ts";
23
+ import { FakeServer } from "../fake-server/server.ts";
24
+ import { ArtifactFactory } from "../artifact-factory/factory.ts";
25
+
26
+ function stateDir(ctx: ToothContext): string {
27
+ return path.join(ctx.sandboxDir, "state");
28
+ }
29
+
30
+ /** A host whose probe reports the started slot's version (swap-like). */
31
+ function simpleHost(): HostAdapter {
32
+ let version = "0.0.0";
33
+ let startId = "init";
34
+ return {
35
+ async quiesce() {},
36
+ async stop() {},
37
+ async start(slot: Slot) {
38
+ version = slot === "experiment" ? "2.0.0" : "1.0.0";
39
+ startId = `${slot}-${Math.random().toString(36).slice(2)}`;
40
+ },
41
+ async healthProbe(): Promise<ProcessEvidence> {
42
+ return { version, pid: process.pid, startId };
43
+ },
44
+ async resume() {},
45
+ };
46
+ }
47
+
48
+ function makeSurface(id: string, value: () => Promise<string>): ReadbackSurface {
49
+ return { id, read: async () => ({ value: await value(), source: id }) };
50
+ }
51
+
52
+ async function serveTarget(ctx: ToothContext): Promise<FakeServer> {
53
+ const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
54
+ await server.start();
55
+ const factory = new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache") });
56
+ await factory.makeRelease({
57
+ version: "2.0.0",
58
+ behavior: "ok",
59
+ store: server.store,
60
+ platform: (await import("../../../core/src/artifact/staticManifestSource.ts")).currentPlatformKey(),
61
+ });
62
+ return server;
63
+ }
64
+
65
+ function makeUpgrader(
66
+ ctx: ToothContext,
67
+ server: FakeServer,
68
+ surfaces: ReadbackSurface[],
69
+ ): ReturnType<typeof createUpgrader> {
70
+ const opts: CreateUpgraderOptions = {
71
+ host: simpleHost(),
72
+ source: staticManifestSource({ baseUrl: server.url }),
73
+ policy: "auto",
74
+ notificationSink: async () => {},
75
+ stateDir: stateDir(ctx),
76
+ lifecycleSurfaces: surfaces,
77
+ };
78
+ return createUpgrader(opts);
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // m5.lifecycle-surface-allowlist
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export async function checkM5LifecycleSurfaceAllowlist(
86
+ ctx: ToothContext,
87
+ opts: { acceptUnknown?: boolean } = {},
88
+ ): Promise<void> {
89
+ const allowlist = buildSurfaceAllowlist([
90
+ { surface: makeSurface("test.autostart", async () => "registered-path"), expectedTarget: "registered-path" },
91
+ ]);
92
+ if (opts.acceptUnknown) {
93
+ // mutation: an unknown surface id is accepted as evidence — the
94
+ // refusal assertion must go RED
95
+ assert.ok(
96
+ (await readAllowlisted(allowlist, "not-declared")).value.length >= 0,
97
+ "unregistered surface must be refused (acceptUnknown => RED)",
98
+ );
99
+ return;
100
+ }
101
+ // an id not on the allowlist is a typed refusal (未注册面被引用 ⇒ 拒)
102
+ await assert.rejects(readAllowlisted(allowlist, "not-declared"), /UNREGISTERED_SURFACE/);
103
+
104
+ // and the evaluator refuses when a declared surface cannot be read
105
+ const broken = await evaluateLifecycleConvergence(
106
+ buildSurfaceAllowlist([
107
+ {
108
+ surface: makeSurface("broken.autostart", async () => {
109
+ throw new Error("surface cannot be read on this machine");
110
+ }),
111
+ expectedTarget: "x",
112
+ },
113
+ ]),
114
+ 0,
115
+ );
116
+ assert.equal(broken.passed, false, "an unreadable surface cannot vouch for convergence");
117
+ assert.match(broken.source, /broken\.autostart/);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // m5.lifecycle-converged-promotes
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export async function checkM5LifecycleConvergedPromotes(
125
+ ctx: ToothContext,
126
+ opts: { staleSurface?: boolean } = {},
127
+ ): Promise<void> {
128
+ const server = await serveTarget(ctx);
129
+ try {
130
+ const dir = stateDir(ctx);
131
+ const target = slotArtifactPath(dir, "experiment");
132
+ const stale = path.join(dir, "slots", "stable", "artifact.bin");
133
+ const surface = opts.staleSurface
134
+ ? { id: "test.autostart", read: async () => ({ value: stale, source: "test.autostart" }) }
135
+ : { id: "test.autostart", read: async () => ({ value: target, source: "test.autostart" }) };
136
+ const upgrader = makeUpgrader(ctx, server, [surface]);
137
+ const outcome = await upgrader.upgrade();
138
+ if (opts.staleSurface) {
139
+ // mutation: the surface reads back the OLD path — the predicate must
140
+ // refuse, so the promote assertion goes RED
141
+ assert.equal(
142
+ outcome.result,
143
+ "promoted",
144
+ "convergence requires the surface to read back the new artifact (staleSurface => RED)",
145
+ );
146
+ return;
147
+ }
148
+ assert.equal(outcome.result, "promoted", "a converged surface must allow the promote");
149
+ const lifecycle = outcome.report?.hostLifecycleConverged ?? null;
150
+ assert.ok(lifecycle, "this app DID declare a surface, so the report must carry a verdict");
151
+ assert.equal(lifecycle.passed, true, "the report must carry the real converged predicate");
152
+ assert.equal(lifecycle.source, "test.autostart");
153
+ assert.equal(outcome.report?.binaryAtTarget.passed, true);
154
+ } finally {
155
+ await server.stop();
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // m5.lifecycle-projection-ban
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export async function checkM5LifecycleProjectionBan(
164
+ ctx: ToothContext,
165
+ opts: { acceptProjection?: boolean } = {},
166
+ ): Promise<void> {
167
+ // projection: the surface value is the VERSION string (metadata), not the
168
+ // artifact path — it must never satisfy host_lifecycle_converged
169
+ const projection = makeSurface("test.autostart", async () => "2.0.0");
170
+ const result = await evaluateLifecycleConvergence(
171
+ buildSurfaceAllowlist([
172
+ { surface: projection, expectedTarget: slotArtifactPath(stateDir(ctx), "experiment") },
173
+ ]),
174
+ 0,
175
+ );
176
+ if (opts.acceptProjection) {
177
+ // mutation expectation: the projection IS accepted as convergence — the
178
+ // real evaluator refuses it, so this assertion must go RED
179
+ assert.equal(result.passed, true, "metadata must not green the predicate (acceptProjection => RED)");
180
+ return;
181
+ }
182
+ assert.equal(result.passed, false, "a version string is not lifecycle evidence (projection ban)");
183
+ assert.match(JSON.stringify(result.detail), /does not reference the installed artifact path/);
184
+
185
+ // end to end: the upgrade with a projecting surface refuses to promote
186
+ const server = await serveTarget(ctx);
187
+ try {
188
+ const upgrader = makeUpgrader(ctx, server, [projection]);
189
+ const outcome = await upgrader.upgrade();
190
+ assert.notEqual(outcome.result, "promoted", "a projecting surface must block the promote");
191
+ } finally {
192
+ await server.stop();
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // m5.lifecycle-fail-closed-retirement
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export async function checkM5LifecycleFailClosedRetirement(
201
+ ctx: ToothContext,
202
+ opts: { retireUnconditionally?: boolean } = {},
203
+ ): Promise<void> {
204
+ const server = await serveTarget(ctx);
205
+ try {
206
+ // the surface reads back the OLD path: convergence fails
207
+ const stale = path.join(stateDir(ctx), "slots", "stable", "artifact.bin");
208
+ const surface = { id: "test.autostart", read: async () => ({ value: stale, source: "test.autostart" }) };
209
+ const upgrader = makeUpgrader(ctx, server, [surface]);
210
+ const outcome = await upgrader.upgrade();
211
+ assert.notEqual(outcome.result, "promoted", "the non-converged upgrade must not promote");
212
+
213
+ const retire = await upgrader.retireLegacyManager();
214
+ if (opts.retireUnconditionally) {
215
+ // mutation: retirement ignores convergence — the HOLD assertion goes RED
216
+ assert.equal(retire, "retired", "retirement before convergence must be a HOLD (unconditional => RED)");
217
+ return;
218
+ }
219
+ assert.notEqual(retire, "retired", "retirement before convergence is a typed HOLD");
220
+ assert.match((retire as { held: string }).held, /before host_lifecycle_converged passed/);
221
+
222
+ // after a CONVERGED promote, retirement is allowed
223
+ const goodServer = await serveTarget(ctx);
224
+ try {
225
+ const dir = stateDir(ctx);
226
+ const target = slotArtifactPath(dir, "experiment");
227
+ const goodSurface = { id: "test.autostart", read: async () => ({ value: target, source: "test.autostart" }) };
228
+ const up2 = makeUpgrader(ctx, goodServer, [goodSurface]);
229
+ const good = await up2.upgrade();
230
+ assert.equal(good.result, "promoted");
231
+ assert.equal(await up2.retireLegacyManager(), "retired", "post-convergence retirement is allowed");
232
+ } finally {
233
+ await goodServer.stop();
234
+ }
235
+ } finally {
236
+ await server.stop();
237
+ }
238
+ }
239
+