release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,1361 @@
1
+ /**
2
+ * Transaction coordinator for artifact plan application.
3
+ *
4
+ * Implements the full preflight → PREPARED → APPLYING → COMMITTED flow
5
+ * with durable journaling, per-entry CAS, and crash recovery.
6
+ *
7
+ * ALL filesystem mutations go through the safe-fs backend DirectoryHandle.
8
+ * No Node path-based writes are used for journal, backup, or target files.
9
+ *
10
+ * @module artifacts/transaction
11
+ */
12
+
13
+ import { randomBytes } from 'node:crypto';
14
+ import { relative } from 'node:path';
15
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
16
+ import { canonicalArtifactPath } from './path-key.mjs';
17
+ import { acquireProjectLock } from './project-lock.mjs';
18
+
19
+ import {
20
+ ReleaseError,
21
+ PLAN_STALE,
22
+ TRANSACTION_INCOMPLETE,
23
+ SAFE_WRITE_UNAVAILABLE,
24
+ MISSING_PARAMETERS,
25
+ PATH_UNSAFE,
26
+ } from '../core/errors.mjs';
27
+
28
+ import {
29
+ createTransactionJournal,
30
+ readJournal,
31
+ writeJournalTransition,
32
+ recordAppliedEntry,
33
+ createBackup,
34
+ writeRecoveryRequiredFile,
35
+ } from './transaction-journal.mjs';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Plan digest computation (raw JSON-parsed form, no bytes decoding)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Compute the canonical plan digest from the raw JSON-parsed plan.
43
+ *
44
+ * Strips the planDigest field, then computes sha256 of canonicalJson.
45
+ * This matches how the test helper `computePlanDigest` works:
46
+ * the digest is computed from the JSON-serialised form of the plan,
47
+ * including Buffer-as-JSON representations of the bytes fields.
48
+ *
49
+ * @param {object} plan — raw JSON-parsed plan (bytes are plain objects).
50
+ * @returns {string} Canonical plan digest (sha256:hex).
51
+ */
52
+ function computeCanonicalPlanDigest(plan) {
53
+ const { planDigest: _ignored, ...content } = plan;
54
+ return `sha256:${sha256Hex(canonicalJson(content))}`;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Bytes decoding
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Decode bytes from JSON-serialised Buffer representation to actual Buffer.
63
+ *
64
+ * Handles:
65
+ * - { type: 'Buffer', data: [byte, ...] } — JSON.stringify output
66
+ * - Buffer object passthrough
67
+ *
68
+ * @param {*} raw — raw bytes field.
69
+ * @returns {Buffer} Decoded buffer.
70
+ * @throws {ReleaseError} PATH_UNSAFE if format is invalid.
71
+ */
72
+ function decodeBytes(raw) {
73
+ if (Buffer.isBuffer(raw)) {
74
+ return raw;
75
+ }
76
+ if (
77
+ raw
78
+ && typeof raw === 'object'
79
+ && raw.type === 'Buffer'
80
+ && Array.isArray(raw.data)
81
+ ) {
82
+ for (let i = 0; i < raw.data.length; i++) {
83
+ if (!Number.isInteger(raw.data[i]) || raw.data[i] < 0 || raw.data[i] > 255) {
84
+ throw new ReleaseError(
85
+ TRANSACTION_INCOMPLETE,
86
+ `bytes[${i}] must be an integer in range 0..255`,
87
+ { index: i },
88
+ );
89
+ }
90
+ }
91
+ return Buffer.from(raw.data);
92
+ }
93
+ throw new ReleaseError(PATH_UNSAFE, 'bytes field has invalid format');
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Path validation
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Validate a relative artifact path.
102
+ *
103
+ * @param {string} path — relative artifact path.
104
+ * @throws {ReleaseError} PATH_UNSAFE on violations.
105
+ */
106
+ function validatePath(path) {
107
+ canonicalArtifactPath(path);
108
+ }
109
+
110
+ /**
111
+ * Run an operation against the parent directory of a canonical artifact
112
+ * path. Child handles are always closed once, in reverse order. The root
113
+ * handle remains owned by the transaction coordinator.
114
+ */
115
+ async function withParentHandle(rootHandle, path, operation) {
116
+ const canonical = canonicalArtifactPath(path).path;
117
+ const segments = canonical.split('/');
118
+ const opened = [];
119
+ let current = rootHandle;
120
+ let result;
121
+ let primaryError;
122
+
123
+ try {
124
+ for (let i = 0; i < segments.length - 1; i++) {
125
+ current = await current.openDir(segments[i]);
126
+ opened.push(current);
127
+ }
128
+ result = await operation(current, segments[segments.length - 1]);
129
+ } catch (error) {
130
+ primaryError = error;
131
+ }
132
+
133
+ const closeFailures = [];
134
+ for (let i = opened.length - 1; i >= 0; i--) {
135
+ try {
136
+ await opened[i].close();
137
+ } catch (error) {
138
+ closeFailures.push(error?.code || error?.message || 'close failed');
139
+ }
140
+ }
141
+
142
+ if (primaryError) {
143
+ if (closeFailures.length > 0 && primaryError && typeof primaryError === 'object') {
144
+ primaryError.details = { ...(primaryError.details || {}), closeFailures };
145
+ }
146
+ throw primaryError;
147
+ }
148
+ if (closeFailures.length > 0) {
149
+ throw new ReleaseError(
150
+ TRANSACTION_INCOMPLETE,
151
+ 'safe filesystem child handle close failed',
152
+ { closeFailures },
153
+ );
154
+ }
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Collect canonical paths, check for duplicates and parent-child overlap.
160
+ *
161
+ * @param {object[]} artifacts — plan artifacts.
162
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on violations.
163
+ */
164
+ function validatePathUniqueness(artifacts) {
165
+ const paths = [];
166
+ for (const a of artifacts) {
167
+ if (!a.path || typeof a.path !== 'string') {
168
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `artifact ${a.id} missing path`);
169
+ }
170
+ const { path: canonical, collisionKey } = canonicalArtifactPath(a.path);
171
+ paths.push({ id: a.id, canonical, collisionKey });
172
+ }
173
+
174
+ const seen = new Set();
175
+ for (const p of paths) {
176
+ if (seen.has(p.collisionKey)) {
177
+ throw new ReleaseError(
178
+ TRANSACTION_INCOMPLETE,
179
+ `duplicate canonical path: ${p.canonical}`,
180
+ { path: p.canonical },
181
+ );
182
+ }
183
+ seen.add(p.collisionKey);
184
+ }
185
+
186
+ for (let i = 0; i < paths.length; i++) {
187
+ for (let j = i + 1; j < paths.length; j++) {
188
+ const a = paths[i].canonical;
189
+ const b = paths[j].canonical;
190
+ if (b.startsWith(a + '/') || a.startsWith(b + '/')) {
191
+ throw new ReleaseError(
192
+ TRANSACTION_INCOMPLETE,
193
+ `parent-child path overlap: ${a} and ${b}`,
194
+ { pathA: a, pathB: b },
195
+ );
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Entry validation and decoding
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Validate and decode an entry from the plan.
207
+ *
208
+ * P0-6: Validates entry closed schema, kind, bytes/sha256/size/mode for regular,
209
+ * and Buffer bytes range (0..255 per byte).
210
+ *
211
+ * @param {object} entry — plan entry (may be absent or regular).
212
+ * @param {string} label — 'oldEntry' or 'newEntry'.
213
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on violations.
214
+ */
215
+ function validateAndDecodeEntry(entry, label) {
216
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
217
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${label} must be an object`);
218
+ }
219
+
220
+ // P0-6: Validate entry closed schema
221
+ const ENTRY_SCHEMA_FIELDS = new Set(['kind', 'bytes', 'sha256', 'size', 'mode']);
222
+ for (const key of Object.keys(entry)) {
223
+ if (!ENTRY_SCHEMA_FIELDS.has(key)) {
224
+ throw new ReleaseError(
225
+ TRANSACTION_INCOMPLETE,
226
+ `${label} has unknown field: ${key}`,
227
+ { field: key },
228
+ );
229
+ }
230
+ }
231
+
232
+ // P0-6: Validate kind
233
+ const VALID_KINDS = new Set(['absent', 'regular']);
234
+ if (!VALID_KINDS.has(entry.kind)) {
235
+ throw new ReleaseError(
236
+ TRANSACTION_INCOMPLETE,
237
+ `${label} has invalid kind: ${entry.kind}`,
238
+ { kind: entry.kind },
239
+ );
240
+ }
241
+
242
+ if (entry.kind === 'absent') {
243
+ if (Object.keys(entry).length !== 1) {
244
+ throw new ReleaseError(
245
+ TRANSACTION_INCOMPLETE,
246
+ `${label} absent entry must contain only kind`,
247
+ );
248
+ }
249
+ return;
250
+ }
251
+
252
+ if (entry.kind !== 'regular') return;
253
+
254
+ const isNewEntry = label === 'newEntry';
255
+
256
+ if (isNewEntry && entry.bytes !== undefined && entry.bytes !== null) {
257
+ entry.bytes = decodeBytes(entry.bytes);
258
+
259
+ // P0-6: Validate Buffer bytes range (0..255 per byte)
260
+ for (let i = 0; i < entry.bytes.length; i++) {
261
+ if (entry.bytes[i] < 0 || entry.bytes[i] > 255) {
262
+ throw new ReleaseError(
263
+ TRANSACTION_INCOMPLETE,
264
+ `${label} bytes[${i}] is out of range 0..255: ${entry.bytes[i]}`,
265
+ { index: i, value: entry.bytes[i] },
266
+ );
267
+ }
268
+ }
269
+
270
+ if (typeof entry.sha256 === 'string' && /^(?:sha256:)?[0-9a-f]{64}$/.test(entry.sha256)) {
271
+ const expected = entry.sha256.replace(/^sha256:/, '');
272
+ const actual = sha256Hex(entry.bytes);
273
+ if (actual !== expected) {
274
+ throw new ReleaseError(
275
+ TRANSACTION_INCOMPLETE,
276
+ `${label} sha256 mismatch: expected ${expected}, got ${actual}`,
277
+ );
278
+ }
279
+ } else {
280
+ // P0-6: regular entry with bytes MUST have valid sha256
281
+ throw new ReleaseError(
282
+ TRANSACTION_INCOMPLETE,
283
+ `${label} regular entry with bytes must have sha256 in sha256:hex format`,
284
+ );
285
+ }
286
+
287
+ if (entry.size !== undefined && entry.size !== null) {
288
+ if (Number(entry.size) !== entry.bytes.length) {
289
+ throw new ReleaseError(
290
+ TRANSACTION_INCOMPLETE,
291
+ `${label} size mismatch: expected ${entry.size}, got ${entry.bytes.length}`,
292
+ );
293
+ }
294
+ } else {
295
+ // P0-6: regular entry with bytes MUST have size
296
+ throw new ReleaseError(
297
+ TRANSACTION_INCOMPLETE,
298
+ `${label} regular entry with bytes must have size`,
299
+ );
300
+ }
301
+ } else if (isNewEntry) {
302
+ // New regular entries carry the exact bytes to materialise.
303
+ throw new ReleaseError(
304
+ TRANSACTION_INCOMPLETE,
305
+ `${label} regular entry must have bytes`,
306
+ );
307
+ } else if (entry.bytes !== undefined) {
308
+ // Old bytes are deliberately not trusted from the plan. They are read
309
+ // from the identity-bound live entry after the per-entry CAS and then
310
+ // persisted in the transaction backup.
311
+ throw new ReleaseError(
312
+ TRANSACTION_INCOMPLETE,
313
+ `${label} regular entry must not contain bytes`,
314
+ );
315
+ }
316
+
317
+ if (typeof entry.sha256 !== 'string' || !/^(?:sha256:)?[0-9a-f]{64}$/.test(entry.sha256)) {
318
+ throw new ReleaseError(
319
+ TRANSACTION_INCOMPLETE,
320
+ `${label} regular entry must have a valid sha256`,
321
+ );
322
+ }
323
+
324
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
325
+ throw new ReleaseError(
326
+ TRANSACTION_INCOMPLETE,
327
+ `${label} regular entry must have a non-negative integer size`,
328
+ );
329
+ }
330
+
331
+ if (entry.mode !== undefined && entry.mode !== null) {
332
+ const modeStr = String(entry.mode);
333
+ // P0-6: Validate mode format (last 3 digits are octal)
334
+ const last3 = modeStr.slice(-3);
335
+ if (!/^[0-7]{3}$/.test(last3)) {
336
+ throw new ReleaseError(
337
+ TRANSACTION_INCOMPLETE,
338
+ `${label} has invalid mode: ${entry.mode}`,
339
+ );
340
+ }
341
+ } else {
342
+ // P0-6: regular entry MUST have mode
343
+ throw new ReleaseError(
344
+ TRANSACTION_INCOMPLETE,
345
+ `${label} regular entry must have mode`,
346
+ );
347
+ }
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // CAS validation (full)
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Validate an old entry against the current filesystem state using the
356
+ * safe-fs backend.
357
+ *
358
+ * @param {object} handle — root DirectoryHandle.
359
+ * @param {object} oldEntry — plan oldEntry.
360
+ * @param {string} canonicalPath — normalised artifact path.
361
+ * @throws {ReleaseError} PLAN_STALE on any mismatch.
362
+ */
363
+ async function assertFullCas(handle, oldEntry, canonicalPath) {
364
+ if (!oldEntry || typeof oldEntry !== 'object') return;
365
+
366
+ const current = await readCurrentEntry(handle, canonicalPath);
367
+
368
+ if (oldEntry.kind === 'absent') {
369
+ if (current.kind !== 'absent') {
370
+ throw new ReleaseError(
371
+ PLAN_STALE,
372
+ `CAS mismatch: ${canonicalPath} expected absent, got ${current.kind}`,
373
+ { path: canonicalPath, expected: 'absent', actual: current.kind },
374
+ );
375
+ }
376
+ return current;
377
+ }
378
+
379
+ if (oldEntry.kind === 'regular') {
380
+ if (current.kind !== 'regular') {
381
+ throw new ReleaseError(
382
+ PLAN_STALE,
383
+ `CAS mismatch: ${canonicalPath} expected regular, got ${current.kind}`,
384
+ { path: canonicalPath, expected: 'regular', actual: current.kind },
385
+ );
386
+ }
387
+
388
+ if (typeof oldEntry.sha256 === 'string') {
389
+ const expected = oldEntry.sha256.startsWith('sha256:')
390
+ ? oldEntry.sha256
391
+ : `sha256:${oldEntry.sha256}`;
392
+ if (current.sha256 !== expected) {
393
+ throw new ReleaseError(
394
+ PLAN_STALE,
395
+ `CAS mismatch: ${canonicalPath} sha256 changed`,
396
+ { path: canonicalPath, expected, actual: current.sha256 },
397
+ );
398
+ }
399
+ }
400
+
401
+ if (oldEntry.size !== undefined && oldEntry.size !== null) {
402
+ if (Number(oldEntry.size) !== current.size) {
403
+ throw new ReleaseError(
404
+ PLAN_STALE,
405
+ `CAS mismatch: ${canonicalPath} size changed`,
406
+ { path: canonicalPath, expected: Number(oldEntry.size), actual: current.size },
407
+ );
408
+ }
409
+ }
410
+
411
+ if (oldEntry.mode !== undefined && oldEntry.mode !== null) {
412
+ const modeStr = String(oldEntry.mode);
413
+ const last3 = modeStr.slice(-3);
414
+ if (/^[0-7]{3}$/.test(last3)) {
415
+ const expectedMode = parseInt(last3, 8);
416
+ const maskedExpected = expectedMode & 0o111 ? expectedMode : expectedMode & ~0o111;
417
+ const maskedActual = current.mode & 0o111 ? current.mode : current.mode & ~0o111;
418
+ if (maskedExpected !== maskedActual) {
419
+ throw new ReleaseError(
420
+ PLAN_STALE,
421
+ `CAS mismatch: ${canonicalPath} mode changed`,
422
+ {
423
+ path: canonicalPath,
424
+ expected: `0o${maskedExpected.toString(8)}`,
425
+ actual: `0o${maskedActual.toString(8)}`,
426
+ },
427
+ );
428
+ }
429
+ }
430
+ }
431
+ return current;
432
+ }
433
+ return current;
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Read current entry from safe-fs backend
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Read the current filesystem state of an artifact via the safe-fs backend.
442
+ *
443
+ * Maps real addon readEntry format { type, size, mode } to internal { kind, ... }.
444
+ * For regular files, reads bytes via readFile to compute sha256.
445
+ *
446
+ * @param {object} handle — root DirectoryHandle.
447
+ * @param {string} path — normalised relative path.
448
+ * @returns {Promise<object>} Entry with kind, sha256, size, mode.
449
+ */
450
+ async function readCurrentEntry(handle, path) {
451
+ return withParentHandle(handle, path, async (current, leaf) => {
452
+ const entry = await current.readEntry(leaf);
453
+
454
+ if (!entry || entry.kind === 'absent') {
455
+ return { kind: 'absent' };
456
+ }
457
+
458
+ // Map both the production addon shape ({type:'file'|'directory'}) and
459
+ // artifact-style recording backends ({kind:'regular'|'tree',type:'blob'}).
460
+ if (entry.type === 'directory' || entry.kind === 'tree') {
461
+ return { kind: 'tree', entries: [] };
462
+ }
463
+
464
+ const isRegular = entry.type === 'file'
465
+ || entry.type === 'blob'
466
+ || entry.kind === 'regular';
467
+ if (!isRegular) {
468
+ throw new ReleaseError(
469
+ PATH_UNSAFE,
470
+ `artifact path is not a regular file: ${entry.type}`,
471
+ { path, type: entry.type },
472
+ );
473
+ }
474
+
475
+ // For regular files, read bytes via readFile to compute sha256
476
+ const fileData = await current.readFile(leaf);
477
+ if (!fileData) return { kind: 'absent' };
478
+ if (Number(fileData.nlink) !== 1) {
479
+ throw new ReleaseError(
480
+ PATH_UNSAFE,
481
+ `artifact path has unexpected hard link count: ${path}`,
482
+ { path, nlink: Number(fileData.nlink) },
483
+ );
484
+ }
485
+
486
+ return {
487
+ kind: 'regular',
488
+ bytes: fileData.bytes,
489
+ sha256: `sha256:${sha256Hex(fileData.bytes)}`,
490
+ size: fileData.size,
491
+ mode: fileData.mode,
492
+ identityToken: fileData,
493
+ };
494
+ });
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Preflight and CAS (zero side effects)
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * Full preflight validation with zero filesystem side effects.
503
+ *
504
+ * P0-6: Validates apiVersion/bindings, plan schema, safeToWrite, artifact
505
+ * schema, path safety, path uniqueness, entry schema/decoding, Buffer
506
+ * bytes range (0..255), unknown fields/kinds, and full CAS for all old entries.
507
+ *
508
+ * @param {object} handle — root DirectoryHandle.
509
+ * @param {object} plan — decoded plan (bytes decoded in-place).
510
+ * @param {string} planPath — for error context.
511
+ * @throws {ReleaseError} On any validation failure.
512
+ */
513
+ async function performPreflightAndCas(handle, plan, planPath) {
514
+ if (!plan || typeof plan !== 'object') {
515
+ throw new ReleaseError(PLAN_STALE, 'plan is not a valid object', { path: planPath });
516
+ }
517
+
518
+ if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1') {
519
+ throw new ReleaseError(
520
+ TRANSACTION_INCOMPLETE,
521
+ 'plan apiVersion is missing or unsupported',
522
+ { apiVersion: plan.apiVersion },
523
+ );
524
+ }
525
+
526
+ if (typeof plan.bindings !== 'object' || plan.bindings === null || Array.isArray(plan.bindings)) {
527
+ throw new ReleaseError(
528
+ TRANSACTION_INCOMPLETE,
529
+ 'plan bindings must be an object',
530
+ );
531
+ }
532
+ const bindingFields = [
533
+ 'repositoryIdentity', 'policyDigest', 'baseManifestDigest',
534
+ 'currentManifestDigest', 'producerClosureDigest',
535
+ ];
536
+ if (Object.keys(plan.bindings).length !== bindingFields.length) {
537
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'plan bindings must use the closed v1 schema');
538
+ }
539
+ for (const field of bindingFields) {
540
+ if (typeof plan.bindings[field] !== 'string'
541
+ || !/^sha256:[0-9a-f]{64}$/.test(plan.bindings[field])) {
542
+ throw new ReleaseError(
543
+ TRANSACTION_INCOMPLETE,
544
+ `plan binding ${field} must be a sha256 digest`,
545
+ );
546
+ }
547
+ }
548
+
549
+ if (!plan.safeToWrite) {
550
+ throw new ReleaseError(
551
+ TRANSACTION_INCOMPLETE,
552
+ 'plan is not safe to write',
553
+ { safeToWrite: plan.safeToWrite },
554
+ );
555
+ }
556
+ if (!['inspect', 'status', 'apply'].includes(plan.operation)) {
557
+ throw new ReleaseError(
558
+ TRANSACTION_INCOMPLETE,
559
+ `plan operation is not applyable: ${plan.operation}`,
560
+ );
561
+ }
562
+ if (plan.targetUnchanged !== true) {
563
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'plan targetUnchanged must be true');
564
+ }
565
+ if (!plan.nextAction || typeof plan.nextAction !== 'object'
566
+ || Array.isArray(plan.nextAction)
567
+ || Object.keys(plan.nextAction).length !== 1
568
+ || typeof plan.nextAction.command !== 'string'
569
+ || !/\bartifacts apply\b/.test(plan.nextAction.command)) {
570
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'plan nextAction must be the apply command');
571
+ }
572
+ if (!Array.isArray(plan.artifacts)) {
573
+ throw new ReleaseError(PLAN_STALE, 'plan missing artifacts array', { path: planPath });
574
+ }
575
+
576
+ // P0-6: Validate no unknown plan fields (closed schema)
577
+ const PLAN_SCHEMA_FIELDS = new Set([
578
+ 'apiVersion', 'operation', 'bindings', 'safeToWrite', 'targetUnchanged',
579
+ 'nextAction', 'artifacts', 'planDigest',
580
+ ]);
581
+ for (const key of Object.keys(plan)) {
582
+ if (!PLAN_SCHEMA_FIELDS.has(key)) {
583
+ throw new ReleaseError(
584
+ TRANSACTION_INCOMPLETE,
585
+ `plan has unknown field: ${key}`,
586
+ { field: key },
587
+ );
588
+ }
589
+ }
590
+
591
+ // Validate and decode all entries (in-place bytes modification)
592
+ for (const artifact of plan.artifacts) {
593
+ if (!artifact.id || typeof artifact.id !== 'string') {
594
+ throw new ReleaseError(
595
+ TRANSACTION_INCOMPLETE,
596
+ 'artifact missing id or id is not a string',
597
+ { artifact },
598
+ );
599
+ }
600
+
601
+ // P0-6: Validate artifact closed schema
602
+ const ARTIFACT_SCHEMA_FIELDS = new Set([
603
+ 'id', 'path', 'oldEntry', 'newEntry', 'status', 'safeToWrite',
604
+ ]);
605
+ for (const key of Object.keys(artifact)) {
606
+ if (!ARTIFACT_SCHEMA_FIELDS.has(key)) {
607
+ throw new ReleaseError(
608
+ TRANSACTION_INCOMPLETE,
609
+ `artifact has unknown field: ${key}`,
610
+ { artifactId: artifact.id, field: key },
611
+ );
612
+ }
613
+ }
614
+
615
+ const VALID_ARTIFACT_STATUSES = new Set([
616
+ 'READY', 'CLEAN', 'NEW', 'HUMAN_CHANGED',
617
+ 'GENERATOR_CHANGED', 'MERGEABLE', 'RESOLVED',
618
+ ]);
619
+ if (!VALID_ARTIFACT_STATUSES.has(artifact.status)) {
620
+ throw new ReleaseError(
621
+ TRANSACTION_INCOMPLETE,
622
+ `artifact has invalid or blocking status: ${artifact.status}`,
623
+ { artifactId: artifact.id, status: artifact.status },
624
+ );
625
+ }
626
+ if (artifact.safeToWrite !== true) {
627
+ throw new ReleaseError(
628
+ TRANSACTION_INCOMPLETE,
629
+ `artifact is not explicitly safe to write: ${artifact.id}`,
630
+ { artifactId: artifact.id, safeToWrite: artifact.safeToWrite },
631
+ );
632
+ }
633
+
634
+ validateAndDecodeEntry(artifact.newEntry, 'newEntry');
635
+ validateAndDecodeEntry(artifact.oldEntry, 'oldEntry');
636
+ }
637
+
638
+ // Path validation
639
+ for (const artifact of plan.artifacts) {
640
+ validatePath(artifact.path);
641
+ }
642
+ validatePathUniqueness(plan.artifacts);
643
+
644
+ // Full CAS for all old entries (zero side effects)
645
+ for (const artifact of plan.artifacts) {
646
+ const canonicalPath = artifact.path.endsWith('/')
647
+ ? artifact.path.slice(0, -1)
648
+ : artifact.path;
649
+ await assertFullCas(handle, artifact.oldEntry, canonicalPath);
650
+ }
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Manifest building
655
+ // ---------------------------------------------------------------------------
656
+
657
+ /**
658
+ * Build old manifest with backup data (bytes for regular files).
659
+ *
660
+ * @param {object} handle — root DirectoryHandle.
661
+ * @param {object[]} artifacts — plan artifacts.
662
+ * @returns {Promise<object[]>} Old manifest entries.
663
+ */
664
+ async function buildOldManifest(handle, artifacts) {
665
+ const manifest = [];
666
+ for (const artifact of artifacts) {
667
+ const canonicalPath = artifact.path.endsWith('/')
668
+ ? artifact.path.slice(0, -1)
669
+ : artifact.path;
670
+ const current = await assertFullCas(handle, artifact.oldEntry, canonicalPath);
671
+ if (artifact.oldEntry && artifact.oldEntry.kind === 'regular') {
672
+ manifest.push({
673
+ path: canonicalPath,
674
+ kind: 'regular',
675
+ sha256: current.sha256,
676
+ size: current.size,
677
+ mode: current.mode,
678
+ bytes: current.bytes,
679
+ });
680
+ } else {
681
+ manifest.push({
682
+ path: canonicalPath,
683
+ kind: 'absent',
684
+ absent: true,
685
+ });
686
+ }
687
+ }
688
+ return manifest;
689
+ }
690
+
691
+ /**
692
+ * Build new manifest from plan newEntry.
693
+ *
694
+ * @param {object[]} artifacts — plan artifacts (bytes already decoded).
695
+ * @returns {object[]} New manifest entries.
696
+ */
697
+ function buildNewManifest(artifacts) {
698
+ return artifacts.map((a) => {
699
+ const canonicalPath = a.path.endsWith('/') ? a.path.slice(0, -1) : a.path;
700
+ if (a.newEntry && a.newEntry.kind === 'regular') {
701
+ return {
702
+ path: canonicalPath,
703
+ kind: 'regular',
704
+ sha256: a.newEntry.sha256.startsWith('sha256:')
705
+ ? a.newEntry.sha256
706
+ : `sha256:${a.newEntry.sha256}`,
707
+ size: a.newEntry.size,
708
+ mode: a.newEntry.mode,
709
+ };
710
+ }
711
+ return { path: canonicalPath, kind: 'absent' };
712
+ });
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // Apply single artifact through handle
717
+ // ---------------------------------------------------------------------------
718
+
719
+ /**
720
+ * Apply a single artifact mutation through the safe-fs handle.
721
+ *
722
+ * @param {object} handle — root DirectoryHandle.
723
+ * @param {object} artifact — plan artifact (bytes decoded).
724
+ * @returns {Promise<void>}
725
+ */
726
+ async function applySingleArtifact(handle, artifact, expectedIdentity = null) {
727
+ const canonicalPath = artifact.path.endsWith('/')
728
+ ? artifact.path.slice(0, -1)
729
+ : artifact.path;
730
+ return withParentHandle(handle, canonicalPath, async (parent, leaf) => {
731
+ if (artifact.newEntry && artifact.newEntry.kind === 'absent') {
732
+ await parent.unlink(leaf);
733
+ await parent.fsync();
734
+ return;
735
+ }
736
+
737
+ if (artifact.newEntry && artifact.newEntry.kind === 'regular') {
738
+ const bytes = Buffer.from(artifact.newEntry.bytes);
739
+ const mode = parseMode(artifact.newEntry.mode);
740
+ const token = await parent.createTemp(leaf, mode, bytes);
741
+ try {
742
+ await parent.rename(token, leaf, expectedIdentity);
743
+ } catch (renameError) {
744
+ try {
745
+ const abortResult = await parent.abortTemp(token);
746
+ if (!abortResult?.removed) {
747
+ renameError.details = {
748
+ ...(renameError.details || {}),
749
+ abortResult,
750
+ };
751
+ }
752
+ } catch (abortError) {
753
+ renameError.details = {
754
+ ...(renameError.details || {}),
755
+ abortError: abortError?.code || abortError?.message || 'abort failed',
756
+ };
757
+ }
758
+ throw renameError;
759
+ }
760
+ await parent.fsync();
761
+ }
762
+ });
763
+ }
764
+
765
+ /**
766
+ * Parse mode from plan entry (string '100644' → number 0o644).
767
+ *
768
+ * @param {*} mode — mode value from plan.
769
+ * @returns {number} Permission bits as number.
770
+ */
771
+ function parseMode(mode) {
772
+ if (mode === undefined || mode === null) return 0o600;
773
+ const modeStr = String(mode);
774
+ const last3 = modeStr.slice(-3);
775
+ if (/^[0-7]{3}$/.test(last3)) {
776
+ return parseInt(last3, 8);
777
+ }
778
+ return 0o600;
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // Manifest verification
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Verify the final state matches the plan's new entries.
787
+ *
788
+ * @param {object} handle — root DirectoryHandle.
789
+ * @param {object[]} artifacts — plan artifacts.
790
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on mismatch.
791
+ */
792
+ async function verifyManifest(handle, artifacts) {
793
+ for (const artifact of artifacts) {
794
+ const canonicalPath = artifact.path.endsWith('/')
795
+ ? artifact.path.slice(0, -1)
796
+ : artifact.path;
797
+
798
+ if (artifact.newEntry && artifact.newEntry.kind === 'absent') {
799
+ await assertFullCas(handle, { kind: 'absent' }, canonicalPath);
800
+ }
801
+
802
+ if (artifact.newEntry && artifact.newEntry.kind === 'regular') {
803
+ const expected = {
804
+ kind: 'regular',
805
+ sha256: artifact.newEntry.sha256,
806
+ size: artifact.newEntry.size,
807
+ mode: artifact.newEntry.mode,
808
+ };
809
+ await assertFullCas(handle, expected, canonicalPath);
810
+ }
811
+ }
812
+ }
813
+
814
+ // ---------------------------------------------------------------------------
815
+ // Target unchanged check
816
+ // ---------------------------------------------------------------------------
817
+
818
+ /**
819
+ * Check whether all target files are still unchanged (matching oldEntry).
820
+ *
821
+ * @param {object} handle — root DirectoryHandle.
822
+ * @param {object[]} artifacts — plan artifacts.
823
+ * @returns {Promise<boolean>} true if all targets match oldEntry state.
824
+ */
825
+ async function checkTargetUnchanged(handle, artifacts) {
826
+ try {
827
+ for (const artifact of artifacts) {
828
+ const canonicalPath = artifact.path.endsWith('/')
829
+ ? artifact.path.slice(0, -1)
830
+ : artifact.path;
831
+ if (artifact.oldEntry && artifact.oldEntry.kind === 'regular') {
832
+ await assertFullCas(handle, artifact.oldEntry, canonicalPath);
833
+ }
834
+ if (artifact.oldEntry && artifact.oldEntry.kind === 'absent') {
835
+ await assertFullCas(handle, { kind: 'absent' }, canonicalPath);
836
+ }
837
+ }
838
+ return true;
839
+ } catch {
840
+ return false;
841
+ }
842
+ }
843
+
844
+ // ---------------------------------------------------------------------------
845
+ // Recovery protocol
846
+ // ---------------------------------------------------------------------------
847
+
848
+ /**
849
+ * Attempt to transition journal to RECOVERY_REQUIRED and write marker file.
850
+ *
851
+ * P0-8: PREPARED后普通primitive error转为durable RECOVERY_REQUIRED,
852
+ * 保留原cause code、transactionId、真实targetUnchanged、唯一recover命令。
853
+ * journal建立前失败不得谎称recovery。
854
+ *
855
+ * @param {object} options
856
+ * @param {object} options.backend — safe-fs backend.
857
+ * @param {string} options.root — repository root.
858
+ * @param {string} options.transactionId — transaction ID.
859
+ * @param {Error} options.originalError — the error that triggered recovery.
860
+ * @param {object[]} options.artifacts — plan artifacts.
861
+ * @param {boolean} options.journalCreated — whether journal exists.
862
+ * @returns {Promise<{ recoveryError: Error|null, targetUnchanged: boolean }>}
863
+ */
864
+ async function tryRecoveryProtocol({
865
+ rootHandle,
866
+ txnHandle,
867
+ transactionId,
868
+ originalError,
869
+ artifacts,
870
+ journalCreated,
871
+ }) {
872
+ // P0-8: Journal建立前失败不得谎称recovery
873
+ if (!journalCreated) {
874
+ return { recoveryError: null, targetUnchanged: false };
875
+ }
876
+
877
+ // P0-8: Real target unchanged check
878
+ let targetUnchanged = false;
879
+ try {
880
+ targetUnchanged = await checkTargetUnchanged(rootHandle, artifacts);
881
+ } catch {
882
+ targetUnchanged = false;
883
+ }
884
+
885
+ let journalState = 'unknown';
886
+ try {
887
+ const journal = await readJournal(txnHandle, transactionId);
888
+ journalState = journal.state;
889
+ } catch {
890
+ journalState = 'unreadable';
891
+ }
892
+
893
+ // P0-8: Unique recover command
894
+ const recover = `release-skill artifacts recover --transaction ${transactionId}`;
895
+
896
+ let recoveryStatePersisted = journalState === 'RECOVERY_REQUIRED';
897
+ let transitionErrorCode = null;
898
+ if (['PREPARED', 'APPLYING', 'APPLIED', 'VERIFYING'].includes(journalState)) {
899
+ try {
900
+ await writeJournalTransition({
901
+ txnHandle,
902
+ transactionId,
903
+ from: journalState,
904
+ to: 'RECOVERY_REQUIRED',
905
+ });
906
+ recoveryStatePersisted = true;
907
+ } catch (error) {
908
+ transitionErrorCode = error?.code || 'UNKNOWN';
909
+ }
910
+ }
911
+
912
+ let recoveryMarkerPersisted = false;
913
+ let markerErrorCode = null;
914
+ try {
915
+ await writeRecoveryRequiredFile({
916
+ txnHandle,
917
+ transactionId,
918
+ targetUnchanged,
919
+ recover,
920
+ });
921
+ recoveryMarkerPersisted = true;
922
+ } catch (error) {
923
+ markerErrorCode = error?.code || 'UNKNOWN';
924
+ }
925
+
926
+ // Never claim a durable RECOVERY_REQUIRED state when the journal transition
927
+ // itself could not be persisted. The marker is supplementary evidence, not
928
+ // a substitute for the authoritative state machine.
929
+ const recoveryError = new ReleaseError(
930
+ TRANSACTION_INCOMPLETE,
931
+ recoveryStatePersisted
932
+ ? `${originalError.message}. ${recover}`
933
+ : `transaction failed and RECOVERY_REQUIRED could not be persisted. ${recover}`,
934
+ {
935
+ transactionId,
936
+ targetUnchanged,
937
+ recover,
938
+ cause: originalError.code || null,
939
+ causeMessage: originalError.message || null,
940
+ recoveryDurable: recoveryStatePersisted,
941
+ recoveryMarkerPersisted,
942
+ journalState,
943
+ transitionErrorCode,
944
+ markerErrorCode,
945
+ },
946
+ );
947
+ recoveryError.transactionId = transactionId;
948
+
949
+ return { recoveryError, targetUnchanged };
950
+ }
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // Transaction ID generation
954
+ // ---------------------------------------------------------------------------
955
+
956
+ function generateTransactionId(clock) {
957
+ const timeSeed = clock ? clock() : Date.now();
958
+ const timeDigest = sha256Hex(String(timeSeed)).slice(0, 12);
959
+ return `txn-${timeDigest}-${randomBytes(8).toString('hex')}`;
960
+ }
961
+
962
+ // ---------------------------------------------------------------------------
963
+ // Public API
964
+ // ---------------------------------------------------------------------------
965
+
966
+ /**
967
+ * Apply an artifact plan with durable transaction journaling.
968
+ *
969
+ * All filesystem mutations use the safe-fs backend. No Node path writes.
970
+ *
971
+ * @param {object} options
972
+ * @param {string} options.root — Repository root (absolute).
973
+ * @param {string} options.planPath — Path to the artifact plan file.
974
+ * @param {string} options.planDigest — Expected plan digest.
975
+ * @param {object} [options.safeFs] — Safe filesystem backend.
976
+ * @param {Function} [options.faultInjector] — Fault injection for testing.
977
+ * @param {Function} [options.clock] — Clock function for timestamps.
978
+ * @returns {Promise<TransactionResult>}
979
+ * @throws {ReleaseError} On validation failure or CAS mismatch.
980
+ */
981
+ async function applyArtifactPlanUnderLock({
982
+ root,
983
+ planPath,
984
+ planDigest,
985
+ safeFs,
986
+ faultInjector,
987
+ clock,
988
+ assertLockOwner = async () => {},
989
+ } = {}) {
990
+ // === PHASE 0: validate inputs and safe-fs availability ===
991
+
992
+ if (!root || typeof root !== 'string') {
993
+ throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
994
+ }
995
+ if (!planPath || typeof planPath !== 'string') {
996
+ throw new ReleaseError(MISSING_PARAMETERS, 'planPath is required');
997
+ }
998
+ if (!planDigest || typeof planDigest !== 'string') {
999
+ throw new ReleaseError(MISSING_PARAMETERS, 'planDigest is required');
1000
+ }
1001
+ if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
1002
+ throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
1003
+ }
1004
+ if (!safeFs) {
1005
+ throw new ReleaseError(
1006
+ SAFE_WRITE_UNAVAILABLE,
1007
+ 'safe filesystem backend is required',
1008
+ );
1009
+ }
1010
+
1011
+ // === PHASE 1: validate plan file through safe-fs ===
1012
+
1013
+ const handle = await safeFs.openRoot(root);
1014
+ let txnResult;
1015
+ const assertLockAuthority = async () => {
1016
+ try {
1017
+ await assertLockOwner();
1018
+ } catch (error) {
1019
+ if (error && typeof error === 'object') error.lockOwnershipLost = true;
1020
+ throw error;
1021
+ }
1022
+ };
1023
+ try {
1024
+
1025
+ // Convert the absolute plan path to a canonical root-relative path before
1026
+ // any fd-relative access. This rejects root itself and all escape spellings.
1027
+ const relPlanPath = canonicalArtifactPath(relative(root, planPath)).path;
1028
+ const planFileData = await withParentHandle(handle, relPlanPath, async (parent, leaf) => {
1029
+ const planEntry = await parent.readEntry(leaf);
1030
+ if (!planEntry || planEntry.kind === 'absent') {
1031
+ throw new ReleaseError(PLAN_STALE, 'plan file does not exist', { path: planPath });
1032
+ }
1033
+ const planIsRegular = planEntry.kind === 'regular'
1034
+ || planEntry.type === 'file'
1035
+ || planEntry.type === 'blob';
1036
+ if (!planIsRegular) {
1037
+ throw new ReleaseError(PATH_UNSAFE, 'plan path is not a regular file', { path: planPath });
1038
+ }
1039
+ if (typeof planEntry.nlink === 'number' && planEntry.nlink !== 1) {
1040
+ throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
1041
+ }
1042
+ const data = await parent.readFile(leaf);
1043
+ if (!data) {
1044
+ throw new ReleaseError(PLAN_STALE, 'plan file is unreadable', { path: planPath });
1045
+ }
1046
+ if (Number(data.nlink) !== 1) {
1047
+ throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
1048
+ }
1049
+ return data;
1050
+ });
1051
+
1052
+ let plan;
1053
+ try {
1054
+ plan = JSON.parse(planFileData.bytes.toString('utf8'));
1055
+ } catch (err) {
1056
+ throw new ReleaseError(
1057
+ PLAN_STALE,
1058
+ 'plan file is not valid JSON',
1059
+ { path: planPath, error: err.message },
1060
+ );
1061
+ }
1062
+
1063
+ // === PHASE 2: recompute canonical plan digest ===
1064
+
1065
+ const recomputedDigest = computeCanonicalPlanDigest(plan);
1066
+ if (plan.planDigest !== planDigest || recomputedDigest !== planDigest) {
1067
+ throw new ReleaseError(
1068
+ PLAN_STALE,
1069
+ 'plan digest does not match expected',
1070
+ { expected: planDigest, embedded: plan.planDigest, actual: recomputedDigest },
1071
+ );
1072
+ }
1073
+
1074
+ // === PHASE 3: full preflight + CAS (zero side effects) ===
1075
+
1076
+ await performPreflightAndCas(handle, plan, planPath);
1077
+
1078
+ if (faultInjector) {
1079
+ await faultInjector('preflight-complete');
1080
+ }
1081
+
1082
+ // === PHASE 4: probe safe-fs backend ===
1083
+
1084
+ const probeResult = await safeFs.probe(root);
1085
+ if (!probeResult.supported) {
1086
+ throw new ReleaseError(
1087
+ SAFE_WRITE_UNAVAILABLE,
1088
+ 'safe write primitives are not functional on this platform',
1089
+ { platform: process.platform },
1090
+ );
1091
+ }
1092
+ if (faultInjector) await faultInjector('after-probe');
1093
+ await assertLockAuthority();
1094
+
1095
+ // === PHASE 5: create transaction and journal ===
1096
+
1097
+ const transactionId = generateTransactionId(clock);
1098
+ let journalCreated = false;
1099
+
1100
+ const oldManifest = await buildOldManifest(handle, plan.artifacts);
1101
+ const newManifest = buildNewManifest(plan.artifacts);
1102
+
1103
+ try {
1104
+ await assertLockAuthority();
1105
+ txnResult = await createTransactionJournal({
1106
+ rootHandle: handle,
1107
+ transactionId,
1108
+ planDigest,
1109
+ canonicalPlan: plan,
1110
+ oldManifest,
1111
+ newManifest,
1112
+ });
1113
+ journalCreated = true;
1114
+ } catch (journalErr) {
1115
+ // Journal creation failed — no side effects, clean failure
1116
+ throw journalErr;
1117
+ }
1118
+
1119
+ const { txnHandle } = txnResult;
1120
+
1121
+ try {
1122
+ if (faultInjector) await faultInjector('after-prepared');
1123
+ // === PHASE 5a: transition to APPLYING ===
1124
+
1125
+ await assertLockAuthority();
1126
+ await writeJournalTransition({
1127
+ txnHandle,
1128
+ transactionId,
1129
+ from: 'PREPARED',
1130
+ to: 'APPLYING',
1131
+ });
1132
+ if (faultInjector) await faultInjector('after-applying-transition');
1133
+
1134
+ // === PHASE 6: apply each artifact with write-ahead journaling ===
1135
+
1136
+ const results = [];
1137
+ for (let i = 0; i < plan.artifacts.length; i++) {
1138
+ const artifact = plan.artifacts[i];
1139
+
1140
+ // Write-ahead: record entry index BEFORE mutation
1141
+ await assertLockAuthority();
1142
+ await recordAppliedEntry({
1143
+ txnHandle,
1144
+ transactionId,
1145
+ entryIndex: i,
1146
+ entry: { id: artifact.id, path: artifact.path, status: 'pending' },
1147
+ });
1148
+ if (faultInjector) await faultInjector(`after-entry-pending:${i}`);
1149
+
1150
+ // P0-4: Re-exact CAS before each target mutation
1151
+ const canonicalPath = artifact.path.endsWith('/')
1152
+ ? artifact.path.slice(0, -1)
1153
+ : artifact.path;
1154
+ const current = await assertFullCas(handle, artifact.oldEntry, canonicalPath);
1155
+
1156
+ // P0-4: Create backup from the SAME stable read as CAS verification
1157
+ // backup bytes must come from this CAS read, not a separate one
1158
+ if (artifact.oldEntry && artifact.oldEntry.kind === 'regular') {
1159
+ // The exact bytes and unforgeable identity token come from the same
1160
+ // stable read used for this per-entry CAS.
1161
+ const expectedOldSha = artifact.oldEntry.sha256.startsWith('sha256:')
1162
+ ? artifact.oldEntry.sha256
1163
+ : `sha256:${artifact.oldEntry.sha256}`;
1164
+ if (current.sha256 !== expectedOldSha) {
1165
+ throw new ReleaseError(
1166
+ PLAN_STALE,
1167
+ `CAS mismatch during backup read: ${canonicalPath} sha256 changed`,
1168
+ { path: canonicalPath },
1169
+ );
1170
+ }
1171
+ await assertLockAuthority();
1172
+ await createBackup({
1173
+ txnHandle,
1174
+ transactionId,
1175
+ entryIndex: i,
1176
+ oldEntry: { ...artifact.oldEntry, bytes: current.bytes },
1177
+ });
1178
+ } else {
1179
+ await assertLockAuthority();
1180
+ await createBackup({
1181
+ txnHandle,
1182
+ transactionId,
1183
+ entryIndex: i,
1184
+ oldEntry: { kind: 'absent' },
1185
+ });
1186
+ }
1187
+ if (faultInjector) await faultInjector(`after-entry-backup:${i}`);
1188
+
1189
+ // Apply artifact mutation through safe-fs handle
1190
+ if (faultInjector) await faultInjector(`before-entry-mutation:${i}`);
1191
+ await assertLockAuthority();
1192
+ await applySingleArtifact(
1193
+ handle,
1194
+ artifact,
1195
+ current?.kind === 'regular' ? current.identityToken : null,
1196
+ );
1197
+ if (faultInjector) await faultInjector(`after-entry-mutation:${i}`);
1198
+
1199
+ // Record applied
1200
+ await assertLockAuthority();
1201
+ await recordAppliedEntry({
1202
+ txnHandle,
1203
+ transactionId,
1204
+ entryIndex: i,
1205
+ entry: { id: artifact.id, path: artifact.path, status: 'applied' },
1206
+ });
1207
+ if (faultInjector) await faultInjector(`after-entry-applied:${i}`);
1208
+
1209
+ results.push({ id: artifact.id, path: artifact.path, applied: true });
1210
+
1211
+ }
1212
+
1213
+ // === PHASE 7: mark APPLIED → VERIFYING → COMMITTED ===
1214
+
1215
+ await assertLockAuthority();
1216
+ await writeJournalTransition({
1217
+ txnHandle,
1218
+ transactionId,
1219
+ from: 'APPLYING',
1220
+ to: 'APPLIED',
1221
+ });
1222
+ if (faultInjector) await faultInjector('after-applied-transition');
1223
+
1224
+ await assertLockAuthority();
1225
+ await writeJournalTransition({
1226
+ txnHandle,
1227
+ transactionId,
1228
+ from: 'APPLIED',
1229
+ to: 'VERIFYING',
1230
+ });
1231
+ if (faultInjector) await faultInjector('after-verifying-transition');
1232
+
1233
+ await verifyManifest(handle, plan.artifacts);
1234
+ if (faultInjector) await faultInjector('after-verify');
1235
+
1236
+ await assertLockAuthority();
1237
+ await writeJournalTransition({
1238
+ txnHandle,
1239
+ transactionId,
1240
+ from: 'VERIFYING',
1241
+ to: 'COMMITTED',
1242
+ });
1243
+ if (faultInjector) await faultInjector('after-committed');
1244
+
1245
+ const finalJournal = await readJournal(txnHandle, transactionId);
1246
+
1247
+ return Object.freeze({
1248
+ transactionId,
1249
+ state: 'COMMITTED',
1250
+ results: Object.freeze(results),
1251
+ journal: finalJournal,
1252
+ });
1253
+ } catch (applyErr) {
1254
+ // === RECOVERY PROTOCOL ===
1255
+
1256
+ // A fault hook marked as a hard crash models abrupt process death: the
1257
+ // latest durable journal state must remain untouched for the next process.
1258
+ if (applyErr?.code === 'INJECTED_CRASH' || applyErr?.name === 'InjectedCrash') {
1259
+ throw applyErr;
1260
+ }
1261
+ if (applyErr?.lockOwnershipLost === true) {
1262
+ throw applyErr;
1263
+ }
1264
+
1265
+ const { recoveryError } = await tryRecoveryProtocol({
1266
+ rootHandle: handle,
1267
+ txnHandle,
1268
+ transactionId,
1269
+ originalError: applyErr instanceof ReleaseError ? applyErr : new ReleaseError(
1270
+ typeof applyErr?.code === 'string' ? applyErr.code : TRANSACTION_INCOMPLETE,
1271
+ applyErr?.message || 'safe filesystem operation failed',
1272
+ ),
1273
+ artifacts: plan.artifacts,
1274
+ journalCreated,
1275
+ });
1276
+
1277
+ if (recoveryError) {
1278
+ throw recoveryError;
1279
+ }
1280
+
1281
+ throw applyErr;
1282
+ }
1283
+ } finally {
1284
+ let closeError;
1285
+ if (txnResult?.close) {
1286
+ try {
1287
+ await txnResult.close();
1288
+ } catch (error) {
1289
+ closeError = error;
1290
+ }
1291
+ }
1292
+ try {
1293
+ await handle.close();
1294
+ } catch (error) {
1295
+ closeError ??= error;
1296
+ }
1297
+ if (closeError) throw closeError;
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Public apply entry. The shared project lock is held from plan read and
1303
+ * preflight through COMMITTED or durable RECOVERY_REQUIRED. Direct API users
1304
+ * receive the same concurrency boundary as the CLI.
1305
+ */
1306
+ export async function applyArtifactPlan(options = {}) {
1307
+ const { root } = options;
1308
+ if (!root || typeof root !== 'string') {
1309
+ throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
1310
+ }
1311
+
1312
+ const lock = await acquireProjectLock({
1313
+ root,
1314
+ command: 'artifacts apply',
1315
+ mode: 'exclusive',
1316
+ });
1317
+ let result;
1318
+ let primaryError;
1319
+ try {
1320
+ result = await lock.capture(() => applyArtifactPlanUnderLock({
1321
+ ...options,
1322
+ assertLockOwner: () => lock.assertOwner(),
1323
+ }));
1324
+ } catch (error) {
1325
+ primaryError = error;
1326
+ }
1327
+
1328
+ try {
1329
+ await lock.release();
1330
+ } catch (releaseError) {
1331
+ if (primaryError) {
1332
+ const combined = new ReleaseError(
1333
+ TRANSACTION_INCOMPLETE,
1334
+ 'artifact apply failed and project lock release also failed',
1335
+ {
1336
+ businessErrorCode: primaryError?.code || null,
1337
+ releaseErrorCode: releaseError?.code || null,
1338
+ },
1339
+ );
1340
+ combined.cause = primaryError;
1341
+ combined.releaseCause = releaseError;
1342
+ throw combined;
1343
+ }
1344
+ throw releaseError;
1345
+ }
1346
+
1347
+ if (primaryError) throw primaryError;
1348
+ return result;
1349
+ }
1350
+
1351
+ // ---------------------------------------------------------------------------
1352
+ // Type definitions
1353
+ // ---------------------------------------------------------------------------
1354
+
1355
+ /**
1356
+ * @typedef {object} TransactionResult
1357
+ * @property {string} transactionId
1358
+ * @property {string} state — 'COMMITTED'
1359
+ * @property {Array<object>} results — per-artifact results
1360
+ * @property {object} journal — final journal state
1361
+ */