asylumwarden 0.0.0 → 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.
@@ -0,0 +1,142 @@
1
+ // Wave planning — docs/CONTRACT.md §3/§4; pinned signature:
2
+ // docs/DESIGN-INTERNALS.md §src/manifest/plan.mjs.
3
+ // The ledger arrives as ctx.ledger ({append, verify, readAll}) from the
4
+ // integration layer; this module never imports the ledger module itself.
5
+
6
+ import fs from "node:fs/promises";
7
+
8
+ import { fail, violation } from "../kernel/errors.mjs";
9
+ import { sha256Of } from "../kernel/canon.mjs";
10
+ import { atomicWriteJson, CAP_CONFIG, fileExists, nowIso, readJsonCapped } from "../kernel/fsutil.mjs";
11
+ import { HEX64_RE, readStateJson } from "../kernel/state.mjs";
12
+ import { assertNoDuplicateKeys } from "../config/load.mjs";
13
+ import { restoreArrayProtocols } from "../config/schemas.mjs";
14
+ import { validateManifest } from "./manifest.mjs";
15
+
16
+ // Integrity gate before any state read that could be poisoned by a broken
17
+ // chain: without a verifiable ledger, nothing may be planned.
18
+ async function assertLedgerHealthy(ctx) {
19
+ let verdict = null;
20
+ try {
21
+ verdict = (await ctx?.ledger?.verify?.()) ?? null;
22
+ } catch {
23
+ verdict = null;
24
+ }
25
+ if (verdict === null || verdict.chain_ok !== true) {
26
+ fail("LEDGER-BROKEN", "ledger missing or chain not verifiable; plan refused", [
27
+ violation("ledger", "chain", "unverifiable"),
28
+ ]);
29
+ }
30
+ }
31
+
32
+ // A --force re-plan may overwrite only inert cells; an in-flight (claimed) or
33
+ // finished (done) cell's state must never be silently erased.
34
+ async function assertOverwritable(sl, wave) {
35
+ let names;
36
+ try {
37
+ names = await fs.readdir(sl.cellsDir(wave));
38
+ } catch (error) {
39
+ if (error?.code === "ENOENT") return;
40
+ throw error;
41
+ }
42
+ for (const name of names) {
43
+ if (!name.endsWith(".json")) continue;
44
+ const cellId = name.slice(0, -".json".length);
45
+ const existing = await readStateJson(sl.cellFile(wave, cellId));
46
+ if (existing?.state === "claimed" || existing?.state === "done") {
47
+ fail("CELL-STATE", `--force refused: cell ${cellId} of wave ${wave} is ${existing.state}`, [
48
+ violation(`cells.${cellId}`, "cell-state", existing.state),
49
+ ]);
50
+ }
51
+ }
52
+ }
53
+
54
+ export async function planWave(ctx, { manifestPath, force = false }) {
55
+ await assertLedgerHealthy(ctx);
56
+
57
+ let raw;
58
+ try {
59
+ ({ value: raw } = await readJsonCapped(manifestPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
60
+ } catch (error) {
61
+ if (error?.code === "ENOENT") {
62
+ fail("MANIFEST-INVALID", `manifest file not found: ${String(manifestPath)}`, [
63
+ violation("manifestPath", "missing", String(manifestPath)),
64
+ ]);
65
+ }
66
+ if (error?.name === "WardenError") throw error;
67
+ fail("MANIFEST-INVALID", `cannot read manifest ${String(manifestPath)}: ${error?.message}`);
68
+ }
69
+ restoreArrayProtocols(raw);
70
+ await assertNoDuplicateKeys(manifestPath, "MANIFEST-INVALID");
71
+
72
+ const { manifest, warnings } = await validateManifest(raw, {
73
+ config: ctx.config,
74
+ registry: ctx.registry,
75
+ policy: ctx.policy,
76
+ });
77
+
78
+ const sl = ctx.sl;
79
+ const wave = manifest.wave;
80
+ if (await fileExists(sl.waveDir(wave))) {
81
+ if (!force) {
82
+ fail("WAVE-EXISTS", `wave ${wave} is already planned in ${sl.waveDir(wave)}`, [
83
+ violation("wave", "wave-exists", wave),
84
+ ]);
85
+ }
86
+ await assertOverwritable(sl, wave);
87
+ }
88
+
89
+ for (const value of [ctx.configSha256, ctx.registrySha256]) {
90
+ if (typeof value !== "string" || !HEX64_RE.test(value)) {
91
+ fail("INTERNAL", "planWave: ctx.configSha256 / ctx.registrySha256 must be 64-hex digests");
92
+ }
93
+ }
94
+
95
+ const plannedAt = nowIso();
96
+ const cells = {};
97
+ for (const cell of manifest.cells) {
98
+ const state = {
99
+ id: cell.id,
100
+ wave,
101
+ kind: cell.kind,
102
+ line: cell.line,
103
+ cwd: cell.cwd,
104
+ allowed_roots: cell.allowed_roots,
105
+ read_roots: cell.read_roots,
106
+ deps: cell.deps,
107
+ role: cell.role,
108
+ role_file: cell.role_file,
109
+ session_started_at: cell.session_started_at,
110
+ brief: cell.brief,
111
+ acceptance: cell.acceptance,
112
+ state: "pending",
113
+ epoch: 0,
114
+ planned_at: plannedAt,
115
+ updated_at: plannedAt,
116
+ };
117
+ await atomicWriteJson(sl.cellFile(wave, cell.id), state);
118
+ cells[cell.id] = sha256Of(state);
119
+ }
120
+ await atomicWriteJson(sl.metaFile(wave), {
121
+ wave,
122
+ config_sha256: ctx.configSha256,
123
+ registry_sha256: ctx.registrySha256,
124
+ created_at: plannedAt,
125
+ cells,
126
+ });
127
+
128
+ // The plan event is appended only after every write landed, so a failed
129
+ // plan never leaves a phantom wave on the ledger.
130
+ await ctx.ledger.append("plan", {
131
+ wave,
132
+ cell: null,
133
+ actor: null,
134
+ payload: { cells: Object.keys(cells) },
135
+ });
136
+
137
+ return {
138
+ wave,
139
+ cells: manifest.cells.map((c) => ({ id: c.id, kind: c.kind, line: c.line, cwd: c.cwd, state: "pending" })),
140
+ warnings,
141
+ };
142
+ }
@@ -0,0 +1,555 @@
1
+ // Receipt validation + done/fail placement — CONTRACT §7, DESIGN-INTERNALS
2
+ // §src/receipt.
3
+ //
4
+ // Module law (pinned):
5
+ // - Validation order is CONTRACT §7 EXACT: lease(actor→epoch) → receipt read
6
+ // (1 MiB cap, SCHEMA-VIOLATION) → schemalite → cell-id → commands →
7
+ // artifacts → reads → shadow extras → COMMIT. Any validation failure
8
+ // throws WardenError with ZERO state mutation.
9
+ // - The stored receipt FILE preserves the caller's exact bytes (evidence
10
+ // artifact): writeRawAtomic below is the kernel atomic-write pattern
11
+ // (tmp+fsync+rename+read-back) applied to raw bytes.
12
+ // - No lateral imports: this module re-implements its own lease loader
13
+ // rather than importing src/lease (layering rule).
14
+ // - Modules append only SUCCESS events; CLI appends rejected attempts.
15
+ // - ctx.nowFn() is the only clock source (tests inject).
16
+
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+
20
+ import { fail, violation } from "../kernel/errors.mjs";
21
+ import { sha256File, sha256Hex } from "../kernel/canon.mjs";
22
+ import { CAP_CONFIG, fileExists, nowIso, readJsonCapped } from "../kernel/fsutil.mjs";
23
+ import { readCell, writeCell, HEX64_RE, SAFE_ID_RE } from "../kernel/state.mjs";
24
+ import { withExclusiveLock } from "../kernel/lock.mjs";
25
+ import { validateSchema } from "../kernel/schemalite.mjs";
26
+
27
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
28
+ const LEASE_KEYS = new Set([
29
+ "actor",
30
+ "pid",
31
+ "host",
32
+ "epoch",
33
+ "acquired_at",
34
+ "heartbeat_at",
35
+ "expires_at",
36
+ "max_hold_until",
37
+ "ttl_seconds",
38
+ ]);
39
+
40
+ // ---------------------------------------------------------------- primitives
41
+
42
+ function nowIsoOf(ctx) {
43
+ return (ctx?.nowFn ?? nowIso)();
44
+ }
45
+
46
+ function msOf(iso, what) {
47
+ const ms = Date.parse(iso);
48
+ if (!Number.isFinite(ms)) fail("STATE-CORRUPT", `unparseable timestamp in ${what}: ${iso}`);
49
+ return ms;
50
+ }
51
+
52
+ function isIsoString(value) {
53
+ return typeof value === "string" && ISO_RE.test(value) && Number.isFinite(Date.parse(value));
54
+ }
55
+
56
+ function validateLeaseShape(owner, filePath) {
57
+ if (owner === null || typeof owner !== "object" || Array.isArray(owner)) {
58
+ fail("STATE-CORRUPT", `lease is not an object: ${filePath}`);
59
+ }
60
+ for (const key of Object.getOwnPropertyNames(owner)) {
61
+ if (!LEASE_KEYS.has(key)) {
62
+ fail("STATE-CORRUPT", `lease has unknown key "${key}": ${filePath}`, [
63
+ violation(filePath, "lease-unknown-key", key),
64
+ ]);
65
+ }
66
+ }
67
+ for (const key of LEASE_KEYS) {
68
+ if (!Object.hasOwn(owner, key)) {
69
+ fail("STATE-CORRUPT", `lease missing key "${key}": ${filePath}`, [
70
+ violation(filePath, "lease-missing-key", key),
71
+ ]);
72
+ }
73
+ }
74
+ if (typeof owner.actor !== "string" || owner.actor.length === 0) fail("STATE-CORRUPT", `lease actor invalid: ${filePath}`);
75
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) fail("STATE-CORRUPT", `lease pid invalid: ${filePath}`);
76
+ if (typeof owner.host !== "string" || owner.host.length === 0) fail("STATE-CORRUPT", `lease host invalid: ${filePath}`);
77
+ if (!Number.isInteger(owner.epoch) || owner.epoch < 1) fail("STATE-CORRUPT", `lease epoch invalid: ${filePath}`);
78
+ if (!Number.isInteger(owner.ttl_seconds) || owner.ttl_seconds < 30 || owner.ttl_seconds > 86400) {
79
+ fail("STATE-CORRUPT", `lease ttl_seconds invalid: ${filePath}`);
80
+ }
81
+ for (const key of ["acquired_at", "heartbeat_at", "expires_at", "max_hold_until"]) {
82
+ if (!isIsoString(owner[key])) fail("STATE-CORRUPT", `lease ${key} is not RFC3339: ${filePath}`);
83
+ }
84
+ }
85
+
86
+ function validateSafeId(name, value) {
87
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
88
+ fail("USAGE", `${name} must be a SafeId, got ${String(value)}`, [violation(name, "safe-id", String(value))]);
89
+ }
90
+ }
91
+
92
+ async function loadCellOrFail(ctx, wave, cell) {
93
+ validateSafeId("wave", wave);
94
+ validateSafeId("cell", cell);
95
+ if (!(await fileExists(ctx.sl.waveDir(wave)))) {
96
+ fail("WAVE-MISSING", `wave not found: ${wave}`, [violation("wave", "wave-missing", wave)]);
97
+ }
98
+ try {
99
+ return await readCell(ctx.sl, wave, cell);
100
+ } catch (error) {
101
+ if (error && error.code === "ENOENT") {
102
+ fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [violation("cell", "cell-missing", `${wave}/${cell}`)]);
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ // §7 step 1 authority: the live lease. Lease absent is a business reject
109
+ // (CELL-STATE, exit 1) — the terminal path of the §6 protocol (release first)
110
+ // is a retryable caller mistake. The deeper broken invariant "cell claimed
111
+ // but no lease file" stays fail-closed as STATE-CORRUPT.
112
+ async function loadLeaseForDone(ctx, wave, cell, cellRow) {
113
+ const filePath = ctx.sl.ownerFile(wave, cell);
114
+ let owner;
115
+ try {
116
+ ({ value: owner } = await readJsonCapped(filePath, undefined, "STATE-CORRUPT"));
117
+ } catch (error) {
118
+ if (error && error.code === "ENOENT") {
119
+ if (cellRow.state === "claimed") {
120
+ fail("STATE-CORRUPT", `cell ${wave}/${cell} is claimed but owner.json is missing`, [
121
+ violation("lease", "claimed-without-lease", ctx.sl.cellFile(wave, cell)),
122
+ ]);
123
+ }
124
+ fail("CELL-STATE", `no live lease for done on ${wave}/${cell}`, [violation("lease", "no-lease", "absent")]);
125
+ }
126
+ throw error;
127
+ }
128
+ validateLeaseShape(owner, filePath);
129
+ return owner;
130
+ }
131
+
132
+ function checkLeaseIdentity(owner, actor, epoch) {
133
+ if (owner.actor !== actor) {
134
+ fail("ACTOR-MISMATCH", `lease held by ${owner.actor}, done attempted as ${actor}`, [
135
+ violation("actor", "actor-mismatch", owner.actor),
136
+ ]);
137
+ }
138
+ if (owner.epoch !== epoch) {
139
+ fail("EPOCH-MISMATCH", `lease epoch is ${owner.epoch}, done attempted with ${epoch}`, [
140
+ violation("epoch", "epoch-mismatch", String(owner.epoch)),
141
+ ]);
142
+ }
143
+ }
144
+
145
+ // path.relative based containment (realpath'd inputs; no trailing-slash trap).
146
+ function isInside(root, abs) {
147
+ const rel = path.relative(root, abs);
148
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
149
+ }
150
+
151
+ // Receipt-provided paths must already be absolute and normalized (CONTRACT
152
+ // §1: the loader rejects non-normalized input; no ".." segments).
153
+ function assertNormalizedAbs(raw, code, what) {
154
+ if (typeof raw !== "string" || !raw.startsWith("/")) {
155
+ fail(code, `${what} must be an absolute path: ${String(raw)}`, [violation(what, "abs-path", String(raw))]);
156
+ }
157
+ if (path.posix.normalize(raw) !== raw) {
158
+ fail(code, `${what} is not a normalized absolute path: ${raw}`, [violation(what, "normalized", raw)]);
159
+ }
160
+ }
161
+
162
+ // Receipt schema is pinned in DESIGN-INTERNALS as loaded via import.meta.url;
163
+ // src/config/schemas.mjs is the integration loader, but lateral/upward
164
+ // imports are banned for this module, so it loads its own cached copy.
165
+ // Plain JSON.parse (NOT readJsonCapped): readJsonCapped null-prototypes every
166
+ // array, and kernel schemalite iterates SCHEMA keyword arrays (allOf/anyOf/
167
+ // required) with for...of, which requires Array.prototype. The VALUE side is
168
+ // iterated by index only, so the null-proto receipt value stays compatible.
169
+ // The schema file is this repo's own trusted artifact; size cap + parse still
170
+ // fail closed with INTERNAL.
171
+ let receiptSchemaCache = null;
172
+ async function loadReceiptSchema() {
173
+ if (receiptSchemaCache !== null) return receiptSchemaCache;
174
+ const schemaPath = path.resolve(import.meta.dirname, "..", "..", "schemas", "receipt.schema.json");
175
+ let raw;
176
+ try {
177
+ raw = await fs.readFile(schemaPath);
178
+ } catch (error) {
179
+ fail("INTERNAL", `cannot read receipt schema ${schemaPath}: ${error.message}`);
180
+ }
181
+ if (raw.length > CAP_CONFIG) {
182
+ fail("INTERNAL", `receipt schema exceeds ${CAP_CONFIG} byte cap`);
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw.toString("utf8"));
187
+ } catch {
188
+ fail("INTERNAL", `receipt schema is unparseable JSON: ${schemaPath}`);
189
+ }
190
+ receiptSchemaCache = parsed;
191
+ return receiptSchemaCache;
192
+ }
193
+
194
+ // Atomic raw-byte write mirroring kernel atomicWriteJson (tmp same-dir → full
195
+ // write → fsync → rename → read-back) but preserving the caller's exact
196
+ // bytes: the receipt file IS the evidence artifact, not a re-serialization.
197
+ async function writeRawAtomic(filePath, bytes) {
198
+ const dir = path.dirname(filePath);
199
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
200
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
201
+ let handle;
202
+ try {
203
+ handle = await fs.open(tmp, "w", 0o600);
204
+ await handle.writeFile(bytes);
205
+ await handle.sync();
206
+ } finally {
207
+ await handle?.close();
208
+ }
209
+ try {
210
+ await fs.rename(tmp, filePath);
211
+ } catch (error) {
212
+ await fs.rm(tmp, { force: true });
213
+ fail("INTERNAL", `atomic rename failed for ${filePath}: ${error.message}`);
214
+ }
215
+ const back = await fs.readFile(filePath);
216
+ if (!back.equals(Buffer.from(bytes))) {
217
+ fail("INTERNAL", `atomic write read-back mismatch: ${filePath}`);
218
+ }
219
+ }
220
+
221
+ // Validate artifact/claim hash etc. AFTER all containment checks pass.
222
+ async function validateArtifacts(ctx, receipt, cellRow) {
223
+ // Kernel null-prototype note: readJsonCapped arrays have no
224
+ // Symbol.iterator — iterate disk-read arrays by index only.
225
+ const artifacts = Array.isArray(receipt.artifacts) ? receipt.artifacts : [];
226
+ const reals = [];
227
+ for (let i = 0; i < artifacts.length; i += 1) {
228
+ const artifact = artifacts[i];
229
+ const artifactPath = artifact.path;
230
+ assertNormalizedAbs(artifactPath, "PATH-ESCAPE", "artifact.path");
231
+ let stat;
232
+ try {
233
+ stat = await fs.lstat(artifactPath);
234
+ } catch (error) {
235
+ if (error && error.code === "ENOENT") {
236
+ // Pinned choice: a nonexistent artifact is RECEIPT-HASH (exit 1,
237
+ // business) with detail "missing" — the claimed hash cannot exist.
238
+ // PATH-ESCAPE is reserved for containment failures.
239
+ fail("RECEIPT-HASH", `artifact missing: ${artifactPath}`, [
240
+ violation(artifactPath, "artifact-missing", "missing"),
241
+ ]);
242
+ }
243
+ // Unverifiable containment fails closed on the containment code.
244
+ fail("PATH-ESCAPE", `cannot lstat artifact ${artifactPath}: ${error.message}`, [
245
+ violation(artifactPath, "artifact-lstat", error.message),
246
+ ]);
247
+ }
248
+ if (stat.isSymbolicLink()) {
249
+ fail("PATH-ESCAPE", `artifact is a symlink: ${artifactPath}`, [
250
+ violation(artifactPath, "artifact-symlink", "lstat"),
251
+ ]);
252
+ }
253
+ if (!stat.isFile()) {
254
+ fail("PATH-ESCAPE", `artifact is not a regular file: ${artifactPath}`, [
255
+ violation(artifactPath, "artifact-not-file", "lstat"),
256
+ ]);
257
+ }
258
+ let real;
259
+ try {
260
+ real = await fs.realpath(artifactPath);
261
+ } catch (error) {
262
+ fail("PATH-ESCAPE", `cannot resolve artifact ${artifactPath}: ${error.message}`, [
263
+ violation(artifactPath, "artifact-realpath", error.message),
264
+ ]);
265
+ }
266
+ // Catches a symlink swap between lstat and realpath (TOCTOU guard).
267
+ if (real !== artifactPath) {
268
+ fail("PATH-ESCAPE", `artifact realpath differs from claimed path: ${artifactPath} -> ${real}`, [
269
+ violation(artifactPath, "artifact-swap", real),
270
+ ]);
271
+ }
272
+ // policy.artifactOk throws FORBIDDEN-SURFACE (forbidden hit first, per
273
+ // pinned artifactOk semantics) or PATH-ESCAPE (outside allowed_roots).
274
+ // Real policy impls are async (realpath inside); await so the
275
+ // rejection propagates instead of floating as a rejected promise.
276
+ await ctx.policy.artifactOk(real, cellRow);
277
+ const actual = await sha256File(real);
278
+ if (actual !== artifact.sha256) {
279
+ fail("RECEIPT-HASH", `artifact hash mismatch: ${artifactPath}`, [
280
+ violation(artifactPath, "hash-mismatch", `claimed ${artifact.sha256}, actual ${actual}`),
281
+ ]);
282
+ }
283
+ reals.push(real);
284
+ }
285
+ return reals;
286
+ }
287
+
288
+ async function validateReads(ctx, receipt, cellRow) {
289
+ const reads = Array.isArray(receipt.reads) ? receipt.reads : [];
290
+ const lineName = typeof cellRow.line === "string" ? cellRow.line : "";
291
+ for (let i = 0; i < reads.length; i += 1) {
292
+ const readPath = reads[i];
293
+ assertNormalizedAbs(readPath, "READ-OUTSIDE", "reads[]");
294
+ let real;
295
+ try {
296
+ real = await fs.realpath(readPath);
297
+ } catch {
298
+ // §7 step 6: every reads[] path must exist; absent is out-of-bounds.
299
+ fail("READ-OUTSIDE", `read path missing or unresolvable: ${readPath}`, [
300
+ violation(readPath, "read-missing", "absent"),
301
+ ]);
302
+ }
303
+ if (real !== readPath) {
304
+ fail("READ-OUTSIDE", `read realpath differs from claimed path: ${readPath} -> ${real}`, [
305
+ violation(readPath, "read-swap", real),
306
+ ]);
307
+ }
308
+ if ((await ctx.policy.readOk(real, cellRow, lineName)) !== true) {
309
+ fail("READ-OUTSIDE", `read path outside allowed union: ${real}`, [
310
+ violation(readPath, "read-outside", real),
311
+ ]);
312
+ }
313
+ }
314
+ }
315
+
316
+ // CONTRACT §10 shadow extras, run after the §7 core pass. Wave prefix and
317
+ // receipt shape are shape violations (SCHEMA-VIOLATION, exit 2); an artifact
318
+ // outside shadow_home is a containment failure (PATH-ESCAPE, exit 2).
319
+ function validateShadow(ctx, receipt, wave, artifactReals) {
320
+ if (ctx.mode !== "shadow") return;
321
+ if (receipt.mode !== "shadow" || !HEX64_RE.test(String(receipt.shadow_of ?? ""))) {
322
+ fail("SCHEMA-VIOLATION", `shadow receipt must carry mode="shadow" and a 64hex shadow_of`, [
323
+ violation("shadow_of", "shadow-shape", String(receipt.shadow_of)),
324
+ ]);
325
+ }
326
+ if (!wave.startsWith("shadow-")) {
327
+ fail("SCHEMA-VIOLATION", `shadow wave id must start with "shadow-", got ${wave}`, [
328
+ violation("wave", "shadow-prefix", wave),
329
+ ]);
330
+ }
331
+ const shadowHome = ctx.config?.shadow_home;
332
+ assertNormalizedAbs(shadowHome, "PATH-ESCAPE", "config.shadow_home");
333
+ for (const real of artifactReals) {
334
+ if (!isInside(shadowHome, real)) {
335
+ fail("PATH-ESCAPE", `shadow artifact outside shadow_home ${shadowHome}: ${real}`, [
336
+ violation(real, "shadow-home-containment", shadowHome),
337
+ ]);
338
+ }
339
+ }
340
+ }
341
+
342
+ // Normal-mode receipts must not carry shadow keys (CONTRACT §7: "No other
343
+ // keys, except in shadow mode..."). The schema's shadow branch is
344
+ // mode-agnostic, so the context check lives here.
345
+ function checkShadowKeyPurity(ctx, receipt) {
346
+ if (ctx.mode === "shadow") return;
347
+ if (Object.hasOwn(receipt, "mode") || Object.hasOwn(receipt, "shadow_of")) {
348
+ fail("SCHEMA-VIOLATION", "normal-mode receipt must not carry mode/shadow_of", [
349
+ violation("mode", "shadow-keys-in-normal-mode", "forbidden"),
350
+ ]);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * doneWithReceipt(ctx, {wave, cell, actor, epoch, receiptPath}) → {file, sha256}
356
+ * CONTRACT §7 exact order; zero mutation on any rejection.
357
+ */
358
+ async function doneLocked(ctx, { wave, cell, actor, epoch, receiptPath }) {
359
+ if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "done requires a non-empty actor");
360
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
361
+
362
+ // §7 step 1 — lease identity (actor before epoch, pinned).
363
+ const owner = await loadLeaseForDone(ctx, wave, cell, cellRow);
364
+ checkLeaseIdentity(owner, actor, epoch);
365
+
366
+ // §7 receipt read: 1 MiB cap, SCHEMA-VIOLATION family; ENOENT is mapped
367
+ // into the same code family (a caller mistake, exit 2 by the receipt code
368
+ // table since the file content cannot be repaired by waiting).
369
+ let parsed;
370
+ try {
371
+ ({ value: parsed } = await readJsonCapped(receiptPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
372
+ } catch (error) {
373
+ if (error && error.code === "ENOENT") {
374
+ fail("SCHEMA-VIOLATION", `receipt file missing: ${String(receiptPath)}`, [
375
+ violation("receipt", "receipt-missing", String(receiptPath)),
376
+ ]);
377
+ }
378
+ throw error;
379
+ }
380
+
381
+ // schemalite (unknown keys rejected — schema additionalProperties:false).
382
+ const schema = await loadReceiptSchema();
383
+ const violations = validateSchema(schema, parsed);
384
+ if (violations.length > 0) {
385
+ fail("SCHEMA-VIOLATION", `receipt violates receipt.schema.json: ${violations[0].field}: ${violations[0].rule}`, violations);
386
+ }
387
+
388
+ // §7 step 3 — cell id must match the target (contract pins SCHEMA-VIOLATION
389
+ // for this bullet).
390
+ if (parsed.cell !== cell) {
391
+ fail("SCHEMA-VIOLATION", `receipt cell ${String(parsed.cell)} does not match target ${cell}`, [
392
+ violation("cell", "cell-match", String(parsed.cell)),
393
+ ]);
394
+ }
395
+ checkShadowKeyPurity(ctx, parsed);
396
+
397
+ // §7 step 2 — status done requires every command exit 0. (Order within the
398
+ // §7 numbering: commands before artifacts/reads, after shape/cell match.)
399
+ const commands = Array.isArray(parsed.commands) ? parsed.commands : [];
400
+ if (parsed.status === "done") {
401
+ for (let i = 0; i < commands.length; i += 1) {
402
+ const command = commands[i];
403
+ if (command.exit !== 0) {
404
+ fail("RECEIPT-COMMANDS", `receipt status done but command exited ${command.exit}: ${command.cmd}`, [
405
+ violation("commands", "command-exit", String(command.exit)),
406
+ ]);
407
+ }
408
+ }
409
+ }
410
+
411
+ // §7 steps 4+5 — artifacts: containment (lstat/realpath/policy) first, then
412
+ // the hash recompute, for every artifact, before any read-path work.
413
+ const artifactReals = await validateArtifacts(ctx, parsed, cellRow);
414
+
415
+ // §7 step 6 — reads.
416
+ await validateReads(ctx, parsed, cellRow);
417
+
418
+ // §10 shadow extras.
419
+ validateShadow(ctx, parsed, wave, artifactReals);
420
+
421
+ // ---- COMMIT (all validation above passed; nothing mutated until here) ----
422
+ // Receipt FILE stores exactly the caller's bytes (evidence artifact). The
423
+ // stored sha256 and ledger receipt_sha256 are of these file bytes. Accepted
424
+ // time-of-validation window: §7 proves "content-at-time", the source file
425
+ // is caller-owned.
426
+ const rawBytes = await fs.readFile(receiptPath);
427
+ const stored = ctx.sl.receiptFile(wave, cell);
428
+ await writeRawAtomic(stored, rawBytes);
429
+ const receiptSha = sha256Hex(rawBytes);
430
+
431
+ const nowIsoStr = nowIsoOf(ctx);
432
+ const finalState = parsed.status === "done" ? "done" : "failed";
433
+ await writeCell(ctx.sl, wave, cell, { ...cellRow, state: finalState, updated_at: nowIsoStr });
434
+ // Finishing consumes the lease (CONTRACT §6/§7): a cell that is not
435
+ // claimed must not carry a lease, or requeue-after-fail would deadlock.
436
+ await fs.rm(ctx.sl.ownerFile(wave, cell), { force: true });
437
+ await ctx.ledger.append(finalState === "done" ? "done" : "fail", {
438
+ wave,
439
+ cell,
440
+ actor,
441
+ payload: { receipt_sha256: receiptSha, status: parsed.status },
442
+ });
443
+ return { file: stored, sha256: receiptSha };
444
+ }
445
+
446
+ /**
447
+ * failCell(ctx, {wave, cell, actor, epoch, reason}) → {file, sha256}
448
+ * Synthesizes a failed receipt and lands the cell failed. Same lease
449
+ * identity law as done; input validated before any mutation.
450
+ */
451
+ async function failCellLocked(ctx, { wave, cell, actor, epoch, reason }) {
452
+ if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "fail requires a non-empty actor");
453
+ if (typeof reason !== "string" || reason.length === 0) {
454
+ fail("USAGE", "fail requires a non-empty --reason", [violation("reason", "usage", "empty")]);
455
+ }
456
+ if (reason.length > 4096) {
457
+ fail("USAGE", `reason exceeds 4096-char string cap (${reason.length})`, [
458
+ violation("reason", "string-cap", String(reason.length)),
459
+ ]);
460
+ }
461
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
462
+ const owner = await loadLeaseForDone(ctx, wave, cell, cellRow);
463
+ checkLeaseIdentity(owner, actor, epoch);
464
+
465
+ const receipt = {
466
+ version: 1,
467
+ cell,
468
+ actor,
469
+ epoch: owner.epoch,
470
+ status: "failed",
471
+ summary: reason,
472
+ commands: [],
473
+ artifacts: [],
474
+ reads: [],
475
+ };
476
+ if (ctx.mode === "shadow") {
477
+ receipt.mode = "shadow";
478
+ receipt.shadow_of = ctx.config?.shadow_of;
479
+ if (typeof receipt.shadow_of !== "string" || !HEX64_RE.test(receipt.shadow_of)) {
480
+ // Config shape problem (exit-2 family), caught before any mutation.
481
+ fail("SCHEMA-VIOLATION", "shadow config lacks a 64hex shadow_of", [
482
+ violation("shadow_of", "config-shadow", String(receipt.shadow_of)),
483
+ ]);
484
+ }
485
+ }
486
+
487
+ const bytes = Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`, "utf8");
488
+ const stored = ctx.sl.receiptFile(wave, cell);
489
+ await writeRawAtomic(stored, bytes);
490
+ const receiptSha = sha256Hex(bytes);
491
+
492
+ const nowIsoStr = nowIsoOf(ctx);
493
+ await writeCell(ctx.sl, wave, cell, { ...cellRow, state: "failed", updated_at: nowIsoStr });
494
+ // Finishing consumes the lease (see doneWithReceipt).
495
+ await fs.rm(ctx.sl.ownerFile(wave, cell), { force: true });
496
+ await ctx.ledger.append("fail", {
497
+ wave,
498
+ cell,
499
+ actor,
500
+ payload: { reason, receipt_sha256: receiptSha, status: "failed" },
501
+ });
502
+ return { file: stored, sha256: receiptSha };
503
+ }
504
+
505
+ // Validation + commit run inside the per-cell lease lock (same critical
506
+ // section as claim/renew/release): lease identity checks, state flip, and
507
+ // owner removal must be atomic against a concurrent steal/reclaim, and a
508
+ // validation pass must not be based on a lease that vanished mid-check.
509
+
510
+ // Wave/cell existence is proven BEFORE locking: acquiring the lock runs
511
+ // ensureDir, and letting it mint directories for unknown waves would flip
512
+ // WAVE-MISSING into CELL-MISSING inside claimLocked's checks.
513
+ async function requireWaveCell(ctx, wave, cell) {
514
+ for (const [name, value] of [["wave", wave], ["cell", cell]]) {
515
+ if (typeof value !== "string" || !SAFE_ID_RE.test(value)) {
516
+ fail("USAGE", `${name} must be a safe id: ${String(value)}`, [
517
+ violation(name, "safe-id", String(value)),
518
+ ]);
519
+ }
520
+ }
521
+ const fs = await import("node:fs/promises");
522
+ try {
523
+ await fs.stat(ctx.sl.metaFile(wave));
524
+ } catch {
525
+ // plan always writes meta.json; the cells-dir alternative keeps
526
+ // hand-seeded wave fixtures working. Absent both = no wave.
527
+ try {
528
+ await fs.stat(ctx.sl.cellsDir(wave));
529
+ } catch {
530
+ fail("WAVE-MISSING", `no such wave: ${wave}`, [violation("wave", "wave-missing", wave)]);
531
+ }
532
+ }
533
+ try {
534
+ await fs.stat(ctx.sl.cellFile(wave, cell));
535
+ } catch {
536
+ fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [violation("cell", "cell-missing", `${wave}/${cell}`)]);
537
+ }
538
+ }
539
+
540
+ async function withCellLock(ctx, wave, cell, fn) {
541
+ await requireWaveCell(ctx, wave, cell);
542
+ return withExclusiveLock(
543
+ ctx.sl.leaseLock(wave, cell),
544
+ { busyCode: "LEASE-BUSY", busyMessage: `lease mutation busy for ${wave}/${cell}` },
545
+ fn,
546
+ );
547
+ }
548
+
549
+ export async function doneWithReceipt(ctx, args) {
550
+ return withCellLock(ctx, args.wave, args.cell, () => doneLocked(ctx, args));
551
+ }
552
+
553
+ export async function failCell(ctx, args) {
554
+ return withCellLock(ctx, args.wave, args.cell, () => failCellLocked(ctx, args));
555
+ }