@botiverse/k-carrier 0.1.6 → 0.1.8

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.
@@ -32,42 +32,9 @@ import { ArtifactError } from "./errors.ts";
32
32
  import type { Release } from "./source.ts";
33
33
  import { collectStream } from "./collectStream.ts";
34
34
  import { partialPathFor } from "./partialPath.ts";
35
+ import type { DownloadOptions } from "./transferPolicy.ts";
35
36
  export { partialPathFor } from "./partialPath.ts";
36
-
37
- export interface DownloadOptions {
38
- /**
39
- * Byte progress. `downloaded` counts bytes ON DISK including any resumed
40
- * prefix, so a resumed download never appears to restart at zero.
41
- */
42
- onProgress?: (downloaded: number, total: number) => void;
43
- clock?: Clock;
44
- /** Abort the download after this many ms (0 = no timeout). Default 10000. */
45
- timeoutMs?: number;
46
- /**
47
- * Abort after this many ms with NO bytes arriving (0 = off). Default 0.
48
- *
49
- * Distinct from `timeoutMs`, and for large artifacts the more useful of the
50
- * two: a total budget must either be big enough for the slowest acceptable
51
- * download of a 150MB binary -- in which case a wedged connection holds for
52
- * just as long -- or small enough to kill a slow one that was making steady
53
- * progress. Bounding SILENCE instead follows liveness, so the limit does not
54
- * have to encode a guess about size or bandwidth.
55
- */
56
- stallTimeoutMs?: number;
57
- /**
58
- * HTTP client for the artifact bytes. `staticManifestSource` already takes
59
- * one; without the same seam here an adopter can point K at their own server
60
- * for the MANIFEST but not for the BYTES, which is half a seam and surprising
61
- * in exactly the place it matters (proxies, custom agents, and an adopter's
62
- * own integration tests all need both).
63
- */
64
- fetchImpl?: typeof fetch;
65
- /**
66
- * Directory for partial-download state. When set, an interrupted download
67
- * leaves its prefix here and the next attempt resumes via Range.
68
- */
69
- resumeDir?: string;
70
- }
37
+ export type { DownloadOptions } from "./transferPolicy.ts";
71
38
 
72
39
  /** Fetch and verify one release's bytes, resuming from a partial if present. */
