@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,274 @@
1
+ /**
2
+ * M6 provenance acceptance checks (test-plan M6 rows: provenance journal
3
+ * forward-only; genesis machines are NOT_OBSERVED and never counted as
4
+ * "recorded"; an unreadable/corrupt journal is a THIRD state, never
5
+ * genesis; every reconcile that reaches the transaction records WHO drove
6
+ * it, write-ahead).
7
+ *
8
+ * These drive createUpgrader in-process (the library plane — the journal
9
+ * is exactly the kind of internal tooth the black-box plane cannot reach).
10
+ * Mutation journals/summaries live in m6Mutations.ts (line budget).
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import * as path from "node:path";
14
+ import { promises as fs } from "node:fs";
15
+ import { type ToothContext } from "../teeth/registry.ts";
16
+ import { createUpgrader, type CreateUpgraderOptions } from "../../../core/src/createUpgrader.ts";
17
+ import type { ReleaseSource } from "../../../core/src/artifact/source.ts";
18
+ import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
19
+ import {
20
+ fileProvenanceJournal,
21
+ summarizeProvenance,
22
+ type ProvenanceJournal,
23
+ type ProvenanceEntry,
24
+ } from "../../../core/src/provenance/journal.ts";
25
+ import { systemClock } from "../../../core/src/clock.ts";
26
+ import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
27
+ import { serveRelease } from "./m1.ts";
28
+ import type { FakeServer } from "../fake-server/server.ts";
29
+ import {
30
+ reuseSeqJournal,
31
+ acceptingJournal,
32
+ keepTornJournal,
33
+ appendOnCorruptJournal,
34
+ emptyAsGenesisJournal,
35
+ mergeUnreadableSummary,
36
+ promoteOnlyJournal,
37
+ } from "./m6Mutations.ts";
38
+
39
+ export function stateDir(ctx: ToothContext): string {
40
+ return path.join(ctx.sandboxDir, "state");
41
+ }
42
+
43
+ function provenanceFile(ctx: ToothContext): string {
44
+ return path.join(stateDir(ctx), "provenance", "provenance.jsonl");
45
+ }
46
+
47
+ /** A host whose probe reports the experiment's (configured) version. */
48
+ export function hostReporting(experimentVersion: string): HostAdapter {
49
+ let version = "1.0.0";
50
+ let startId = "init";
51
+ return {
52
+ async quiesce() {},
53
+ async stop() {},
54
+ async start(slot: Slot) {
55
+ version = slot === "experiment" ? experimentVersion : "1.0.0";
56
+ startId = `${slot}-${Math.random().toString(36).slice(2)}`;
57
+ },
58
+ async healthProbe(): Promise<ProcessEvidence> {
59
+ return { version, pid: process.pid, startId };
60
+ },
61
+ async resume() {},
62
+ };
63
+ }
64
+
65
+ export interface MakeUpgraderOpts {
66
+ policy?: "auto" | "confirm" | "notify-only";
67
+ installOwnership?: () => "self" | "managed-elsewhere";
68
+ notificationSink?: (event: { kind: string; detail: Record<string, string> }) => Promise<void>;
69
+ host?: HostAdapter;
70
+ source?: ReleaseSource;
71
+ }
72
+
73
+ export async function makeUpgrader(
74
+ ctx: ToothContext,
75
+ server: FakeServer,
76
+ journal: ProvenanceJournal | null,
77
+ opts: MakeUpgraderOpts = {},
78
+ ): Promise<ReturnType<typeof createUpgrader>> {
79
+ const base: Omit<CreateUpgraderOptions, "provenance"> = {
80
+ host: opts.host ?? hostReporting("2.0.0"),
81
+ source: opts.source ?? staticManifestSource({ baseUrl: server.url }),
82
+ policy: opts.policy ?? "auto",
83
+ notificationSink: (opts.notificationSink ?? (async () => {})) as CreateUpgraderOptions["notificationSink"],
84
+ stateDir: stateDir(ctx),
85
+ ...(opts.installOwnership === undefined ? {} : { installOwnership: opts.installOwnership }),
86
+ };
87
+ return createUpgrader(journal === null ? base : { ...base, provenance: journal });
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // m6.provenance-forward-only
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export async function checkM6ProvenanceForwardOnly(
95
+ ctx: ToothContext,
96
+ opts: { acceptRewrite?: boolean; keepTorn?: boolean; reuseSeqs?: boolean; appendOnCorrupt?: boolean } = {},
97
+ ): Promise<void> {
98
+ const real = fileProvenanceJournal(path.join(stateDir(ctx), "provenance"), systemClock);
99
+ const journal = opts.acceptRewrite
100
+ ? acceptingJournal()
101
+ : opts.keepTorn
102
+ ? keepTornJournal(real, provenanceFile(ctx))
103
+ : opts.reuseSeqs
104
+ ? reuseSeqJournal()
105
+ : opts.appendOnCorrupt
106
+ ? appendOnCorruptJournal(provenanceFile(ctx), real)
107
+ : real;
108
+
109
+ const e1 = await journal.append({ who: "srv", carrier: "fleet", version: "2.0.0" });
110
+ const e2 = await journal.append({ who: "srv", carrier: "fleet", version: "2.0.0" });
111
+ assert.equal(e1.seq, 0, "first append is seq 0");
112
+ assert.equal(
113
+ e2.seq,
114
+ 1,
115
+ "second append is seq 1 — appends are append-only, history cannot be collapsed (reuseSeqs => RED)",
116
+ );
117
+
118
+ // An overwrite of an existing entry is REFUSED (provenance is not
119
+ // rewritable — a rewrite would let a later actor re-write who did what).
120
+ await assert.rejects(
121
+ journal.append({ who: "srv", carrier: "fleet", version: "2.0.0" }, 0),
122
+ /PROVENANCE_SEQ_REWRITE/,
123
+ "an overwrite of an existing entry must be refused (acceptRewrite => RED)",
124
+ );
125
+
126
+ // A torn final line (crash mid-append) is dropped; earlier lines stand.
127
+ await fs.appendFile(provenanceFile(ctx), '{"seq":2,"who":', "utf8");
128
+ const read = await journal.read();
129
+ assert.equal(read.kind, "observed");
130
+ assert.deepEqual(
131
+ read.entries.map((e) => e.seq),
132
+ [0, 1],
133
+ "appends are forward-only: unique, in order, and a torn final line is dropped (keepTorn => RED)",
134
+ );
135
+
136
+ // A corrupt MIDDLE line makes the history unreadable — and appending to
137
+ // it is REFUSED (a truncated view must never re-issue a seq).
138
+ await fs.writeFile(
139
+ provenanceFile(ctx),
140
+ '{"seq":0,"who":"a","carrier":"c","when":1,"version":"2.0.0"}\n{"seq":1,"BROKEN\n{"seq":2,"who":"b","carrier":"c","when":2,"version":"2.0.0"}\n',
141
+ "utf8",
142
+ );
143
+ const afterCorrupt = await journal.read();
144
+ assert.equal(
145
+ afterCorrupt.kind,
146
+ "unreadable",
147
+ "a corrupt middle line makes the history unreadable, never a short observed one (appendOnCorrupt => RED)",
148
+ );
149
+ await assert.rejects(
150
+ journal.append({ who: "srv", carrier: "fleet", version: "2.0.0" }),
151
+ /PROVENANCE_HISTORY_UNREADABLE/,
152
+ "appending on an unreadable history must be refused — a guessed seq is how history becomes rewritable (appendOnCorrupt => RED)",
153
+ );
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // m6.provenance-genesis-not-observed
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export async function checkM6ProvenanceGenesisNotObserved(
161
+ ctx: ToothContext,
162
+ opts: {
163
+ collapseGenesis?: boolean;
164
+ emptyIsGenesis?: boolean;
165
+ unreadableIsGenesis?: boolean;
166
+ mergeUnreadableIntoNotObserved?: boolean;
167
+ } = {},
168
+ ): Promise<void> {
169
+ const provDir = path.join(stateDir(ctx), "provenance");
170
+ const journal = fileProvenanceJournal(provDir, systemClock);
171
+
172
+ // A machine with NO journal file never had the machinery: GENESIS. Its
173
+ // value is NOT_OBSERVED, not "zero reconciles".
174
+ const genesis = opts.collapseGenesis ? { kind: "observed" as const, entries: [] as ProvenanceEntry[] } : await journal.read();
175
+ assert.equal(
176
+ genesis.kind,
177
+ "genesis",
178
+ "a machine with no journal is genesis — NOT_OBSERVED, never an observed zero (collapseGenesis => RED)",
179
+ );
180
+
181
+ // An empty-but-present journal IS an observed machine: the machinery
182
+ // exists and has reconciled zero times. Distinct from genesis.
183
+ await fs.mkdir(provDir, { recursive: true });
184
+ await fs.writeFile(path.join(provDir, "provenance.jsonl"), "", "utf8");
185
+ const empty = opts.emptyIsGenesis ? await emptyAsGenesisJournal().read() : await journal.read();
186
+ assert.equal(
187
+ empty.kind,
188
+ "observed",
189
+ "an empty-but-present journal is observed-with-zero reconciles, distinct from genesis (emptyIsGenesis => RED)",
190
+ );
191
+
192
+ // A journal that exists but cannot be read (here: the file became a
193
+ // directory, EISDIR) is the THIRD state — NOT genesis, NOT observed.
194
+ await fs.rm(path.join(provDir, "provenance.jsonl"), { force: true });
195
+ await fs.mkdir(path.join(provDir, "provenance.jsonl"));
196
+ const unreadable = opts.unreadableIsGenesis ? { kind: "genesis" as const } : await journal.read();
197
+ assert.equal(
198
+ unreadable.kind,
199
+ "unreadable",
200
+ "an unreadable journal is its own state, never genesis — \"I cannot see the data\" is not \"there is no data\" (unreadableIsGenesis => RED)",
201
+ );
202
+
203
+ // Aggregation keeps the buckets separate: genesis → notObserved, never
204
+ // recorded; unreadable → unreadable, never recorded AND never notObserved.
205
+ const sum = opts.mergeUnreadableIntoNotObserved
206
+ ? mergeUnreadableSummary([genesis, empty, unreadable])
207
+ : summarizeProvenance([genesis, empty, unreadable]);
208
+ assert.equal(sum.notObserved, 1, "the genesis machine is counted in notObserved, never in recorded");
209
+ assert.equal(sum.recorded, 1, "only the observed machine is counted as recorded");
210
+ assert.equal(
211
+ sum.unreadable,
212
+ 1,
213
+ "the unreadable machine is counted in unreadable — a separate bucket (mergeUnreadableIntoNotObserved => RED)",
214
+ );
215
+ assert.equal(sum.reconciles, 0, "zero reconciles stays zero");
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // m6.provenance-records-each-reconcile
220
+ // ---------------------------------------------------------------------------
221
+
222
+ export async function checkM6ProvenanceRecordsEachReconcile(
223
+ ctx: ToothContext,
224
+ opts: { journalOnPromoteOnly?: boolean; skipJournaling?: boolean } = {},
225
+ ): Promise<void> {
226
+ const real = fileProvenanceJournal(path.join(stateDir(ctx), "provenance"), systemClock);
227
+ const deferred = promoteOnlyJournal(real);
228
+ const journal = opts.journalOnPromoteOnly ? deferred : real;
229
+
230
+ const good = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "good" });
231
+ const bad = await serveRelease(ctx, { version: "3.0.0", behavior: "ok", name: "bad" });
232
+ try {
233
+ // Reconcile 1 (good source): promotes.
234
+ const goodUpgrader = await makeUpgrader(ctx, good, opts.skipJournaling ? null : journal);
235
+ const r1 = await goodUpgrader.upgradeTo("2.0.0", {
236
+ consented: true,
237
+ provenance: { who: "fleet-control", carrier: "example-host" },
238
+ });
239
+ assert.equal(r1.result, "promoted", "the good reconcile must promote");
240
+ if (opts.journalOnPromoteOnly) await deferred.flush();
241
+
242
+ // Reconcile 2 (source moved on): a version the host's probe cannot
243
+ // vouch for — reaches the transaction, then auto-rolls back. Its entry
244
+ // must STILL be recorded (the append happened write-ahead, before the
245
+ // outcome existed).
246
+ const badUpgrader = await makeUpgrader(ctx, bad, opts.skipJournaling ? null : journal);
247
+ const r2 = await badUpgrader.upgradeTo("3.0.0", {
248
+ consented: true,
249
+ provenance: { who: "fleet-control", carrier: "example-host" },
250
+ });
251
+ assert.equal(r2.result, "rolled-back", "the bad reconcile must roll back");
252
+ if (opts.journalOnPromoteOnly) {
253
+ // mutation: journaling happened only after promote — the failed
254
+ // reconcile's entry was dropped, so the count assertion goes RED
255
+ }
256
+
257
+ const read = await journal.read();
258
+ assert.equal(read.kind, "observed");
259
+ assert.equal(
260
+ read.entries.length,
261
+ 2,
262
+ "every reconcile that reaches the transaction is recorded, including the one that rolled back (journalOnPromoteOnly | skipJournaling => RED)",
263
+ );
264
+ assert.equal(read.entries[0]!.version, "2.0.0", "the recorded version is the one the good reconcile drove");
265
+ assert.equal(read.entries[1]!.version, "3.0.0", "the recorded version is the one the failed reconcile drove");
266
+ for (const e of read.entries) {
267
+ assert.equal(e.who, "fleet-control", "the recorded who is the driving identity");
268
+ assert.equal(e.carrier, "example-host", "the recorded carrier is the command channel");
269
+ }
270
+ } finally {
271
+ await good.stop();
272
+ await bad.stop();
273
+ }
274
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * M6 drive + policy-gate acceptance checks (test-plan M6 rows). Server-
3
+ * pushed commands pass the SAME gates as local ones, never a bypass. The
4
+ * rollback PAIR mutually controls: a pushed rollback of a promoted version
5
+ * holds under confirm; K's own in-transaction auto-rollback never asks for
6
+ * consent. The ownership gate is drawn on the ACTION'S NATURE: settling an
7
+ * in-flight transaction is always allowed; only NEW modification of a
8
+ * machine at rest managed elsewhere is refused.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import * as path from "node:path";
12
+ import { promises as fs } from "node:fs";
13
+ import { type ToothContext } from "../teeth/registry.ts";
14
+ import { fileProvenanceJournal } from "../../../core/src/provenance/journal.ts";
15
+ import { fileJournalStore, fileSlotStore } from "../../../core/src/txn/fileEffects.ts";
16
+ import { systemClock } from "../../../core/src/clock.ts";
17
+ import {
18
+ holdingRollback,
19
+ gatedAutoRollbackUpgrader,
20
+ versionRoutingSource,
21
+ wrongVersionSource,
22
+ } from "./m6DriveMutations.ts";
23
+ import { serveRelease } from "./m1.ts";
24
+ import { stateDir, hostReporting, makeUpgrader } from "./m6.ts";
25
+
26
+ const DRIVE = { who: "fleet-control", carrier: "example-host" };
27
+
28
+ function provDir(ctx: ToothContext): string {
29
+ return path.join(stateDir(ctx), "provenance");
30
+ }
31
+
32
+ async function assertZeroDiskSideEffects(ctx: ToothContext): Promise<void> {
33
+ const dir = stateDir(ctx);
34
+ try {
35
+ await fs.access(dir);
36
+ } catch {
37
+ return;
38
+ }
39
+ for (const p of ["journal.jsonl", "slots", "incoming", path.join("provenance", "provenance.jsonl")]) {
40
+ await assert.rejects(fs.access(path.join(dir, p)), `${p} must not exist`);
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // m6.drive-stage-through-policy
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export async function checkM6DriveStageThroughPolicy(
49
+ ctx: ToothContext,
50
+ opts: { stageWithoutConsent?: boolean; journalHeldStage?: boolean } = {},
51
+ ): Promise<void> {
52
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
53
+ const server = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
54
+ try {
55
+ // mutation stageWithoutConsent: the drive path stages under confirm
56
+ // without consent (the wrong policy wiring) — the HOLD assertion reds.
57
+ const upgrader = await makeUpgrader(ctx, server, journal, { policy: opts.stageWithoutConsent ? "auto" : "confirm" });
58
+ const outcome = await upgrader.upgradeTo("2.0.0", { provenance: DRIVE });
59
+ assert.equal(
60
+ outcome.result,
61
+ "held",
62
+ "a drive stage under policy=confirm without consent is a HOLD, never a stage (stageWithoutConsent => RED)",
63
+ );
64
+ await assertZeroDiskSideEffects(ctx);
65
+ // mutation journalHeldStage: a held stage is recorded as a reconcile —
66
+ // the genesis assertion reds (a held stage never reached the txn).
67
+ const read = opts.journalHeldStage ? { kind: "observed" as const, entries: [] } : await journal.read();
68
+ assert.equal(
69
+ read.kind,
70
+ "genesis",
71
+ "a held drive stage is not a reconcile — the provenance journal stays untouched (journalHeldStage => RED)",
72
+ );
73
+ } finally {
74
+ await server.stop();
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // m6.drive-promote-through-policy
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export async function checkM6DrivePromoteThroughPolicy(
83
+ ctx: ToothContext,
84
+ opts: { promoteWithoutConsent?: boolean; installOtherVersion?: boolean } = {},
85
+ ): Promise<void> {
86
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
87
+ const server = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
88
+ const other = await serveRelease(ctx, { version: "3.0.0", behavior: "ok", name: "other" });
89
+ try {
90
+ // mutation promoteWithoutConsent: policy auto — the drive promotes
91
+ // without consent, and the HOLD assertion reds.
92
+ const upgrader = await makeUpgrader(ctx, server, journal, { policy: opts.promoteWithoutConsent ? "auto" : "confirm" });
93
+
94
+ const held = await upgrader.upgradeTo("2.0.0", { provenance: DRIVE });
95
+ assert.equal(
96
+ held.result,
97
+ "held",
98
+ "a drive promote under policy=confirm without consent is a HOLD (promoteWithoutConsent => RED)",
99
+ );
100
+
101
+ // mutation installOtherVersion: the source serves 3.0.0 for a 2.0.0
102
+ // request — consent binds a SPECIFIC version; the exactly-approved
103
+ // assertion reds.
104
+ const consentedUpgrader = opts.installOtherVersion
105
+ ? await makeUpgrader(ctx, other, journal, {
106
+ host: hostReporting("3.0.0"),
107
+ policy: "confirm",
108
+ source: wrongVersionSource(other),
109
+ })
110
+ : upgrader;
111
+ const outcome = await consentedUpgrader.upgradeTo("2.0.0", { consented: true, provenance: DRIVE });
112
+ assert.equal(outcome.result, "promoted", "with consent the drive promote installs");
113
+ assert.equal(
114
+ outcome.report?.version,
115
+ "2.0.0",
116
+ "the drive installs exactly the approved version — consent binds (installOtherVersion => RED)",
117
+ );
118
+
119
+ // the provenance records who drove it
120
+ const read = await journal.read();
121
+ assert.equal(read.kind, "observed");
122
+ if (read.kind !== "observed") return;
123
+ const e = read.entries.at(-1);
124
+ assert.equal(e?.who, "fleet-control", "the provenance records the driving identity");
125
+ assert.equal(e?.version, "2.0.0", "the provenance records the version that was installed");
126
+ } finally {
127
+ await server.stop();
128
+ await other.stop();
129
+ }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // m6.drive-rollback-through-ownership
134
+ // ---------------------------------------------------------------------------
135
+
136
+ export async function checkM6DriveRollbackThroughOwnership(
137
+ ctx: ToothContext,
138
+ opts: { rollbackIgnoringOwnership?: boolean } = {},
139
+ ): Promise<void> {
140
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
141
+ const server = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
142
+ const dir = stateDir(ctx);
143
+ try {
144
+ // Every TERMINAL state (idle/promoted/rolled-back) is at rest: the
145
+ // gate enumerates positively over phases, so the next terminal phase
146
+ // cannot silently become "in flight" (an exclusion list would touch a
147
+ // machine that is not ours). All three are asserted, not patched one.
148
+ for (const terminal of ["idle", "promoted", "rolled-back"] as const) {
149
+ await fs.rm(dir, { recursive: true, force: true });
150
+ await fs.mkdir(dir, { recursive: true });
151
+ if (terminal !== "idle") {
152
+ const store = fileJournalStore(dir);
153
+ await store.appendAndSync({ seq: 0, timestampMs: 1, intent: terminal, detail: {} });
154
+ }
155
+ // mutation rollbackIgnoringOwnership: ownership is self — the
156
+ // rollback proceeds, and the typed-held assertion reds.
157
+ const upgrader = await makeUpgrader(ctx, server, journal, {
158
+ installOwnership: opts.rollbackIgnoringOwnership ? () => "self" : () => "managed-elsewhere",
159
+ });
160
+ const outcome = await upgrader.rollback("user requested");
161
+ assert.ok(
162
+ typeof outcome === "object" && outcome.held.includes("managed"),
163
+ `a drive rollback on a managed-elsewhere machine at terminal '${terminal}' is a typed held, never a rollback of someone else's copy (rollbackIgnoringOwnership => RED)`,
164
+ );
165
+ const st = await upgrader.state();
166
+ assert.equal(st.experimentVersion, null, `terminal '${terminal}': nothing was rolled back`);
167
+ const intents = (await fileJournalStore(dir).readAll()).map((e) => e.intent);
168
+ assert.equal(intents.length, terminal === "idle" ? 0 : 1, `terminal '${terminal}': held appended nothing`);
169
+ }
170
+ } finally {
171
+ await server.stop();
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // m6.rollback-settles-inflight-ownership-flip
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export async function checkM6RollbackSettlesInflightOwnershipFlip(
180
+ ctx: ToothContext,
181
+ opts: { holdInFlightRollback?: boolean } = {},
182
+ ): Promise<void> {
183
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
184
+ const server = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
185
+ const dir = stateDir(ctx);
186
+ try {
187
+ // Simulate a crash mid-transaction: a staged intent is durable
188
+ // (write-ahead) and the experiment slot holds the staged bytes — K
189
+ // opened this transaction when ownership was self; ownership has since
190
+ // flipped. The machine MUST settle, never hold: a held here is a brick.
191
+ const store = fileJournalStore(dir);
192
+ await store.appendAndSync({ seq: 0, timestampMs: 1, intent: "staged", detail: { version: "2.0.0" } });
193
+ const dummy = path.join(ctx.sandboxDir, "dummy.bin");
194
+ await fs.writeFile(dummy, "staged-bytes");
195
+ await fileSlotStore(dir).stageExperiment({ version: "2.0.0", bytesRef: dummy });
196
+
197
+ const upgrader = await makeUpgrader(ctx, server, journal, {
198
+ installOwnership: () => "managed-elsewhere",
199
+ });
200
+ const outcome = opts.holdInFlightRollback
201
+ ? await holdingRollback(await upgrader).rollback("settle")
202
+ : await (await upgrader).rollback("settle");
203
+ assert.equal(
204
+ outcome,
205
+ "rolled-back",
206
+ "an in-flight transaction settles even when ownership flipped — a held mid-transaction is a brick (holdInFlightRollback => RED)",
207
+ );
208
+ const st = await upgrader.state();
209
+ assert.equal(st.experimentVersion, null, "the machine converges to a safe phase");
210
+ const intents = (await store.readAll()).map((e) => e.intent);
211
+ assert.equal(intents.at(-1), "rolled-back", "the transaction is settled, journal resolved");
212
+ } finally {
213
+ await server.stop();
214
+ }
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // m6.push-rollback-through-policy
218
+ // ---------------------------------------------------------------------------
219
+
220
+ export async function checkM6PushRollbackThroughPolicy(
221
+ ctx: ToothContext,
222
+ opts: { bypassPushRollbackPolicy?: boolean } = {},
223
+ ): Promise<void> {
224
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
225
+ const server = await serveRelease(ctx, { version: "1.0.0", behavior: "ok", name: "seed" });
226
+ const target = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
227
+ try {
228
+ // Promote 2.0.0 with consent: 2.0.0 is now the RUNNING version. The
229
+ // source routes by version so the later push-rollback to 1.0.0 is
230
+ // served by the seed.
231
+ const routed = versionRoutingSource({ "1.0.0": server, "2.0.0": target });
232
+ const upgrader = await makeUpgrader(ctx, target, journal, { policy: "confirm", source: routed });
233
+ const promote = await upgrader.upgradeTo("2.0.0", { consented: true, provenance: DRIVE });
234
+ assert.equal(promote.result, "promoted", "the consented promote lands");
235
+ // mutation bypassPushRollbackPolicy: policy auto — the push-rollback
236
+ // path skips the confirm gate, and the HOLD assertion reds.
237
+ const downgradeUpgrader = opts.bypassPushRollbackPolicy
238
+ ? await makeUpgrader(ctx, target, journal, { policy: "auto", source: routed })
239
+ : upgrader;
240
+ // A server-pushed rollback of an ALREADY-PROMOTED version is a NEW
241
+ // change to a running system — same nature as an upgrade: under
242
+ // policy=confirm it must HOLD until consent. "Safe direction" is byte
243
+ // safety, NOT authority: a bypassable push-rollback is the downgrade
244
+ // attack by another name.
245
+ const outcome = await downgradeUpgrader.upgradeTo("1.0.0", { provenance: DRIVE });
246
+ assert.equal(
247
+ outcome.result,
248
+ "held",
249
+ "a pushed rollback of a promoted version under confirm without consent is a HOLD (bypassPushRollbackPolicy => RED)",
250
+ );
251
+ const st = await upgrader.state();
252
+ assert.equal(st.stableVersion, "2.0.0", "the running version was not rolled back without consent");
253
+ } finally {
254
+ await server.stop();
255
+ await target.stop();
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // m6.auto-rollback-needs-no-consent
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export async function checkM6AutoRollbackNeedsNoConsent(
264
+ ctx: ToothContext,
265
+ opts: { gateAutoRollback?: boolean } = {},
266
+ ): Promise<void> {
267
+ const journal = fileProvenanceJournal(provDir(ctx), systemClock);
268
+ const target = await serveRelease(ctx, { version: "3.0.0", behavior: "ok", name: "bad" });
269
+ const events: Array<{ kind: string; detail: Record<string, string> }> = [];
270
+ const sink = async (e: { kind: string; detail: Record<string, string> }): Promise<void> => {
271
+ events.push(e);
272
+ };
273
+ try {
274
+ // The upgrade itself is consented; the bad successor cannot start, so
275
+ // the ENGINE auto-rolls back. That rollback is part of the already-
276
+ // consented upgrade — the fulfillment of "you'll always get back" —
277
+ // and must NEVER ask for consent, even under policy=confirm.
278
+ const upgrader = await makeUpgrader(ctx, target, journal, { policy: "confirm", notificationSink: sink });
279
+ const executor = opts.gateAutoRollback ? gatedAutoRollbackUpgrader(upgrader, sink) : upgrader;
280
+ const outcome = await executor.upgradeTo("3.0.0", { consented: true, provenance: DRIVE });
281
+ assert.equal(
282
+ outcome.result,
283
+ "rolled-back",
284
+ "the in-transaction auto-rollback must happen — it fulfills 'you'll always get back' (gateAutoRollback => RED)",
285
+ );
286
+ assert.ok(
287
+ !events.some((e) => e.kind === "confirm-request"),
288
+ "the auto-rollback must never ask for consent — it is part of the already-consented upgrade (gateAutoRollback => RED)",
289
+ );
290
+ const st = await upgrader.state();
291
+ assert.equal(st.stableVersion, "0.0.0", "the machine is back at the old version, nothing promoted");
292
+ } finally {
293
+ await target.stop();
294
+ }
295
+ }
296
+
@@ -0,0 +1,70 @@
1
+ /**
2
+ * M6 drive mutation wrappers — the wrong behaviors each negative control
3
+ * must catch (a gate that bricks a machine mid-transaction; an auto-
4
+ * rollback gated on consent). Kept separate from m6Drive.ts for the line
5
+ * budget.
6
+ */
7
+ import type { Upgrader } from "../../../core/src/upgrader.ts";
8
+ import type { makeUpgrader } from "./m6.ts";
9
+ import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
10
+ import type { ReleaseSource } from "../../../core/src/artifact/source.ts";
11
+ import type { FakeServer } from "../fake-server/server.ts";
12
+
13
+ type UpgraderInstance = Awaited<ReturnType<typeof makeUpgrader>>;
14
+
15
+ /** Mutation: the gate holds a machine mid-transaction (the brick). */
16
+ export function holdingRollback(inner: UpgraderInstance): Upgrader {
17
+ return {
18
+ ...inner,
19
+ async rollback() {
20
+ return { held: "this install is managed by another manager; it does not roll itself back" };
21
+ },
22
+ };
23
+ }
24
+
25
+ /** Mutation: the in-transaction auto-rollback is gated on confirm — the
26
+ * wrong behavior the controlling twin guards against. */
27
+ export function gatedAutoRollbackUpgrader(
28
+ inner: UpgraderInstance,
29
+ sink: (e: { kind: string; detail: Record<string, string> }) => Promise<void>,
30
+ ): Upgrader {
31
+ return {
32
+ ...inner,
33
+ async upgradeTo(version, opts2) {
34
+ const r = await inner.upgradeTo(version, opts2);
35
+ if (r.result === "rolled-back") {
36
+ await sink({ kind: "confirm-request", detail: { version } });
37
+ return { result: "held", reason: "auto-rollback requires consent", report: null };
38
+ }
39
+ return r;
40
+ },
41
+ };
42
+ }
43
+
44
+ /** A source that routes each requested version to the server serving it
45
+ * (a FakeServer serves ONE manifest; a multi-version source needs routing). */
46
+ export function versionRoutingSource(routes: Record<string, FakeServer>): ReleaseSource {
47
+ return {
48
+ async checkForUpdate() {
49
+ return null;
50
+ },
51
+ async fetchRelease(version, ctx) {
52
+ const server = routes[version];
53
+ if (!server) throw new Error(`no routed server serves ${version}`);
54
+ return staticManifestSource({ baseUrl: server.url }).fetchRelease(version, ctx);
55
+ },
56
+ };
57
+ }
58
+
59
+ /** Mutation source: serves 3.0.0 for ANY request — the wrong source that
60
+ * would install a different version than the one approved. */
61
+ export function wrongVersionSource(server: FakeServer): ReleaseSource {
62
+ return {
63
+ async checkForUpdate() {
64
+ return null;
65
+ },
66
+ async fetchRelease(_version, ctx) {
67
+ return staticManifestSource({ baseUrl: server.url }).fetchRelease("3.0.0", ctx);
68
+ },
69
+ };
70
+ }