73
40
  export async function downloadVerified(
@@ -92,7 +59,9 @@ export async function downloadVerified(
92
59
 
93
60
  const bytes = await fetchAndAppend(
94
61
  url, partialPath, partialSize, clock, timeoutMs, opts.onProgress, release.size,
95
- opts.stallTimeoutMs ?? 0, opts.fetchImpl ?? fetch,
62
+ opts.responseTimeoutMs ?? opts.stallTimeoutMs ?? 0,
63
+ opts.idleTimeoutMs ?? opts.stallTimeoutMs ?? 0,
64
+ opts.fetchImpl ?? fetch,
96
65
  );
97
66
 
98
67
  const sha = sha256Hex(bytes);
@@ -129,26 +98,24 @@ async function fetchAndAppend(
129
98
  timeoutMs: number,
130
99
  onProgress?: (downloaded: number, total: number) => void,
131
100
  total?: number,
132
- stallTimeoutMs = 0,
101
+ responseTimeoutMs = 0,
102
+ idleTimeoutMs = 0,
133
103
  doFetch: typeof fetch = fetch,
134
104
  ): Promise<Uint8Array> {
135
105
  const controller = new AbortController();
136
- // Rearmed on every chunk; fires only if the gap between chunks exceeds the
137
- // budget. `stalled` records WHY we aborted, because the abort itself cannot
138
- // say -- a stall and a total-timeout abort look identical at the signal.
139
- let stalled = false;
140
- let stallTimer: (() => void) | undefined;
106
+ // Body liveness is rearmed on every chunk. `timeoutKind` records WHY we
107
+ // aborted because the abort signal cannot distinguish the three budgets.
108
+ let timeoutKind: "overall" | "response" | "idle" | null = null;
109
+ let responseTimer: (() => void) | undefined;
110
+ let idleTimer: (() => void) | undefined;
141
111
  // Invoked when a deadline fires so the pending fetch cannot outlive it.
142
112
  let aborted: (() => void) | undefined;
143
- // Which phase we were in when the deadline fired. Reporting "awaiting
144
- // response" for a stall that happened mid-body sends the reader looking at
145
- // the wrong end of the transfer.
146
- let responded = false;
147
113
  const abortReason = (u: string): string =>
148
- stalled
149
- ? `download stalled: nothing received for ${stallTimeoutMs}ms ` +
150
- `(${responded ? "mid-body" : "awaiting response"}): ${u}`
151
- : `download timed out after ${timeoutMs}ms: ${u}`;
114
+ timeoutKind === "response"
115
+ ? `download response timed out after ${responseTimeoutMs}ms (awaiting response): ${u}`
116
+ : timeoutKind === "idle"
117
+ ? `download stalled: nothing received for ${idleTimeoutMs}ms (mid-body): ${u}`
118
+ : `download timed out after ${timeoutMs}ms: ${u}`;
152
119
  // Declared AFTER `aborted`: an immediate/virtual clock fires this callback
153
120
  // synchronously inside `clock.after`, so a timer created earlier would reach
154
121
  // `aborted` in its temporal dead zone. Real clocks hide that ordering; the
@@ -156,15 +123,25 @@ async function fetchAndAppend(
156
123
  const cancel =
157
124
  timeoutMs > 0
158
125
  ? clock.after(timeoutMs, () => {
126
+ timeoutKind = "overall";
159
127
  controller.abort();
160
128
  aborted?.();
161
129
  })
162
130
  : undefined;
163
- const armStall = (): void => {
164
- if (stallTimeoutMs <= 0) return;
165
- stallTimer?.();
166
- stallTimer = clock.after(stallTimeoutMs, () => {
167
- stalled = true;
131
+ const armResponse = (): void => {
132
+ if (responseTimeoutMs <= 0) return;
133
+ responseTimer?.();
134
+ responseTimer = clock.after(responseTimeoutMs, () => {
135
+ timeoutKind = "response";
136
+ controller.abort();
137
+ aborted?.();
138
+ });
139
+ };
140
+ const armIdle = (): void => {
141
+ if (idleTimeoutMs <= 0) return;
142
+ idleTimer?.();
143
+ idleTimer = clock.after(idleTimeoutMs, () => {
144
+ timeoutKind = "idle";
168
145
  controller.abort();
169
146
  aborted?.();
170
147
  });
@@ -173,7 +150,7 @@ async function fetchAndAppend(
173
150
  const headers: Record<string, string> = {};
174
151
  if (partialSize > 0) headers["Range"] = `bytes=${partialSize}-`;
175
152
  let res: Response;
176
- armStall(); // the response headers themselves must not hang forever
153
+ armResponse();
177
154
  try {
178
155
  // Race the abort, do not merely signal it. `AbortSignal` only works if the
179
156
  // fetch implementation honours it, and `fetchImpl` is an adopter-supplied
@@ -193,11 +170,7 @@ async function fetchAndAppend(
193
170
  const timedOut = controller.signal.aborted;
194
171
  throw new ArtifactError(
195
172
  "DOWNLOAD_FAILED",
196
- stalled
197
- ? `download stalled: nothing received for ${stallTimeoutMs}ms (awaiting response): ${url}`
198
- : timedOut
199
- ? `download timed out after ${timeoutMs}ms: ${url}`
200
- : `fetch failed: ${url}`,
173
+ timedOut ? abortReason(url) : `fetch failed: ${url}`,
201
174
  { cause: err },
202
175
  );
203
176
  }
@@ -209,8 +182,9 @@ async function fetchAndAppend(
209
182
  // server that answers slowly and then streams normally is killed by a
210
183
  // deadline that started before the request was even answered -- and the
211
184
  // failure reports "awaiting response" while bytes were on their way.
212
- responded = true;
213
- armStall();
185
+ responseTimer?.();
186
+ responseTimer = undefined;
187
+ armIdle();
214
188
 
215
189
  if (partialPath === null) {
216
190
  // Stream even with nowhere to resume to. `res.arrayBuffer()` is one
@@ -219,7 +193,7 @@ async function fetchAndAppend(
219
193
  // silently had no byte progress and no stall detection at all, while
220
194
  // both looked configured.
221
195
  try {
222
- return await collectStream(url, res.body, total, onProgress, armStall);
196
+ return await collectStream(url, res.body, total, onProgress, armIdle);
223
197
  } catch (err) {
224
198
  // Same classification as the resume path. Without this the in-memory
225
199
  // branch reported a bare stream error, so an abort we ourselves caused
@@ -248,10 +222,10 @@ async function fetchAndAppend(
248
222
  // reads as "it lost my download" to the person watching it.
249
223
  let onDisk = partialSize;
250
224
  onProgress?.(onDisk, total ?? 0);
251
- armStall();
225
+ armIdle();
252
226
  await streamToFile(res.body, fh, (chunk) => {
253
227
  onDisk += chunk;
254
- armStall();
228
+ armIdle();
255
229
  onProgress?.(onDisk, total ?? 0);
256
230
  });
257
231
  await fh.sync();
@@ -262,11 +236,7 @@ async function fetchAndAppend(
262
236
  const timedOut = controller.signal.aborted;
263
237
  throw new ArtifactError(
264
238
  "DOWNLOAD_FAILED",
265
- stalled
266
- ? `download stalled: nothing received for ${stallTimeoutMs}ms (mid-body): ${url}`
267
- : timedOut
268
- ? `download timed out after ${timeoutMs}ms: ${url}`
269
- : `download interrupted: ${url}`,
239
+ timedOut ? abortReason(url) : `download interrupted: ${url}`,
270
240
  { cause: err },
271
241
  );
272
242
  } finally {
@@ -275,7 +245,8 @@ async function fetchAndAppend(
275
245
  return new Uint8Array(await fs.readFile(partialPath));
276
246
  } finally {
277
247
  cancel?.();
278
- stallTimer?.();
248
+ responseTimer?.();
249
+ idleTimer?.();
279
250
  }
280
251
  }
281
252
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Production artifact-transfer budgets.
3
+ *
4
+ * Response latency, progress liveness, and total duration answer different
5
+ * questions. Keeping them separate prevents a healthy large transfer from
6
+ * being killed by the same deadline that bounds an unreachable server.
7
+ */
8
+ import type { Clock } from "../clock.ts";
9
+
10
+ export interface DownloadOptions {
11
+ /** Byte progress, including any prefix already present on disk. */
12
+ onProgress?: (downloaded: number, total: number) => void;
13
+ clock?: Clock;
14
+ /** Whole-download bound (0 = off). Legacy direct-call default is 10000. */
15
+ timeoutMs?: number;
16
+ /** Maximum wait for response headers (0 = off). */
17
+ responseTimeoutMs?: number;
18
+ /** Maximum silence between response-body chunks (0 = off). */
19
+ idleTimeoutMs?: number;
20
+ /** Legacy alias for both responseTimeoutMs and idleTimeoutMs. */
21
+ stallTimeoutMs?: number;
22
+ fetchImpl?: typeof fetch;
23
+ /** Directory holding a resumable partial artifact. */
24
+ resumeDir?: string;
25
+ }
26
+
27
+ export interface ArtifactTransferPolicy {
28
+ /** Maximum wait for response headers. */
29
+ responseTimeoutMs: number;
30
+ /** Maximum silence between response-body chunks. */
31
+ idleTimeoutMs: number;
32
+ /** Slowest sustained body rate accepted when deriving the size budget. */
33
+ minimumBytesPerSecond: number;
34
+ /** Absolute ceiling even when the declared artifact is very large. */
35
+ maximumOverallTimeoutMs: number;
36
+ }
37
+
38
+ export interface ArtifactTransferTimeouts {
39
+ responseTimeoutMs: number;
40
+ idleTimeoutMs: number;
41
+ overallTimeoutMs: number;
42
+ }
43
+
44
+ export const DEFAULT_ARTIFACT_TRANSFER_POLICY: ArtifactTransferPolicy = {
45
+ responseTimeoutMs: 30_000,
46
+ idleTimeoutMs: 30_000,
47
+ minimumBytesPerSecond: 64 * 1024,
48
+ maximumOverallTimeoutMs: 30 * 60_000,
49
+ };
50
+
51
+ function requirePositiveInteger(name: string, value: number): void {
52
+ if (!Number.isSafeInteger(value) || value <= 0) {
53
+ throw new Error(`ARTIFACT_TRANSFER_POLICY_INVALID: ${name} must be a positive safe integer`);
54
+ }
55
+ }
56
+
57
+ /** Derive the bounded total budget from the release authority's exact size. */
58
+ export function artifactTransferTimeouts(
59
+ artifactSize: number,
60
+ policy: ArtifactTransferPolicy = DEFAULT_ARTIFACT_TRANSFER_POLICY,
61
+ ): ArtifactTransferTimeouts {
62
+ requirePositiveInteger("artifactSize", artifactSize);
63
+ requirePositiveInteger("responseTimeoutMs", policy.responseTimeoutMs);
64
+ requirePositiveInteger("idleTimeoutMs", policy.idleTimeoutMs);
65
+ requirePositiveInteger("minimumBytesPerSecond", policy.minimumBytesPerSecond);
66
+ requirePositiveInteger("maximumOverallTimeoutMs", policy.maximumOverallTimeoutMs);
67
+ if (policy.maximumOverallTimeoutMs < policy.responseTimeoutMs) {
68
+ throw new Error(
69
+ "ARTIFACT_TRANSFER_POLICY_INVALID: maximumOverallTimeoutMs must cover responseTimeoutMs",
70
+ );
71
+ }
72
+
73
+ const bodyBudgetMs = Math.ceil((artifactSize * 1_000) / policy.minimumBytesPerSecond);
74
+ return {
75
+ responseTimeoutMs: policy.responseTimeoutMs,
76
+ idleTimeoutMs: policy.idleTimeoutMs,
77
+ overallTimeoutMs: Math.min(
78
+ policy.maximumOverallTimeoutMs,
79
+ policy.responseTimeoutMs + bodyBudgetMs,
80
+ ),
81
+ };
82
+ }
@@ -7,7 +7,7 @@
7
7
  * rollback is rare, not routine.
8
8
  */
9
9
  import type { Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity } from "./upgrader.ts";
10
- import { phaseAtRest, type TxnState } from "./txn/state.ts";
10
+ import { assertNever, buildTxnState, phaseAtRest, type TxnState } from "./txn/state.ts";
11
11
  import type { ProcessEvidence } from "./lifecycle/hostAdapter.ts";
12
12
  import { UpgradeEngine } from "./txn/engine.ts";
13
13
  import { fileEffects } from "./txn/fileEffects.ts";
@@ -26,6 +26,8 @@ import { recoverUpgrade } from "./upgrade/recover.ts";
26
26
  import type { OperationDescriptor } from "./operation.ts";
27
27
  import { createOperationLifecycle } from "./operationLifecycle.ts";
28
28
  import { driveUpgrade } from "./upgrade/drive.ts";
29
+ import type { ArtifactTransferPolicy } from "./artifact/transferPolicy.ts";
30
+ import { quarantineState } from "./quarantine.ts";
29
31
 
30
32
  export interface CreateUpgraderOptions extends UpgraderConfig {
31
33
  clock?: Clock;
@@ -58,6 +60,12 @@ export interface CreateUpgraderOptions extends UpgraderConfig {
58
60
  provenance?: ProvenanceJournal;
59
61
  /** Identity recorded for reconciles that carry none (local auto-update). */
60
62
  provenanceIdentity?: ProvenanceIdentity;
63
+ /**
64
+ * Independent response, idle, and size-derived total budgets for artifact
65
+ * bytes. Defaults are safe for Computer-sized binaries; adopters may make
66
+ * them stricter, but cannot disable every bound.
67
+ */
68
+ artifactTransferPolicy?: ArtifactTransferPolicy;
61
69
  }
62
70
 
63
71
  export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
@@ -111,12 +119,35 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
111
119
  async function readState(): Promise<TxnState> {
112
120
  const slots = await effects.slots.slotVersions();
113
121
  const intents = (await effects.journal.readAll()).map((e) => e.intent);
114
- return {
115
- phase: intents.at(-1) ?? "idle",
116
- stableVersion: slots.stable ?? "0.0.0",
117
- experimentVersion: slots.experiment,
118
- rollbackReason: null,
119
- };
122
+ const phase = intents.at(-1) ?? "idle";
123
+ const stableVersion = slots.stable ?? "0.0.0";
124
+ const experimentVersion = slots.experiment;
125
+ switch (phase) {
126
+ case "idle":
127
+ case "promoted":
128
+ return buildTxnState({ phase, stableVersion });
129
+ case "rolled-back":
130
+ return buildTxnState({ phase, stableVersion });
131
+ case "staged":
132
+ case "handing-over":
133
+ case "running-experiment":
134
+ case "readback": {
135
+ // In-flight phases always carry an experiment. A persisted world that
136
+ // says an in-flight phase has an empty experiment slot is
137
+ // inconsistent, and we fail closed rather than fabricate a value that
138
+ // would be a type lie at this (the only runtime) boundary.
139
+ if (experimentVersion === null) {
140
+ throw new Error(
141
+ `TXN_STATE_INCONSISTENT: phase ${phase} is in-flight but the experiment slot is empty`,
142
+ );
143
+ }
144
+ return buildTxnState({ phase, stableVersion, experimentVersion });
145
+ }
146
+ default:
147
+ // Matches phaseAtRest's exhaustive discipline: an unknown phase from a
148
+ // newer core is not silently reinterpreted.
149
+ return assertNever(phase);
150
+ }
120
151
  }
121
152
 
122
153
  const operationLifecycle = createOperationLifecycle(opts.stateDir, clock, readState);
@@ -142,6 +173,9 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
142
173
  },
143
174
  ...(opts.provenance ? { provenanceJournal: opts.provenance } : {}),
144
175
  ...(opts.provenanceIdentity ? { provenanceIdentity: opts.provenanceIdentity } : {}),
176
+ ...(opts.artifactTransferPolicy
177
+ ? { artifactTransferPolicy: opts.artifactTransferPolicy }
178
+ : {}),
145
179
  }, request);
146
180
 
147
181
  return {
@@ -234,5 +268,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
234
268
  await lock.release();
235
269
  }
236
270
  },
271
+
272
+ quarantineState: (options) => quarantineState(opts.stateDir, options),
237
273
  };
238
274
  }
package/core/src/index.ts CHANGED
@@ -14,10 +14,12 @@ export * from "./bootstrap.ts";
14
14
  // NotificationEvent.
15
15
  export * from "./upgrader.ts";
16
16
  export * from "./operation.ts";
17
+ export * from "./quarantine.ts";
17
18
 
18
19
  // The release-source boundary applications implement and the durable
19
20
  // provenance journal they wire into createUpgrader.
20
21
  export * from "./artifact/source.ts";
22
+ export * from "./artifact/transferPolicy.ts";
21
23
  export * from "./provenance/journal.ts";
22
24
 
23
25
  // The host boundary an adopter implements: HostAdapter, Slot, ProcessEvidence.
@@ -170,6 +170,9 @@ export async function acknowledgeOperation(
170
170
  if (current.kind === "unreadable") throw new Error(current.reason);
171
171
  if (current.operation.id !== operationId) return "changed";
172
172
  if (current.operation.outcome === null) return "not-terminal";
173
+ // Exact replay is idempotent. The first delivery time is part of the audit
174
+ // receipt; a retry must not rewrite it or manufacture a later delivery.
175
+ if (current.operation.acknowledgedAtMs !== null) return "acknowledged";
173
176
  await persistOperation(stateDir, {
174
177
  ...current.operation,
175
178
  updatedAtMs: acknowledgedAtMs,
@@ -0,0 +1,167 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadOperation } from "./operation.ts";
4
+ import { acquireUpgradeLock, type UpgradeLock } from "./txn/lock.ts";
5
+ import { UpgradeLockError } from "./txn/lock.ts";
6
+ import { platformOpsFor } from "./platform/index.ts";
7
+
8
+ export type QuarantineResult =
9
+ | { status: "quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
10
+ | { status: "already-quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
11
+ | { status: "not-found"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number };
12
+
13
+ export type QuarantineErrorCode =
14
+ | "QUARANTINE_INVALID_DESTINATION"
15
+ | "QUARANTINE_DESTINATION_CONFLICT"
16
+ | "QUARANTINE_ACTIVE_OPERATION"
17
+ | "QUARANTINE_ACTIVE_LOCK"
18
+ | "QUARANTINE_STATE_UNREADABLE"
19
+ | "QUARANTINE_WRITE_FAILED";
20
+
21
+ export class QuarantineError extends Error {
22
+ readonly code: QuarantineErrorCode;
23
+
24
+ constructor(code: QuarantineErrorCode, message: string, options?: { cause?: unknown }) {
25
+ super(`[${code}] ${message}`, options);
26
+ this.name = "QuarantineError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ export interface QuarantineOptions {
32
+ /** Absolute destination. It must be outside stateDir and must not exist. */
33
+ destination: string;
34
+ /** Timestamp supplied by the host clock so the receipt is deterministic. */
35
+ timestampMs: number;
36
+ /** Host proof run while K's single-writer lock is held. */
37
+ assertActiveHandoff?: () => Promise<void>;
38
+ }
39
+
40
+ function assertDestination(stateDir: string, destination: string): void {
41
+ if (!path.isAbsolute(destination)) {
42
+ throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be absolute");
43
+ }
44
+ const source = path.resolve(stateDir);
45
+ const target = path.resolve(destination);
46
+ if (source === target || target.startsWith(`${source}${path.sep}`)) {
47
+ throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be outside stateDir");
48
+ }
49
+ }
50
+
51
+ async function exists(filePath: string): Promise<boolean> {
52
+ try {
53
+ await fs.stat(filePath);
54
+ return true;
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ interface QuarantineReceipt {
62
+ formatVersion: 1;
63
+ kind: "k-fresh-install-quarantine";
64
+ sourcePath: string;
65
+ quarantinePath: string;
66
+ operationId: string;
67
+ timestampMs: number;
68
+ }
69
+
70
+ async function readReceipt(destination: string): Promise<QuarantineReceipt | null> {
71
+ try {
72
+ const parsed = JSON.parse(await fs.readFile(path.join(destination, "fresh-install-quarantine.json"), "utf8")) as Partial<QuarantineReceipt>;
73
+ if (parsed.formatVersion !== 1 || parsed.kind !== "k-fresh-install-quarantine" || typeof parsed.sourcePath !== "string" || typeof parsed.quarantinePath !== "string" || typeof parsed.operationId !== "string" || typeof parsed.timestampMs !== "number") return null;
74
+ return parsed as QuarantineReceipt;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Move the complete K state directory to an audit-only quarantine.
82
+ *
83
+ * The state lock is acquired before the terminal check and the directory
84
+ * rename is one filesystem operation. The lock release is ownership-aware, so
85
+ * a new state directory created immediately after the rename cannot have its
86
+ * lock removed by the old holder. Active operations are never killed or
87
+ * silently detached; callers must first complete the host handoff contract.
88
+ */
89
+ export async function quarantineState(stateDir: string, options: QuarantineOptions): Promise<QuarantineResult> {
90
+ assertDestination(stateDir, options.destination);
91
+ const timestampMs = options.timestampMs;
92
+ const sourcePath = path.resolve(stateDir);
93
+ const quarantinePath = path.resolve(options.destination);
94
+ const existingDestination = await exists(quarantinePath);
95
+ const existingOperation = await loadOperation(sourcePath);
96
+ const operationId = existingOperation.kind === "observed" ? existingOperation.operation.id : "genesis";
97
+
98
+ if (existingDestination) {
99
+ if (await exists(sourcePath)) {
100
+ throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
101
+ }
102
+ const receipt = await readReceipt(quarantinePath);
103
+ if (receipt === null) throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination has no valid quarantine receipt: ${quarantinePath}`);
104
+ return { status: "already-quarantined", sourcePath: receipt.sourcePath, quarantinePath: receipt.quarantinePath, operationId: receipt.operationId, timestampMs: receipt.timestampMs };
105
+ }
106
+ if (!(await exists(sourcePath))) {
107
+ return { status: "not-found", sourcePath, quarantinePath, operationId, timestampMs };
108
+ }
109
+
110
+ let lock: UpgradeLock | null = null;
111
+ try {
112
+ lock = await acquireUpgradeLock(sourcePath, timestampMs);
113
+ const operation = await loadOperation(sourcePath);
114
+ if (operation.kind === "unreadable") {
115
+ throw new QuarantineError("QUARANTINE_STATE_UNREADABLE", operation.reason);
116
+ }
117
+ if (operation.kind === "observed" && operation.operation.outcome === null) {
118
+ if (options.assertActiveHandoff === undefined) {
119
+ throw new QuarantineError(
120
+ "QUARANTINE_ACTIVE_OPERATION",
121
+ `operation ${operation.operation.id} is active; complete host handoff before quarantine`,
122
+ );
123
+ }
124
+ try {
125
+ await options.assertActiveHandoff();
126
+ } catch (error) {
127
+ throw new QuarantineError("QUARANTINE_ACTIVE_OPERATION", `active operation ${operation.operation.id} handoff was not proven`, { cause: error });
128
+ }
129
+ }
130
+ if (await exists(quarantinePath)) {
131
+ throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
132
+ }
133
+ await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
134
+ const receipt: QuarantineReceipt = {
135
+ formatVersion: 1,
136
+ kind: "k-fresh-install-quarantine",
137
+ sourcePath,
138
+ quarantinePath,
139
+ operationId: operation.kind === "observed" ? operation.operation.id : "genesis",
140
+ timestampMs,
141
+ };
142
+ const receiptPath = path.join(sourcePath, "fresh-install-quarantine.json");
143
+ const fh = await fs.open(receiptPath, "w");
144
+ try {
145
+ await fh.writeFile(JSON.stringify(receipt));
146
+ await fh.sync();
147
+ } finally {
148
+ await fh.close();
149
+ }
150
+ await platformOpsFor().renamePath(sourcePath, quarantinePath);
151
+ return {
152
+ status: "quarantined",
153
+ sourcePath,
154
+ quarantinePath,
155
+ operationId: receipt.operationId,
156
+ timestampMs,
157
+ };
158
+ } catch (error) {
159
+ if (error instanceof QuarantineError) throw error;
160
+ if (error instanceof UpgradeLockError) {
161
+ throw new QuarantineError("QUARANTINE_ACTIVE_LOCK", error.message, { cause: error });
162
+ }
163
+ throw new QuarantineError("QUARANTINE_WRITE_FAILED", `could not quarantine ${sourcePath}`, { cause: error });
164
+ } finally {
165
+ await lock?.release();
166
+ }
167
+ }
@@ -30,7 +30,7 @@ export type TxnPhase =
30
30
  * default, never the exclusion-list trap where every future terminal
31
31
  * phase silently becomes "in-flight" = allowed to modify.
32
32
  */
33
- function assertNever(x: never): never {
33
+ export function assertNever(x: never): never {
34
34
  throw new Error(`unknown TxnPhase ${String(x)}; refusing to classify — a phase a newer core wrote must not be touched`);
35
35
  }
36
36
 
@@ -53,13 +53,34 @@ export function phaseAtRest(phase: TxnPhase): boolean {
53
53
  }
54
54
  }
55
55
 
56
- export interface TxnState {
57
- phase: TxnPhase;
58
- stableVersion: string;
59
- experimentVersion: string | null;
60
- /** Why the last rollback happened; null unless phase === "rolled-back". */
61
- rollbackReason: string | null;
62
- }
56
+ /**
57
+ * A discriminated union so that an "illegal" combination is not representable:
58
+ *
59
+ * - terminal / at-rest phases (`idle`, `promoted`, `rolled-back`) NEVER carry
60
+ * an experiment an experiment slot that outlives a terminal phase is a
61
+ * leaked transaction (k.terminal-leaves-no-experiment would fire at runtime;
62
+ * here it is a compile error instead).
63
+ * - in-flight phases (`staged`..`readback`) ALWAYS carry an experiment — that
64
+ * is exactly what "in flight" means.
65
+ * - only `rolled-back` may carry a `rollbackReason`.
66
+ *
67
+ * Construction must choose a member consistent with `phase`; a `switch` over
68
+ * `TxnState` narrows each member and forces every phase to make a decision
69
+ * (the same discipline as `phaseAtRest`, lifted to the whole record).
70
+ */
71
+ export type TxnState =
72
+ | { phase: "idle"; stableVersion: string; experimentVersion: null; rollbackReason: null }
73
+ | {
74
+ phase: "staged" | "handing-over" | "running-experiment" | "readback";
75
+ stableVersion: string;
76
+ experimentVersion: string;
77
+ rollbackReason: null;
78
+ }
79
+ | { phase: "promoted"; stableVersion: string; experimentVersion: null; rollbackReason: null }
80
+ | { phase: "rolled-back"; stableVersion: string; experimentVersion: null; rollbackReason: string | null };
81
+
82
+ /** The in-flight, experiment-carrying phases (a helper for exhaustive narrowing). */
83
+ export const IN_FLIGHT_PHASES = ["staged", "handing-over", "running-experiment", "readback"] as const;
63
84
 
64
85
  /** Append-only, write-ahead journal entry. fsync'd before the action runs. */
65
86
  export interface JournalEntry {
@@ -75,3 +96,51 @@ export interface JournalEntry {
75
96
  * silently reinterpret. Encoded as a version field checked before replay.
76
97
  */
77
98
  export const STATE_FORMAT_VERSION = 1;
99
+
100
+ /**
101
+ * The strongly-typed input to `buildTxnState`. The argument is itself a
102
+ * discriminated union over the phase, so an illegal input combination does
103
+ * not type-check: a terminal/at-rest phase simply cannot carry an experiment,
104
+ * only `rolled-back` may carry a reason, and an in-flight phase must carry
105
+ * one. This is the "impossible states" discipline applied to the constructor's
106
+ * arguments, not just its result.
107
+ */
108
+ export type TxnStateInput =
109
+ | { phase: "idle" | "promoted"; stableVersion: string }
110
+ | { phase: "rolled-back"; stableVersion: string; rollbackReason?: string | null }
111
+ | {
112
+ phase: "staged" | "handing-over" | "running-experiment" | "readback";
113
+ stableVersion: string;
114
+ experimentVersion: string;
115
+ };
116
+
117
+ /**
118
+ * Soundly construct a `TxnState` member from a discriminated input.
119
+ *
120
+ * The input type already forbids the illegal combinations (an in-flight phase
121
+ * cannot be given a missing experiment, a terminal phase cannot be given an
122
+ * experiment, only `rolled-back` may carry a reason), so the result is sound
123
+ * by construction — there is no per-phase coercion to get wrong here.
124
+ *
125
+ * The one place an inconsistent persisted world can slip in is the runtime
126
+ * boundary that reads the journal and slots but only knows the phase at
127
+ * runtime (see `createUpgrader.readState`); that boundary must decide the
128
+ * phase before calling this, failing closed if a phase is in flight but the
129
+ * experiment slot is empty.
130
+ */
131
+ export function buildTxnState(input: TxnStateInput): TxnState {
132
+ switch (input.phase) {
133
+ case "idle":
134
+ case "promoted":
135
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: null, rollbackReason: null };
136
+ case "rolled-back":
137
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: null, rollbackReason: input.rollbackReason ?? null };
138
+ case "staged":
139
+ case "handing-over":
140
+ case "running-experiment":
141
+ case "readback":
142
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: input.experimentVersion, rollbackReason: null };
143
+ default:
144
+ return assertNever(input);
145
+ }
146
+ }
@@ -1,6 +1,11 @@
1
1
  import * as path from "node:path";
2
2
  import type { Release } from "../artifact/source.ts";
3
3
  import { downloadVerified } from "../artifact/download.ts";
4
+ import {
5
+ artifactTransferTimeouts,
6
+ type ArtifactTransferPolicy,
7
+ type DownloadOptions,
8
+ } from "../artifact/transferPolicy.ts";
4
9
  import { ArtifactError } from "../artifact/errors.ts";
5
10
  import { materializeArtifact } from "../txn/fileEffects.ts";
6
11
  import { acquireUpgradeLock } from "../txn/lock.ts";
@@ -37,6 +42,8 @@ export interface UpgradeDriveDeps {
37
42
  persistConvergenceReport: (report: ConvergenceReport) => Promise<void>;
38
43
  provenanceJournal?: ProvenanceJournal;
39
44
  provenanceIdentity?: ProvenanceIdentity;
45
+ artifactTransferPolicy?: ArtifactTransferPolicy;
46
+ downloadArtifact?: (release: Release, opts: DownloadOptions) => Promise<Uint8Array>;
40
47
  }
41
48
 
42
49
  export interface UpgradeDriveRequest {
@@ -115,9 +122,13 @@ export async function driveUpgrade(
115
122
 
116
123
  await deps.operation.transition({ phase: "downloading" });
117
124
  progress({ stage: "downloading", version: release.version });
118
- const bytes = await downloadVerified(release, {
125
+ const transfer = artifactTransferTimeouts(release.size, deps.artifactTransferPolicy);
126
+ const bytes = await (deps.downloadArtifact ?? downloadVerified)(release, {
119
127
  clock: deps.clock,
120
128
  resumeDir: path.join(deps.stateDir, "incoming"),
129
+ timeoutMs: transfer.overallTimeoutMs,
130
+ responseTimeoutMs: transfer.responseTimeoutMs,
131
+ idleTimeoutMs: transfer.idleTimeoutMs,
121
132
  onProgress: (downloaded, total) =>
122
133
  progress({ stage: "downloading", version: release.version, downloaded, total }),
123
134
  });
@@ -4,6 +4,7 @@ import type { ConvergenceReport } from "./converge/predicates.js";
4
4
  import type { StatusReport } from "./status/report.js";
5
5
  import type { ReleaseSource } from "./artifact/source.js";
6
6
  import type { OperationDescriptor, OperationRead } from "./operation.js";
7
+ import type { QuarantineOptions, QuarantineResult } from "./quarantine.ts";
7
8
 
8
9
  /**
9
10
  * Who drove a reconcile, recorded in the provenance journal (M6, L5).
@@ -106,6 +107,9 @@ export interface Upgrader {
106
107
 
107
108
  /** Mark one exact terminal operation delivered by the host transport. */
108
109
  acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
110
+
111
+ /** Atomically move quiesced K state to an audit-only fresh-install backup. */
112
+ quarantineState(options: QuarantineOptions): Promise<QuarantineResult>;
109
113
  }
110
114
 
111
115
  export type UpgradeOutcome =
@@ -237,6 +237,29 @@ Three things worth knowing before you draw a bar with it:
237
237
  and discards anything it throws. An observation surface must never become
238
238
  a failure mode — if your renderer breaks, the upgrade still completes.
239
239
 
240
+ Artifact transfer has three independent fail-closed budgets. Response headers
241
+ must arrive promptly, body progress must not go silent, and the full transfer
242
+ has a hard ceiling derived from the release source's declared byte size. The
243
+ defaults accept a Computer-sized binary that takes longer than ten seconds
244
+ while still bounding an unreachable server and a wedged mid-body stream. An
245
+ adopter with stricter network requirements may provide all four policy fields:
246
+
247
+ ```ts
248
+ createUpgrader({
249
+ ...,
250
+ artifactTransferPolicy: {
251
+ responseTimeoutMs: 20_000,
252
+ idleTimeoutMs: 30_000,
253
+ minimumBytesPerSecond: 128 * 1024,
254
+ maximumOverallTimeoutMs: 20 * 60_000,
255
+ },
256
+ });
257
+ ```
258
+
259
+ The total budget is `responseTimeoutMs + size / minimumBytesPerSecond`, capped
260
+ by `maximumOverallTimeoutMs`. Invalid, zero, or effectively unbounded policies
261
+ are rejected before the byte request starts.
262
+
240
263
  The stages are not a parallel state machine: they are derived from the L1
241
264
  transaction phases (`stageForPhase`), so a progress display can never show a
242
265
  state the transaction does not have.
@@ -263,6 +286,16 @@ if (receipt.kind === "observed" && receipt.operation.outcome !== null) {
263
286
  await deliver(receipt.operation);
264
287
  await upgrader.acknowledgeOperation(receipt.operation.id);
265
288
  }
289
+
290
+ // Fresh-install hosts may quarantine a complete, quiesced K state atomically.
291
+ // The destination must be an absolute path outside stateDir and the host must
292
+ // supply its clock timestamp. Terminal receipts are moved without deletion;
293
+ // active receipts require an in-lock host handoff proof.
294
+ const backup = await upgrader.quarantineState({
295
+ destination: "/var/lib/myapp/k-quarantine/op-123-1700000000000",
296
+ timestampMs: 1700000000000,
297
+ });
298
+ // backup.quarantinePath is the durable, non-secret audit location.
266
299
  ```
267
300
 
268
301
  The host may project this receipt into UI or transport, but must not maintain
@@ -39,7 +39,7 @@ function releaseFor(url: string): Release {
39
39
  async function serve(chunks: number, gapMs: number, hang = false, stallAfterFirst = false) {
40
40
  const server = http.createServer((_req, res) => {
41
41
  res.writeHead(200, { "Content-Length": String(BODY.length) });
42
- if (hang) return; // headers sent, bytes never follow
42
+ if (hang) { res.flushHeaders(); return; } // headers sent, bytes never follow
43
43
  const size = Math.ceil(BODY.length / chunks);
44
44
  let sent = 0;
45
45
  const push = (): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/k-carrier",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "packageManager": "pnpm@11.18.0",
5
5
  "description": "A fail-closed upgrade carrier for long-running managed services: two-slot upgrade transactions (promote/rollback), host-driven quiesce/resume handoff, and post-upgrade convergence read-back.",
6
6
  "repository": {