release-skill 0.1.4 → 0.1.6

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 (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +104 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +233 -8
  8. package/README.zh-CN.md +188 -8
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
  12. package/adapters/claude/bin/release-skill.mjs +24 -4
  13. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  16. package/adapters/claude/schemas/.render-manifest.json +10 -10
  17. package/adapters/claude/schemas/release-project.schema.json +141 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  19. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  20. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  21. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
  24. package/adapters/codex/bin/release-skill.mjs +24 -4
  25. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  26. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  27. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  28. package/adapters/codex/schemas/.render-manifest.json +10 -10
  29. package/adapters/codex/schemas/release-project.schema.json +141 -0
  30. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  31. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  32. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  33. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  34. package/bin/release-skill-cli.mjs +163 -4
  35. package/bin/release-skill.bundle.mjs +14164 -9912
  36. package/bin/release-skill.mjs +24 -4
  37. package/native/safe-write/binding.gyp +2 -1
  38. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  39. package/native/safe-write/prebuilds.json +1 -1
  40. package/package.json +2 -2
  41. package/references/.render-manifest.json +4 -4
  42. package/references/02-project-config.md +24 -0
  43. package/references/05-evidence-and-errors.md +5 -0
  44. package/schemas/.render-manifest.json +10 -10
  45. package/schemas/release-project.schema.json +141 -0
  46. package/scripts/build-bundle.mjs +15 -2
  47. package/skills/release-help/SKILL.md +21 -0
  48. package/skills/release-prepare/SKILL.md +17 -6
  49. package/skills/release-publish/SKILL.md +3 -1
  50. package/skills/release-reconcile/SKILL.md +1 -1
  51. package/skills-src/release-help/SKILL.md +21 -0
  52. package/skills-src/release-prepare/SKILL.md +17 -6
  53. package/skills-src/release-publish/SKILL.md +3 -1
  54. package/skills-src/release-reconcile/SKILL.md +1 -1
  55. package/src/adapters/plugin-marketplace.mjs +70 -3
  56. package/src/artifacts/transaction-journal.mjs +1126 -105
  57. package/src/artifacts/transaction.mjs +313 -130
  58. package/src/commands/docs.mjs +332 -0
  59. package/src/commands/prepare.mjs +324 -17
  60. package/src/commands/reconcile.mjs +4 -1
  61. package/src/commands/verify.mjs +4 -1
  62. package/src/core/errors.mjs +64 -2
  63. package/src/core/plan.mjs +59 -1
  64. package/src/core/redact.mjs +206 -0
  65. package/src/docs/changelog-renderer.mjs +853 -0
  66. package/src/docs/config.mjs +337 -0
  67. package/src/docs/notes-loader.mjs +432 -0
  68. package/src/docs/notes.mjs +553 -0
  69. package/src/docs/readme-renderer.mjs +647 -0
  70. package/src/docs/refresh-planner.mjs +542 -0
  71. package/src/docs/refresh-service.mjs +675 -0
@@ -2,12 +2,22 @@
2
2
  * Transaction journal for durable apply operations.
3
3
  *
4
4
  * Manages write-ahead logging, state transitions, and crash recovery
5
- * for artifact plan applications. ALL filesystem writes go through the
6
- * safe-fs backend DirectoryHandle — no Node path-based writes.
5
+ * for artifact plan applications. ALL transactional filesystem writes go
6
+ * through the safe-fs backend DirectoryHandle — no Node path-based writes.
7
+ *
8
+ * Documented exception — retention pruning: `pruneTerminalTransactionRecords`
9
+ * is a best-effort maintenance path that uses `node:fs` to enumerate and
10
+ * remove old TERMINAL records. The safe-fs handle exposes neither directory
11
+ * enumeration nor recursive removal, and a retention failure must never abort
12
+ * or roll back a transaction, so this single helper deliberately bypasses the
13
+ * handle. It is scoped strictly to the current process's transactions root and
14
+ * never prunes non-terminal (recovery-relevant) records. See that helper.
7
15
  *
8
16
  * @module artifacts/transaction-journal
9
17
  */
10
18
 
19
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
20
+ import { join } from 'node:path';
11
21
  import { ReleaseError, INVALID_STATE_TRANSITION, TRANSACTION_INCOMPLETE, PATH_UNSAFE } from '../core/errors.mjs';
12
22
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
13
23
  import { canonicalArtifactPath } from './path-key.mjs';
@@ -40,6 +50,71 @@ export const VALID_TRANSITIONS = Object.freeze({
40
50
  RECOVERY_CONFLICT: [],
41
51
  });
42
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Retention policy constants
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Terminal transaction states that are safe to prune under the retention
59
+ * policy. These are the states with NO outgoing transitions in
60
+ * VALID_TRANSITIONS that the recovery protocol does NOT depend on.
61
+ *
62
+ * `RECOVERY_CONFLICT` also has no outgoing transitions but is deliberately
63
+ * EXCLUDED: it signals a rollback conflict that requires a human decision
64
+ * (see the state-machine discipline in AGENTS.md), so its record must be kept
65
+ * as evidence. Every other non-terminal state (PREPARED, APPLYING, APPLIED,
66
+ * VERIFYING, RECOVERY_REQUIRED, ROLLING_BACK) is recovery-relevant and is
67
+ * never pruned either.
68
+ */
69
+ export const PRUNABLE_TERMINAL_STATES = Object.freeze(new Set(['COMMITTED', 'ROLLED_BACK']));
70
+
71
+ /**
72
+ * Default retention cap: the maximum number of TERMINAL (COMMITTED /
73
+ * ROLLED_BACK) transaction records retained under `.release-skill/transactions/`.
74
+ *
75
+ * When the number of terminal records exceeds this cap, the oldest terminal
76
+ * records (by journal `createdAt`, falling back to directory mtime) are removed
77
+ * until exactly the cap remains. Non-terminal records are never counted against
78
+ * the cap and never pruned. Override per call via the `retentionMax` option on
79
+ * `createTransactionJournal` / `pruneTerminalTransactionRecords`.
80
+ */
81
+ export const DEFAULT_TRANSACTION_RETENTION_MAX = 50;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Terminal receipt convergence constants
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * The explicitly versioned terminal-receipt schema version written by
89
+ * `convergeTerminalRecord`. Receipts carry `terminalReceiptVersion >= 1`;
90
+ * readers fail closed on unknown versions.
91
+ */
92
+ export const TERMINAL_RECEIPT_VERSION = 1;
93
+
94
+ /**
95
+ * Fixed, payload-independent upper bound (bytes) for the serialized terminal
96
+ * receipt (`journal.json` of a converged COMMITTED / ROLLED_BACK record).
97
+ *
98
+ * Rationale: a receipt is a few KB of audit JSON even for a large write set
99
+ * (100 entries x ~300B of digests/metadata ~ 30KB); 256KB is ~8x headroom over
100
+ * that, >=3 orders of magnitude below the observed ~228MB defect records in
101
+ * the read-only `.release-skill/transactions/` evidence, and bounds the
102
+ * worst-case retention footprint to 50 x 256KB = 12.8MB (vs the ~11GB the
103
+ * count-only retention cap permits for payload-sized records). Receipts are
104
+ * never allowed to scale with payload bytes: the whole record is digests,
105
+ * counts, and small audit metadata only — never old/new file bodies,
106
+ * serialized Buffers, or canonicalPlan payload bytes.
107
+ */
108
+ export const TERMINAL_RECEIPT_SIZE_CAP = 256 * 1024;
109
+
110
+ /**
111
+ * Maximum admissible growth of the whole terminal txn directory when the
112
+ * payload grows by several MB. Proves the record no longer scales with
113
+ * payload bytes while admitting per-entry audit growth. Pairs with
114
+ * TERMINAL_RECEIPT_SIZE_CAP (see that constant's rationale).
115
+ */
116
+ export const TERMINAL_RECEIPT_DELTA_CAP = 64 * 1024;
117
+
43
118
  const JOURNAL_SCHEMA_FIELDS = new Set([
44
119
  'transactionId',
45
120
  'planDigest',
@@ -53,6 +128,33 @@ const JOURNAL_SCHEMA_FIELDS = new Set([
53
128
  'updatedAt',
54
129
  ]);
55
130
 
131
+ /**
132
+ * Closed field set of the explicitly versioned terminal receipt (schema
133
+ * version 1). A receipt is what a terminal (COMMITTED / ROLLED_BACK) record's
134
+ * `journal.json` is atomically rewritten into by `convergeTerminalRecord`:
135
+ * small audit metadata only — never old/new file bodies, serialized Buffers,
136
+ * or canonicalPlan payload bytes. `oldManifest` survives as a digest-only
137
+ * summary (no `bytes`); `canonicalPlan` survives with artifact-plan v1
138
+ * `newEntry.bytes` stripped (docs-refresh v1 plans never carry bytes).
139
+ */
140
+ const RECEIPT_SCHEMA_FIELDS = new Set([
141
+ 'terminalReceiptVersion',
142
+ 'transactionId',
143
+ 'planDigest',
144
+ 'canonicalPlan',
145
+ 'planSummary',
146
+ 'oldManifest',
147
+ 'newManifest',
148
+ 'state',
149
+ 'transitions',
150
+ 'entries',
151
+ 'createdAt',
152
+ 'updatedAt',
153
+ ]);
154
+ const RECEIPT_ENTRY_FIELDS = new Set(['id', 'path', 'status', 'appliedAt', 'digest']);
155
+ const RECEIPT_DIGEST_FIELDS = new Set(['path', 'kind', 'sha256', 'size', 'mode']);
156
+ const RECEIPT_PLAN_SUMMARY_FIELDS = new Set(['apiVersion', 'operation']);
157
+
56
158
  const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
57
159
  const PLAN_SCHEMA_FIELDS = new Set([
58
160
  'apiVersion', 'operation', 'bindings', 'safeToWrite', 'targetUnchanged',
@@ -62,6 +164,17 @@ const ARTIFACT_SCHEMA_FIELDS = new Set([
62
164
  'id', 'path', 'oldEntry', 'newEntry', 'status', 'safeToWrite',
63
165
  ]);
64
166
 
167
+ // Closed canonical-plan schema for the docs-refresh v1 transaction authority
168
+ // (2026-07-21-release-docs-refresh-protocol §6). No bytes, no
169
+ // absolute paths: only canonical relative paths and digests.
170
+ const DOCS_REFRESH_PLAN_FIELDS = new Set([
171
+ 'apiVersion', 'operation', 'unitId', 'version', 'refreshDigest', 'files',
172
+ ]);
173
+ const DOCS_REFRESH_FILE_FIELDS = new Set([
174
+ 'id', 'path', 'kind', 'locale', 'oldDigest', 'newDigest', 'change',
175
+ ]);
176
+ const DOCS_REFRESH_UNIT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
177
+
65
178
  function failSchema(message, details) {
66
179
  throw new ReleaseError(TRANSACTION_INCOMPLETE, message, details);
67
180
  }
@@ -143,7 +256,30 @@ function validateManifestItem(item, role, index) {
143
256
  return Object.freeze({ path: canonical.path, kind: 'regular', digest, size: item.size, mode });
144
257
  }
145
258
 
146
- function validateCanonicalPlanBinding(journal, oldItems, newItems) {
259
+ /**
260
+ * Select the per-entry authority items of a canonical plan by apiVersion:
261
+ * artifact-plan v1 plans carry `artifacts`, docs-refresh v1 plans carry
262
+ * `files`. Unknown apiVersions fail closed.
263
+ *
264
+ * @param {object} canonicalPlan — validated journal canonicalPlan.
265
+ * @returns {object[]} The per-entry authority items (id/path carriers).
266
+ */
267
+ function canonicalPlanItems(canonicalPlan) {
268
+ if (canonicalPlan?.apiVersion === 'release-skill.dev/artifact-plan/v1') {
269
+ return canonicalPlan.artifacts;
270
+ }
271
+ if (canonicalPlan?.apiVersion === 'release-skill.dev/docs-refresh/v1') {
272
+ return canonicalPlan.files;
273
+ }
274
+ return failSchema('journal canonicalPlan apiVersion is unsupported');
275
+ }
276
+
277
+ /**
278
+ * Validate the closed artifact-plan v1 canonicalPlan authority against the
279
+ * journal and its manifests. Behaviour is byte-for-byte the pre-refactor
280
+ * validateCanonicalPlanBinding body.
281
+ */
282
+ function validateArtifactPlanAuthority(journal, oldItems, newItems) {
147
283
  const plan = journal.canonicalPlan;
148
284
  assertClosedObject(plan, PLAN_SCHEMA_FIELDS, 'journal canonicalPlan');
149
285
  for (const field of PLAN_SCHEMA_FIELDS) {
@@ -230,6 +366,95 @@ function validateCanonicalPlanBinding(journal, oldItems, newItems) {
230
366
  }
231
367
  }
232
368
 
369
+ /**
370
+ * Validate the closed docs-refresh v1 canonicalPlan authority against the
371
+ * journal and its manifests: closed field sets, refresh operation, safe
372
+ * unitId, non-empty version, refreshDigest bound to journal.planDigest, and
373
+ * per-file identity agreement with both manifests (canonical relative paths
374
+ * only; digests only; never bytes).
375
+ */
376
+ function validateDocsRefreshAuthority(journal, oldItems, newItems) {
377
+ const plan = journal.canonicalPlan;
378
+ assertClosedObject(plan, DOCS_REFRESH_PLAN_FIELDS, 'journal canonicalPlan');
379
+ for (const field of DOCS_REFRESH_PLAN_FIELDS) {
380
+ if (!Object.hasOwn(plan, field)) failSchema(`journal canonicalPlan missing required field: ${field}`);
381
+ }
382
+ if (plan.operation !== 'refresh') {
383
+ failSchema('journal canonicalPlan operation must be refresh');
384
+ }
385
+ if (typeof plan.unitId !== 'string' || !DOCS_REFRESH_UNIT_ID_RE.test(plan.unitId)) {
386
+ failSchema('journal canonicalPlan unitId is invalid');
387
+ }
388
+ if (typeof plan.version !== 'string' || plan.version.length === 0) {
389
+ failSchema('journal canonicalPlan version is invalid');
390
+ }
391
+ if (typeof plan.refreshDigest !== 'string'
392
+ || !DIGEST_RE.test(plan.refreshDigest)
393
+ || plan.refreshDigest !== journal.planDigest) {
394
+ failSchema('journal canonicalPlan refreshDigest does not match journal planDigest');
395
+ }
396
+ if (!Array.isArray(plan.files)
397
+ || plan.files.length !== oldItems.length
398
+ || plan.files.length !== newItems.length) {
399
+ failSchema('journal canonicalPlan files do not match manifest length');
400
+ }
401
+
402
+ const seenIds = new Set();
403
+ for (let i = 0; i < plan.files.length; i++) {
404
+ const file = plan.files[i];
405
+ const label = `journal canonicalPlan.files[${i}]`;
406
+ assertClosedObject(file, DOCS_REFRESH_FILE_FIELDS, label);
407
+ for (const field of DOCS_REFRESH_FILE_FIELDS) {
408
+ if (!Object.hasOwn(file, field)) failSchema(`${label} missing required field: ${field}`);
409
+ }
410
+ if (typeof file.id !== 'string' || file.id.length === 0) failSchema(`${label} has invalid id`);
411
+ if (seenIds.has(file.id)) failSchema(`${label} has duplicate id`);
412
+ seenIds.add(file.id);
413
+ const canonical = canonicalArtifactPath(file.path);
414
+ if (canonical.path !== file.path
415
+ || canonical.path !== oldItems[i].path
416
+ || canonical.path !== newItems[i].path) {
417
+ failSchema(`${label} path does not match manifests`);
418
+ }
419
+ if (oldItems[i].kind !== 'regular') {
420
+ failSchema(`${label} old manifest entry is not regular`);
421
+ }
422
+ if (normaliseDigest(file.oldDigest, `${label}.oldDigest`) !== oldItems[i].digest) {
423
+ failSchema(`${label} oldDigest does not match manifest identity`);
424
+ }
425
+ if (normaliseDigest(file.newDigest, `${label}.newDigest`) !== newItems[i].digest) {
426
+ failSchema(`${label} newDigest does not match manifest identity`);
427
+ }
428
+ if (file.kind !== 'changelog' && file.kind !== 'readme') {
429
+ failSchema(`${label} has invalid kind`);
430
+ }
431
+ if (typeof file.locale !== 'string' || file.locale.length === 0) {
432
+ failSchema(`${label} has invalid locale`);
433
+ }
434
+ if (file.change !== 'insert' && file.change !== 'update') {
435
+ failSchema(`${label} has invalid change`);
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Dispatch canonicalPlan authority validation by apiVersion. The
442
+ * artifact-plan v1 branch preserves the pre-refactor behaviour exactly; the
443
+ * docs-refresh v1 branch validates the closed docs-refresh authority.
444
+ */
445
+ function validateCanonicalPlanAuthority(journal, oldItems, newItems) {
446
+ const plan = journal.canonicalPlan;
447
+ if (plan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
448
+ validateArtifactPlanAuthority(journal, oldItems, newItems);
449
+ return;
450
+ }
451
+ if (plan.apiVersion === 'release-skill.dev/docs-refresh/v1') {
452
+ validateDocsRefreshAuthority(journal, oldItems, newItems);
453
+ return;
454
+ }
455
+ failSchema('journal canonicalPlan apiVersion is unsupported');
456
+ }
457
+
233
458
  // ---------------------------------------------------------------------------
234
459
  // Handle helpers
235
460
  // ---------------------------------------------------------------------------
@@ -361,81 +586,20 @@ async function abortTempAfterFailure(handle, token, primaryError) {
361
586
  }
362
587
 
363
588
  /**
364
- * Validate a journal object against the closed schema.
365
- *
366
- * P0-7: Validates all required fields, transitions/entries structure,
367
- * index ranges, path uniqueness, transactionId/path safety, and legal
368
- * state transitions. Rejects from:null pseudo-transitions and metadata
369
- * overwriting reserved fields.
589
+ * Replay the journal transition chain and validate its structure, legality,
590
+ * and continuity. Shared verbatim by the full-journal and terminal-receipt
591
+ * validation branches: both persist the complete transition chain, so both
592
+ * replay it the same way.
370
593
  *
371
- * @param {object} journal — journal object.
372
- * @throws {ReleaseError} TRANSACTION_INCOMPLETE on unknown fields or
373
- * invalid structure.
594
+ * @param {object} journal — journal or receipt object.
595
+ * @param {number} manifestLength newManifest length for entryIndex bounds.
596
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE / INVALID_STATE_TRANSITION.
374
597
  */
375
- function validateJournalSchema(journal) {
376
- if (!journal || typeof journal !== 'object' || Array.isArray(journal)) {
377
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal is not an object');
378
- }
379
-
380
- // P0-7: Validate closed schema
381
- for (const key of Object.keys(journal)) {
382
- if (!JOURNAL_SCHEMA_FIELDS.has(key)) {
383
- throw new ReleaseError(
384
- TRANSACTION_INCOMPLETE,
385
- `journal has unknown field: ${key}`,
386
- );
387
- }
388
- }
389
- for (const field of JOURNAL_SCHEMA_FIELDS) {
390
- if (!Object.hasOwn(journal, field)) {
391
- throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal missing required field: ${field}`);
392
- }
393
- }
394
-
395
- // P0-7: Validate required fields
396
- if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
397
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing transactionId');
398
- }
399
-
400
- // P0-7: Validate transactionId safety (no path separators, NUL, etc.)
401
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
402
- throw new ReleaseError(
403
- TRANSACTION_INCOMPLETE,
404
- 'journal transactionId contains unsafe characters',
405
- { transactionId: journal.transactionId },
406
- );
407
- }
408
-
409
- if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
410
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing planDigest');
411
- }
412
-
413
- if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
414
- || Array.isArray(journal.canonicalPlan)) {
415
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal canonicalPlan must be an object');
416
- }
417
- if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
418
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifests must be arrays');
419
- }
420
- if (journal.oldManifest.length !== journal.newManifest.length) {
421
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifest lengths differ');
422
- }
423
-
424
- if (!VALID_STATES.has(journal.state)) {
425
- throw new ReleaseError(
426
- TRANSACTION_INCOMPLETE,
427
- `journal has invalid state: ${journal.state}`,
428
- );
429
- }
430
-
598
+ function validateTransitionChain(journal, manifestLength) {
431
599
  if (!Array.isArray(journal.transitions)) {
432
600
  throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal transitions is not an array');
433
601
  }
434
602
 
435
- if (!Array.isArray(journal.entries)) {
436
- throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries is not an array');
437
- }
438
-
439
603
  let replayState = 'PREPARED';
440
604
  // P0-7: Validate transitions structure
441
605
  for (let i = 0; i < journal.transitions.length; i++) {
@@ -481,7 +645,7 @@ function validateJournalSchema(journal) {
481
645
  if (t.entryIndex !== undefined && t.entryIndex !== null) {
482
646
  if (typeof t.entryIndex !== 'number' || t.entryIndex < 0
483
647
  || !Number.isInteger(t.entryIndex)
484
- || t.entryIndex >= journal.newManifest.length) {
648
+ || t.entryIndex >= manifestLength) {
485
649
  throw new ReleaseError(
486
650
  TRANSACTION_INCOMPLETE,
487
651
  `journal transition[${i}] has invalid entryIndex: ${t.entryIndex}`,
@@ -509,6 +673,98 @@ function validateJournalSchema(journal) {
509
673
  `journal state ${journal.state} does not match transition replay ${replayState}`,
510
674
  );
511
675
  }
676
+ }
677
+
678
+ /**
679
+ * Validate a journal object against the closed schema.
680
+ *
681
+ * P0-7: Validates all required fields, transitions/entries structure,
682
+ * index ranges, path uniqueness, transactionId/path safety, and legal
683
+ * state transitions. Rejects from:null pseudo-transitions and metadata
684
+ * overwriting reserved fields.
685
+ *
686
+ * Two closed shapes are accepted:
687
+ * - the FULL journal (the recovery authority for non-terminal states, and
688
+ * the pre-convergence terminal record left behind by a crash/IO failure
689
+ * at 'before-terminal-receipt-write'); and
690
+ * - the explicitly versioned TERMINAL RECEIPT (carries
691
+ * `terminalReceiptVersion`), which is legal ONLY for
692
+ * PRUNABLE_TERMINAL_STATES — a receipt-shaped record in any non-terminal
693
+ * state is invalid, and a receipt never carries file bodies, serialized
694
+ * Buffers, or canonicalPlan payload bytes.
695
+ *
696
+ * @param {object} journal — journal object.
697
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on unknown fields or
698
+ * invalid structure.
699
+ */
700
+ function validateJournalSchema(journal) {
701
+ if (!journal || typeof journal !== 'object' || Array.isArray(journal)) {
702
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal is not an object');
703
+ }
704
+
705
+ // Terminal records converge to an explicitly versioned receipt. The
706
+ // presence of the version marker routes to the closed receipt schema.
707
+ if (Object.hasOwn(journal, 'terminalReceiptVersion')) {
708
+ validateTerminalReceiptSchema(journal);
709
+ return;
710
+ }
711
+
712
+ // P0-7: Validate closed schema
713
+ for (const key of Object.keys(journal)) {
714
+ if (!JOURNAL_SCHEMA_FIELDS.has(key)) {
715
+ throw new ReleaseError(
716
+ TRANSACTION_INCOMPLETE,
717
+ `journal has unknown field: ${key}`,
718
+ );
719
+ }
720
+ }
721
+ for (const field of JOURNAL_SCHEMA_FIELDS) {
722
+ if (!Object.hasOwn(journal, field)) {
723
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal missing required field: ${field}`);
724
+ }
725
+ }
726
+
727
+ // P0-7: Validate required fields
728
+ if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
729
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing transactionId');
730
+ }
731
+
732
+ // P0-7: Validate transactionId safety (no path separators, NUL, etc.)
733
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
734
+ throw new ReleaseError(
735
+ TRANSACTION_INCOMPLETE,
736
+ 'journal transactionId contains unsafe characters',
737
+ { transactionId: journal.transactionId },
738
+ );
739
+ }
740
+
741
+ if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
742
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing planDigest');
743
+ }
744
+
745
+ if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
746
+ || Array.isArray(journal.canonicalPlan)) {
747
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal canonicalPlan must be an object');
748
+ }
749
+ if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
750
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifests must be arrays');
751
+ }
752
+ if (journal.oldManifest.length !== journal.newManifest.length) {
753
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifest lengths differ');
754
+ }
755
+
756
+ if (!VALID_STATES.has(journal.state)) {
757
+ throw new ReleaseError(
758
+ TRANSACTION_INCOMPLETE,
759
+ `journal has invalid state: ${journal.state}`,
760
+ );
761
+ }
762
+
763
+ if (!Array.isArray(journal.entries)) {
764
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries is not an array');
765
+ }
766
+
767
+ validateTransitionChain(journal, journal.newManifest.length);
512
768
 
513
769
  const manifestPaths = new Set();
514
770
  const oldItems = [];
@@ -529,7 +785,8 @@ function validateJournalSchema(journal) {
529
785
  }
530
786
  manifestPaths.add(collisionKey);
531
787
  }
532
- validateCanonicalPlanBinding(journal, oldItems, newItems);
788
+ validateCanonicalPlanAuthority(journal, oldItems, newItems);
789
+ const planItems = canonicalPlanItems(journal.canonicalPlan);
533
790
 
534
791
  // P0-7: Validate entries structure
535
792
  const seenIndices = new Set();
@@ -565,7 +822,7 @@ function validateJournalSchema(journal) {
565
822
  throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal entries[${i}] missing id/path`);
566
823
  }
567
824
  const entryPath = canonicalArtifactPath(e.path);
568
- if (e.id !== journal.canonicalPlan.artifacts[i].id
825
+ if (e.id !== planItems[i].id
569
826
  || entryPath.path !== newItems[i].path) {
570
827
  throw new ReleaseError(
571
828
  TRANSACTION_INCOMPLETE,
@@ -618,39 +875,766 @@ function validateJournalSchema(journal) {
618
875
  }
619
876
 
620
877
  // ---------------------------------------------------------------------------
621
- // Journal creation
878
+ // Terminal receipt (explicitly versioned convergence of terminal records)
622
879
  // ---------------------------------------------------------------------------
623
880
 
624
881
  /**
625
- * Create a new transaction journal through the safe-fs backend.
626
- *
627
- * Creates `.release-skill/transactions/<txnId>/journal.json` with initial
628
- * PREPARED state, canonical plan, old/new manifest.
629
- *
630
- * @param {object} options
631
- * @param {object} options.backend — safe-fs backend.
632
- * @param {string} options.root — repository root.
633
- * @param {string} options.transactionId — unique transaction ID.
634
- * @param {string} options.planDigest — canonical plan digest.
635
- * @param {object} options.canonicalPlan — decoded plan (with Buffer bytes).
636
- * @param {object[]} options.oldManifest — snapshot of old entries with
637
- * backup bytes (for absent entries, `absent: true`).
638
- * @param {object[]} options.newManifest — snapshot of new entries.
639
- * @returns {Promise<{ journal: object, txnHandle: object }>}
882
+ * Validate a manifest item of a terminal receipt: identical identity fields
883
+ * to the full journal, but NEVER bytes. The closed field set rejects any
884
+ * `bytes` key, which is the AC-1 payload-independence guarantee — a receipt
885
+ * keeps path/kind/sha256/size/mode digests only.
640
886
  */
641
- export async function createTransactionJournal({
642
- rootHandle,
643
- transactionId,
644
- planDigest,
645
- canonicalPlan,
646
- oldManifest,
647
- newManifest,
648
- } = {}) {
649
- if (typeof transactionId !== 'string'
650
- || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(transactionId)) {
651
- throw new ReleaseError(PATH_UNSAFE, 'transactionId is not a safe path segment');
887
+ function validateReceiptManifestItem(item, role, index) {
888
+ const label = `receipt ${role}Manifest[${index}]`;
889
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
890
+ failSchema(`${label} must be an object`);
652
891
  }
653
- if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
892
+ const canonical = canonicalArtifactPath(item.path);
893
+ if (canonical.path !== item.path) failSchema(`${label} path is not canonical`);
894
+
895
+ if (item.kind === 'absent') {
896
+ const allowed = role === 'old'
897
+ ? new Set(['path', 'kind', 'absent'])
898
+ : new Set(['path', 'kind']);
899
+ assertClosedObject(item, allowed, label);
900
+ if (role === 'old' && item.absent !== true) {
901
+ failSchema(`${label} must carry an absence tombstone`);
902
+ }
903
+ return Object.freeze({ path: canonical.path, kind: 'absent' });
904
+ }
905
+
906
+ if (item.kind !== 'regular') failSchema(`${label} has invalid kind`);
907
+ const allowed = new Set(['path', 'kind', 'sha256', 'size', 'mode']);
908
+ assertClosedObject(item, allowed, label); // closed: `bytes` is rejected
909
+ for (const required of allowed) {
910
+ if (!Object.hasOwn(item, required)) failSchema(`${label} missing required field: ${required}`);
911
+ }
912
+ const digest = normaliseDigest(item.sha256, label);
913
+ if (!Number.isSafeInteger(item.size) || item.size < 0) failSchema(`${label} has invalid size`);
914
+ const mode = normaliseMode(item.mode, label);
915
+ return Object.freeze({ path: canonical.path, kind: 'regular', digest, size: item.size, mode });
916
+ }
917
+
918
+ /**
919
+ * Validate the artifact-plan v1 canonicalPlan authority of a terminal
920
+ * receipt: the closed plan schema with `newEntry.bytes` stripped. Identity
921
+ * (digest/size/mode) must still agree with both manifests; the plan digest
922
+ * binding field must equal the journal planDigest. The digest is NOT
923
+ * recomputed: receipt canonicalPlans deliberately omit newEntry bytes, so the
924
+ * full-journal digest recomputation does not apply — planDigest remains the
925
+ * binding authority.
926
+ */
927
+ function validateArtifactPlanReceiptAuthority(journal, oldItems, newItems) {
928
+ const plan = journal.canonicalPlan;
929
+ assertClosedObject(plan, PLAN_SCHEMA_FIELDS, 'receipt canonicalPlan');
930
+ for (const field of PLAN_SCHEMA_FIELDS) {
931
+ if (!Object.hasOwn(plan, field)) failSchema(`receipt canonicalPlan missing required field: ${field}`);
932
+ }
933
+ if (typeof plan.planDigest !== 'string' || plan.planDigest !== journal.planDigest) {
934
+ failSchema('receipt canonicalPlan planDigest does not bind the journal planDigest');
935
+ }
936
+ if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1'
937
+ || !['inspect', 'status', 'apply'].includes(plan.operation)
938
+ || plan.safeToWrite !== true || plan.targetUnchanged !== true) {
939
+ failSchema('receipt canonicalPlan is not an applyable v1 plan');
940
+ }
941
+ const bindingFields = new Set([
942
+ 'repositoryIdentity', 'policyDigest', 'baseManifestDigest',
943
+ 'currentManifestDigest', 'producerClosureDigest',
944
+ ]);
945
+ assertClosedObject(plan.bindings, bindingFields, 'receipt canonicalPlan.bindings');
946
+ for (const field of bindingFields) {
947
+ if (!DIGEST_RE.test(plan.bindings[field])) {
948
+ failSchema(`receipt canonicalPlan binding ${field} is invalid`);
949
+ }
950
+ }
951
+ assertClosedObject(plan.nextAction, new Set(['command']), 'receipt canonicalPlan.nextAction');
952
+ if (typeof plan.nextAction.command !== 'string'
953
+ || !/\bartifacts apply\b/.test(plan.nextAction.command)) {
954
+ failSchema('receipt canonicalPlan nextAction is not apply');
955
+ }
956
+ if (!Array.isArray(plan.artifacts) || plan.artifacts.length !== newItems.length) {
957
+ failSchema('receipt canonicalPlan artifacts do not match manifest length');
958
+ }
959
+
960
+ for (let i = 0; i < plan.artifacts.length; i++) {
961
+ const artifact = plan.artifacts[i];
962
+ const label = `receipt canonicalPlan.artifacts[${i}]`;
963
+ assertClosedObject(artifact, ARTIFACT_SCHEMA_FIELDS, label);
964
+ for (const field of ARTIFACT_SCHEMA_FIELDS) {
965
+ if (!Object.hasOwn(artifact, field)) failSchema(`${label} missing required field: ${field}`);
966
+ }
967
+ if (typeof artifact.id !== 'string' || artifact.id.length === 0) failSchema(`${label} has invalid id`);
968
+ if (artifact.safeToWrite !== true
969
+ || !new Set([
970
+ 'READY', 'CLEAN', 'NEW', 'HUMAN_CHANGED',
971
+ 'GENERATOR_CHANGED', 'MERGEABLE', 'RESOLVED',
972
+ ]).has(artifact.status)) {
973
+ failSchema(`${label} is not safe to write`);
974
+ }
975
+ const canonical = canonicalArtifactPath(artifact.path);
976
+ if (canonical.path !== artifact.path || canonical.path !== oldItems[i].path) {
977
+ failSchema(`${label} path does not match manifests`);
978
+ }
979
+ for (const [role, entry, manifest] of [
980
+ ['oldEntry', artifact.oldEntry, oldItems[i]],
981
+ ['newEntry', artifact.newEntry, newItems[i]],
982
+ ]) {
983
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.kind !== manifest.kind) {
984
+ failSchema(`${label}.${role} does not match manifest kind`);
985
+ }
986
+ if (entry.kind === 'absent') {
987
+ if (Object.keys(entry).length !== 1) failSchema(`${label}.${role} absent entry is not closed`);
988
+ continue;
989
+ }
990
+ // Receipt form: digests only — NEVER bytes (closed field set).
991
+ const entryFields = new Set(['kind', 'sha256', 'size', 'mode']);
992
+ assertClosedObject(entry, entryFields, `${label}.${role}`);
993
+ for (const field of entryFields) {
994
+ if (!Object.hasOwn(entry, field)) failSchema(`${label}.${role} missing required field: ${field}`);
995
+ }
996
+ if (normaliseDigest(entry.sha256, `${label}.${role}`) !== manifest.digest
997
+ || entry.size !== manifest.size
998
+ || normaliseMode(entry.mode, `${label}.${role}`) !== manifest.mode) {
999
+ failSchema(`${label}.${role} does not match manifest identity`);
1000
+ }
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Dispatch receipt canonicalPlan authority validation by apiVersion. The
1007
+ * docs-refresh v1 plans never carry bytes, so the full authority validator
1008
+ * applies verbatim (refreshDigest must bind the journal planDigest); the
1009
+ * artifact-plan v1 branch validates the bytes-stripped receipt form.
1010
+ */
1011
+ function validateReceiptCanonicalPlanAuthority(journal, oldItems, newItems) {
1012
+ const plan = journal.canonicalPlan;
1013
+ if (plan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
1014
+ validateArtifactPlanReceiptAuthority(journal, oldItems, newItems);
1015
+ return;
1016
+ }
1017
+ if (plan.apiVersion === 'release-skill.dev/docs-refresh/v1') {
1018
+ validateDocsRefreshAuthority(journal, oldItems, newItems);
1019
+ return;
1020
+ }
1021
+ failSchema('receipt canonicalPlan apiVersion is unsupported');
1022
+ }
1023
+
1024
+ /**
1025
+ * Validate the closed terminal receipt schema (version 1).
1026
+ *
1027
+ * A receipt is legal ONLY for PRUNABLE_TERMINAL_STATES (COMMITTED /
1028
+ * ROLLED_BACK): a receipt-shaped record in any non-terminal state fails
1029
+ * closed, because non-terminal records must keep the full recovery authority
1030
+ * (oldManifest bytes + backups). The transition chain replays to `state`,
1031
+ * every field set is closed, manifests carry digests only (never bytes), and
1032
+ * per-entry audit metadata carries id/path/status/appliedAt plus a
1033
+ * path/kind/sha256/size/mode digest summary bound to the new manifest.
1034
+ *
1035
+ * @param {object} journal — receipt-shaped journal object.
1036
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE / INVALID_STATE_TRANSITION.
1037
+ */
1038
+ function validateTerminalReceiptSchema(journal) {
1039
+ for (const key of Object.keys(journal)) {
1040
+ if (!RECEIPT_SCHEMA_FIELDS.has(key)) {
1041
+ throw new ReleaseError(
1042
+ TRANSACTION_INCOMPLETE,
1043
+ `terminal receipt has unknown field: ${key}`,
1044
+ );
1045
+ }
1046
+ }
1047
+ for (const field of RECEIPT_SCHEMA_FIELDS) {
1048
+ if (!Object.hasOwn(journal, field)) {
1049
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt missing required field: ${field}`);
1050
+ }
1051
+ }
1052
+
1053
+ if (!Number.isInteger(journal.terminalReceiptVersion) || journal.terminalReceiptVersion < 1) {
1054
+ throw new ReleaseError(
1055
+ TRANSACTION_INCOMPLETE,
1056
+ 'terminal receipt must carry an explicit integer version >= 1',
1057
+ );
1058
+ }
1059
+ if (journal.terminalReceiptVersion !== TERMINAL_RECEIPT_VERSION) {
1060
+ throw new ReleaseError(
1061
+ TRANSACTION_INCOMPLETE,
1062
+ `terminal receipt version ${journal.terminalReceiptVersion} is unsupported`,
1063
+ );
1064
+ }
1065
+
1066
+ if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
1067
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt missing transactionId');
1068
+ }
1069
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
1070
+ throw new ReleaseError(
1071
+ TRANSACTION_INCOMPLETE,
1072
+ 'terminal receipt transactionId contains unsafe characters',
1073
+ { transactionId: journal.transactionId },
1074
+ );
1075
+ }
1076
+ if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
1077
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt missing planDigest');
1078
+ }
1079
+
1080
+ // Receipts exist ONLY for terminal states; a receipt shape in any
1081
+ // non-terminal state is invalid (recovery authority must stay full).
1082
+ if (!PRUNABLE_TERMINAL_STATES.has(journal.state)) {
1083
+ throw new ReleaseError(
1084
+ TRANSACTION_INCOMPLETE,
1085
+ `terminal receipt is illegal for non-terminal state: ${journal.state}`,
1086
+ );
1087
+ }
1088
+
1089
+ validateTransitionChain(journal, journal.newManifest.length);
1090
+
1091
+ if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
1092
+ || Array.isArray(journal.canonicalPlan)) {
1093
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt canonicalPlan must be an object');
1094
+ }
1095
+ if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
1096
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt manifests must be arrays');
1097
+ }
1098
+ if (journal.oldManifest.length !== journal.newManifest.length) {
1099
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt manifest lengths differ');
1100
+ }
1101
+
1102
+ const manifestPaths = new Set();
1103
+ const oldItems = [];
1104
+ const newItems = [];
1105
+ for (let i = 0; i < journal.newManifest.length; i++) {
1106
+ const validatedOld = validateReceiptManifestItem(journal.oldManifest[i], 'old', i);
1107
+ const validatedNew = validateReceiptManifestItem(journal.newManifest[i], 'new', i);
1108
+ if (validatedOld.path !== validatedNew.path) {
1109
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt manifest[${i}] paths differ`);
1110
+ }
1111
+ oldItems.push(validatedOld);
1112
+ newItems.push(validatedNew);
1113
+ const { collisionKey } = canonicalArtifactPath(validatedNew.path);
1114
+ if (manifestPaths.has(collisionKey)) {
1115
+ throw new ReleaseError(
1116
+ TRANSACTION_INCOMPLETE,
1117
+ `terminal receipt manifest path is duplicated: ${journal.newManifest[i].path}`,
1118
+ );
1119
+ }
1120
+ manifestPaths.add(collisionKey);
1121
+ }
1122
+
1123
+ validateReceiptCanonicalPlanAuthority(journal, oldItems, newItems);
1124
+ const planItems = canonicalPlanItems(journal.canonicalPlan);
1125
+
1126
+ assertClosedObject(journal.planSummary, RECEIPT_PLAN_SUMMARY_FIELDS, 'terminal receipt planSummary');
1127
+ if (typeof journal.planSummary.apiVersion !== 'string'
1128
+ || typeof journal.planSummary.operation !== 'string') {
1129
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt planSummary fields must be strings');
1130
+ }
1131
+ if (journal.planSummary.apiVersion !== journal.canonicalPlan.apiVersion
1132
+ || journal.planSummary.operation !== journal.canonicalPlan.operation) {
1133
+ throw new ReleaseError(
1134
+ TRANSACTION_INCOMPLETE,
1135
+ 'terminal receipt planSummary does not match canonicalPlan authority',
1136
+ );
1137
+ }
1138
+
1139
+ if (!Array.isArray(journal.entries)) {
1140
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt entries is not an array');
1141
+ }
1142
+ if (journal.entries.length > journal.newManifest.length) {
1143
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt entries exceed manifest length');
1144
+ }
1145
+ const seenPaths = new Set();
1146
+ for (let i = 0; i < journal.entries.length; i++) {
1147
+ const e = journal.entries[i];
1148
+ if (e === null || e === undefined) {
1149
+ // Write-ahead null gaps survive convergence for partially-applied
1150
+ // rollback chains (ROLLED_BACK before every entry was applied).
1151
+ continue;
1152
+ }
1153
+ if (typeof e !== 'object' || Array.isArray(e)) {
1154
+ throw new ReleaseError(
1155
+ TRANSACTION_INCOMPLETE,
1156
+ `terminal receipt entries[${i}] is not an object or null`,
1157
+ );
1158
+ }
1159
+ for (const key of Object.keys(e)) {
1160
+ if (!RECEIPT_ENTRY_FIELDS.has(key)) {
1161
+ throw new ReleaseError(
1162
+ TRANSACTION_INCOMPLETE,
1163
+ `terminal receipt entries[${i}] has unknown field: ${key}`,
1164
+ );
1165
+ }
1166
+ }
1167
+ if (typeof e.id !== 'string' || e.id.length === 0 || typeof e.path !== 'string') {
1168
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] missing id/path`);
1169
+ }
1170
+ const entryPath = canonicalArtifactPath(e.path);
1171
+ if (e.id !== planItems[i].id || entryPath.path !== newItems[i].path) {
1172
+ throw new ReleaseError(
1173
+ TRANSACTION_INCOMPLETE,
1174
+ `terminal receipt entries[${i}] does not match canonical plan authority`,
1175
+ );
1176
+ }
1177
+ if (seenPaths.has(entryPath.collisionKey)) {
1178
+ throw new ReleaseError(
1179
+ TRANSACTION_INCOMPLETE,
1180
+ `terminal receipt entries has duplicate path: ${e.path}`,
1181
+ );
1182
+ }
1183
+ seenPaths.add(entryPath.collisionKey);
1184
+ if (!['pending', 'applied'].includes(e.status)) {
1185
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] has invalid status`);
1186
+ }
1187
+ if (typeof e.appliedAt !== 'string' || !Number.isFinite(Date.parse(e.appliedAt))) {
1188
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] has invalid appliedAt`);
1189
+ }
1190
+
1191
+ // Per-entry digest summary bound to the new manifest — never bytes.
1192
+ const digest = e.digest;
1193
+ const digestLabel = `terminal receipt entries[${i}].digest`;
1194
+ assertClosedObject(digest, RECEIPT_DIGEST_FIELDS, digestLabel);
1195
+ const digestPath = canonicalArtifactPath(digest.path);
1196
+ if (digestPath.path !== newItems[i].path) {
1197
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} path does not match manifest`);
1198
+ }
1199
+ if (newItems[i].kind === 'absent') {
1200
+ if (digest.kind !== 'absent') {
1201
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} kind does not match manifest`);
1202
+ }
1203
+ } else {
1204
+ if (digest.kind !== 'regular') {
1205
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} kind does not match manifest`);
1206
+ }
1207
+ for (const required of ['sha256', 'size', 'mode']) {
1208
+ if (!Object.hasOwn(digest, required)) {
1209
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} missing required field: ${required}`);
1210
+ }
1211
+ }
1212
+ if (normaliseDigest(digest.sha256, digestLabel) !== newItems[i].digest
1213
+ || digest.size !== newItems[i].size
1214
+ || normaliseMode(digest.mode, digestLabel) !== newItems[i].mode) {
1215
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} does not match manifest identity`);
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ if (typeof journal.createdAt !== 'string' || !Number.isFinite(Date.parse(journal.createdAt))) {
1221
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt createdAt must be a timestamp');
1222
+ }
1223
+ if (typeof journal.updatedAt !== 'string' || !Number.isFinite(Date.parse(journal.updatedAt))) {
1224
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt updatedAt must be a timestamp');
1225
+ }
1226
+
1227
+ if (journal.state === 'COMMITTED') {
1228
+ if (journal.entries.length !== journal.newManifest.length
1229
+ || journal.entries.some((entry) => !entry || entry.status !== 'applied')) {
1230
+ throw new ReleaseError(
1231
+ TRANSACTION_INCOMPLETE,
1232
+ 'terminal receipt state COMMITTED requires every manifest entry to be applied',
1233
+ );
1234
+ }
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Strip payload bytes from a validated canonicalPlan for receipt persistence.
1240
+ *
1241
+ * artifact-plan v1 plans embed `newEntry.bytes` (the full new file body) —
1242
+ * the receipt keeps the digest identity ({kind, sha256, size, mode}) and
1243
+ * drops the bytes. docs-refresh v1 plans never carry bytes and pass through
1244
+ * unchanged.
1245
+ */
1246
+ function stripCanonicalPlanBytes(canonicalPlan) {
1247
+ if (canonicalPlan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
1248
+ return {
1249
+ ...canonicalPlan,
1250
+ artifacts: canonicalPlan.artifacts.map((artifact) => {
1251
+ const stripped = { ...artifact };
1252
+ if (stripped.newEntry && stripped.newEntry.kind === 'regular') {
1253
+ const { bytes: _dropped, ...digestIdentity } = stripped.newEntry;
1254
+ stripped.newEntry = digestIdentity;
1255
+ }
1256
+ return stripped;
1257
+ }),
1258
+ };
1259
+ }
1260
+ return canonicalPlan;
1261
+ }
1262
+
1263
+ /**
1264
+ * Build the explicitly versioned terminal receipt from a validated FULL
1265
+ * terminal journal. The receipt keeps: transactionId, planDigest, state, the
1266
+ * complete transition chain, createdAt/updatedAt, digest-only manifests, the
1267
+ * bytes-stripped canonicalPlan, a planSummary{apiVersion, operation}, and
1268
+ * per-entry audit metadata (id/path/status/appliedAt + a
1269
+ * path/kind/sha256/size/mode digest summary). It never keeps old/new file
1270
+ * bodies, serialized Buffers, or canonicalPlan payload bytes.
1271
+ *
1272
+ * @param {object} journal — validated full terminal journal.
1273
+ * @returns {object} The receipt (plain JSON-serialisable object).
1274
+ */
1275
+ function buildTerminalReceipt(journal) {
1276
+ const oldManifest = journal.oldManifest.map((item) => {
1277
+ if (item.kind === 'absent') {
1278
+ return { path: item.path, kind: 'absent', absent: true };
1279
+ }
1280
+ return {
1281
+ path: item.path, kind: 'regular',
1282
+ sha256: item.sha256, size: item.size, mode: item.mode,
1283
+ };
1284
+ });
1285
+ const newManifest = journal.newManifest.map((item) => {
1286
+ if (item.kind === 'absent') return { path: item.path, kind: 'absent' };
1287
+ return {
1288
+ path: item.path, kind: 'regular',
1289
+ sha256: item.sha256, size: item.size, mode: item.mode,
1290
+ };
1291
+ });
1292
+ const entries = journal.entries.map((entry, index) => {
1293
+ if (entry === null || entry === undefined) return null;
1294
+ const manifestItem = journal.newManifest[index];
1295
+ const digest = manifestItem.kind === 'absent'
1296
+ ? { path: manifestItem.path, kind: 'absent' }
1297
+ : {
1298
+ path: manifestItem.path, kind: 'regular',
1299
+ sha256: manifestItem.sha256, size: manifestItem.size, mode: manifestItem.mode,
1300
+ };
1301
+ return {
1302
+ id: entry.id, path: entry.path, status: entry.status,
1303
+ appliedAt: entry.appliedAt, digest,
1304
+ };
1305
+ });
1306
+ const canonicalPlan = stripCanonicalPlanBytes(journal.canonicalPlan);
1307
+ return {
1308
+ terminalReceiptVersion: TERMINAL_RECEIPT_VERSION,
1309
+ transactionId: journal.transactionId,
1310
+ planDigest: journal.planDigest,
1311
+ canonicalPlan,
1312
+ planSummary: {
1313
+ apiVersion: canonicalPlan.apiVersion,
1314
+ operation: canonicalPlan.operation,
1315
+ },
1316
+ oldManifest,
1317
+ newManifest,
1318
+ state: journal.state,
1319
+ transitions: journal.transitions.map((transition) => ({ ...transition })),
1320
+ entries,
1321
+ createdAt: journal.createdAt,
1322
+ // The receipt replaces the full journal atomically; updatedAt records
1323
+ // the convergence instant (the full chain's own timestamps are preserved
1324
+ // verbatim in `transitions`).
1325
+ updatedAt: new Date().toISOString(),
1326
+ };
1327
+ }
1328
+
1329
+ /**
1330
+ * Remove recovery residue a terminal record no longer needs: the
1331
+ * `RECOVERY_REQUIRED` marker and the `backups/` directory (backups are named
1332
+ * `<entryIndex>.bak`). Called ONLY after the receipt has atomically and
1333
+ * durably replaced `journal.json` — the receipt is the authority first;
1334
+ * cleanup second.
1335
+ */
1336
+ async function removeTerminalRecoveryResidue(txnHandle, journal) {
1337
+ const marker = await txnHandle.readEntry('RECOVERY_REQUIRED');
1338
+ if (!(marker === null || marker?.kind === 'absent')) {
1339
+ await txnHandle.unlink('RECOVERY_REQUIRED');
1340
+ }
1341
+
1342
+ const backupsEntry = await txnHandle.readEntry('backups');
1343
+ if (backupsEntry === null || backupsEntry?.kind === 'absent') {
1344
+ await txnHandle.fsync();
1345
+ return;
1346
+ }
1347
+
1348
+ const backupCount = Math.max(journal.newManifest.length, journal.entries.length);
1349
+ const backupsHandle = await txnHandle.openDir('backups');
1350
+ try {
1351
+ for (let i = 0; i < backupCount; i += 1) {
1352
+ const name = `${i}.bak`;
1353
+ const bak = await backupsHandle.readEntry(name);
1354
+ if (!(bak === null || bak?.kind === 'absent')) {
1355
+ await backupsHandle.unlink(name);
1356
+ }
1357
+ }
1358
+ } finally {
1359
+ await backupsHandle.close();
1360
+ }
1361
+ await txnHandle.rmdir('backups');
1362
+ await txnHandle.fsync();
1363
+ }
1364
+
1365
+ /**
1366
+ * Converge a terminal (COMMITTED / ROLLED_BACK) transaction record to the
1367
+ * explicitly versioned small receipt (AC-1 terminal bounding) — a
1368
+ * POST-TERMINAL phase that preserves the durable ordering:
1369
+ *
1370
+ * full terminal journal durable
1371
+ * -> 'before-terminal-receipt-write' (fault point)
1372
+ * -> receipt atomically replaces journal.json (writeJsonViaHandle:
1373
+ * createTemp + fsync + rename + fsync)
1374
+ * -> 'after-terminal-receipt-write' (fault point)
1375
+ * -> backups/*.bak and RECOVERY_REQUIRED marker removed.
1376
+ *
1377
+ * Crash consistency (AC-2): a hard crash (INJECTED_CRASH) at either fault
1378
+ * point propagates verbatim and leaves the latest durable state untouched —
1379
+ * either the complete verifiable full journal (pre-receipt) or the complete
1380
+ * verifiable small receipt (post-receipt); both re-read COMMITTED /
1381
+ * ROLLED_BACK, never RECOVERY_REQUIRED / ROLLING_BACK.
1382
+ *
1383
+ * Honesty (AC-2): an ordinary failure (e.g. EIO) is wrapped as
1384
+ * TRANSACTION_INCOMPLETE with `terminalReceiptPersisted`,
1385
+ * `targetApplied` (true for COMMITTED — the target WAS applied+verified and
1386
+ * the complete verifiable full journal is retained at its latest durable
1387
+ * state; convergence never rewrites COMMITTED back), `transactionId`, and
1388
+ * `recover` guidance. Re-running convergence completes the record.
1389
+ *
1390
+ * @param {object} options
1391
+ * @param {object} options.txnHandle — DirectoryHandle for the transaction dir.
1392
+ * @param {string} options.transactionId — transaction ID.
1393
+ * @param {Function} [options.faultInjector] — fault injection for testing.
1394
+ * @param {string} [options.recoverCommand] — caller-supplied recover command.
1395
+ * @returns {Promise<object>} The validated receipt (or the journal unchanged
1396
+ * if the record is not terminal).
1397
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on convergence failure.
1398
+ */
1399
+ export async function convergeTerminalRecord({
1400
+ txnHandle,
1401
+ transactionId,
1402
+ faultInjector,
1403
+ recoverCommand,
1404
+ } = {}) {
1405
+ const journal = await readJournal(txnHandle, transactionId);
1406
+ if (!PRUNABLE_TERMINAL_STATES.has(journal.state)) {
1407
+ // Non-terminal records keep the FULL recovery authority — never converge.
1408
+ return journal;
1409
+ }
1410
+
1411
+ const recover = typeof recoverCommand === 'string' && recoverCommand.length > 0
1412
+ ? recoverCommand
1413
+ : `release-skill artifacts recover --transaction ${transactionId}`;
1414
+ let receiptPersisted = false;
1415
+ try {
1416
+ if (faultInjector) await faultInjector('before-terminal-receipt-write');
1417
+
1418
+ const receipt = buildTerminalReceipt(journal);
1419
+ // Fail closed on an invalid or oversized receipt BEFORE the atomic
1420
+ // replace: the full journal must remain untouched in that case.
1421
+ validateTerminalReceiptSchema(receipt);
1422
+ if (receipt.transactionId !== transactionId) {
1423
+ throw new ReleaseError(
1424
+ TRANSACTION_INCOMPLETE,
1425
+ 'terminal receipt transactionId does not match its directory authority',
1426
+ );
1427
+ }
1428
+ const serializedLength = Buffer.byteLength(JSON.stringify(receipt, null, 2), 'utf8');
1429
+ if (serializedLength > TERMINAL_RECEIPT_SIZE_CAP) {
1430
+ throw new ReleaseError(
1431
+ TRANSACTION_INCOMPLETE,
1432
+ `terminal receipt exceeds the fixed size cap: ${serializedLength} > ${TERMINAL_RECEIPT_SIZE_CAP}`,
1433
+ { serializedLength, cap: TERMINAL_RECEIPT_SIZE_CAP },
1434
+ );
1435
+ }
1436
+
1437
+ await writeJsonViaHandle(txnHandle, 'journal.json', receipt);
1438
+ receiptPersisted = true;
1439
+
1440
+ if (faultInjector) await faultInjector('after-terminal-receipt-write');
1441
+
1442
+ await removeTerminalRecoveryResidue(txnHandle, journal);
1443
+ return receipt;
1444
+ } catch (error) {
1445
+ // Hard-crash semantics: the recovery protocol must not rewrite the last
1446
+ // durable state; propagate the injected crash verbatim.
1447
+ if (error?.code === 'INJECTED_CRASH' || error?.name === 'InjectedCrash') {
1448
+ throw error;
1449
+ }
1450
+ const wrapped = new ReleaseError(
1451
+ TRANSACTION_INCOMPLETE,
1452
+ `terminal receipt convergence failed after the transaction reached ${journal.state}: `
1453
+ + (receiptPersisted
1454
+ ? 'the small terminal receipt is durable but residue cleanup did not complete'
1455
+ : 'the complete verifiable journal is retained on disk at its latest durable state')
1456
+ + `. ${recover}`,
1457
+ {
1458
+ transactionId,
1459
+ terminalReceiptPersisted: receiptPersisted,
1460
+ // COMMITTED means the target WAS applied and verified before
1461
+ // convergence; convergence failure must never lie about that.
1462
+ targetApplied: journal.state === 'COMMITTED',
1463
+ recover,
1464
+ phase: receiptPersisted ? 'terminal-residue-cleanup' : 'terminal-receipt-write',
1465
+ cause: error?.code || null,
1466
+ causeMessage: error?.message || null,
1467
+ },
1468
+ );
1469
+ // Marker for the transaction coordinator: this failure must bypass the
1470
+ // RECOVERY_REQUIRED protocol (the durable state is already terminal and
1471
+ // verifiable; re-running convergence completes the record).
1472
+ wrapped.terminalReceiptConvergenceFailed = true;
1473
+ wrapped.transactionId = transactionId;
1474
+ throw wrapped;
1475
+ }
1476
+ }
1477
+
1478
+ // ---------------------------------------------------------------------------
1479
+ // Retention (terminal-record pruning)
1480
+ // ---------------------------------------------------------------------------
1481
+
1482
+ /**
1483
+ * Prune the oldest TERMINAL transaction records beyond a retention cap.
1484
+ *
1485
+ * Maintenance path — deliberately tolerant and best-effort. Transaction
1486
+ * journals are written exclusively through the safe-fs DirectoryHandle, but
1487
+ * that handle exposes neither directory enumeration nor recursive removal,
1488
+ * which retention needs. This helper therefore uses best-effort `node:fs`
1489
+ * operations scoped strictly to the current process's transactions root, and
1490
+ * it NEVER throws: a retention failure must not abort or roll back a
1491
+ * transaction (see the module docstring for the documented exception).
1492
+ *
1493
+ * Safety rules (all enforced below):
1494
+ * - Only directory entries named `txn-*` are ever considered.
1495
+ * - A record is prunable only if its `journal.json` parses AND its `state`
1496
+ * is a member of PRUNABLE_TERMINAL_STATES (COMMITTED / ROLLED_BACK).
1497
+ * Both durable terminal shapes parse here: the full journal and the
1498
+ * converged terminal receipt — both keep `state` and `createdAt`
1499
+ * (receipts additionally carry `terminalReceiptVersion`, which this
1500
+ * tolerant scan ignores).
1501
+ * - Unreadable, corrupt, or journal-less records are treated as
1502
+ * NON-terminal and always kept, so recovery evidence is never destroyed.
1503
+ * - Non-terminal states are never counted against the cap and never pruned.
1504
+ * - Removal is oldest-first by journal `createdAt` (directory mtime
1505
+ * fallback; unknown age sorts last so it is pruned last).
1506
+ * - Fast path: if the total `txn-*` record count is within the cap, the
1507
+ * helper returns after a single directory read without opening any
1508
+ * journal.json (which can be MB-sized), since nothing can need pruning.
1509
+ *
1510
+ * @param {string} transactionsRoot — absolute path to the transactions dir.
1511
+ * @param {object} [options]
1512
+ * @param {number} [options.retentionMax=DEFAULT_TRANSACTION_RETENTION_MAX]
1513
+ * Non-negative integer cap on retained terminal records.
1514
+ * @returns {Promise<{considered:number, terminal:number, pruned:string[], errors:string[]}>}
1515
+ * Best-effort summary; `errors` collects per-record failures (never thrown).
1516
+ */
1517
+ export async function pruneTerminalTransactionRecords(transactionsRoot, {
1518
+ retentionMax = DEFAULT_TRANSACTION_RETENTION_MAX,
1519
+ } = {}) {
1520
+ const summary = { considered: 0, terminal: 0, pruned: [], errors: [] };
1521
+ try {
1522
+ if (typeof transactionsRoot !== 'string' || transactionsRoot.length === 0) return summary;
1523
+ if (!Number.isInteger(retentionMax) || retentionMax < 0) return summary;
1524
+
1525
+ let entries;
1526
+ try {
1527
+ entries = await readdir(transactionsRoot, { withFileTypes: true });
1528
+ } catch (scanErr) {
1529
+ // Missing or unreadable transactions root => nothing to prune.
1530
+ summary.errors.push(`scan: ${scanErr?.code || scanErr?.message || 'readdir-failed'}`);
1531
+ return summary;
1532
+ }
1533
+
1534
+ // Consider only transaction-record directories.
1535
+ const txnDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith('txn-'));
1536
+ summary.considered = txnDirs.length;
1537
+
1538
+ // Fast path: terminal records are a subset of all records, so if the total
1539
+ // record count is already within the cap, nothing can need pruning. Skip
1540
+ // reading any journal.json (non-terminal recovery records can still be
1541
+ // MB-sized; converged terminal records are bounded receipts) in the common
1542
+ // below-cap case; only pay the per-record read cost once accumulation
1543
+ // exceeds the cap.
1544
+ if (txnDirs.length <= retentionMax) return summary;
1545
+
1546
+ const terminal = [];
1547
+ for (const entry of txnDirs) {
1548
+ const recordDir = join(transactionsRoot, entry.name);
1549
+
1550
+ let state = null;
1551
+ let createdAt = null;
1552
+ try {
1553
+ const raw = await readFile(join(recordDir, 'journal.json'), 'utf8');
1554
+ const journal = JSON.parse(raw);
1555
+ if (journal && typeof journal === 'object') {
1556
+ state = typeof journal.state === 'string' ? journal.state : null;
1557
+ createdAt = typeof journal.createdAt === 'string' ? journal.createdAt : null;
1558
+ }
1559
+ } catch {
1560
+ // Unreadable/corrupt journal => keep (never prune ambiguous records).
1561
+ state = null;
1562
+ }
1563
+ if (!PRUNABLE_TERMINAL_STATES.has(state)) continue;
1564
+
1565
+ let sortTime = Date.parse(createdAt);
1566
+ if (!Number.isFinite(sortTime)) {
1567
+ try {
1568
+ const dirStat = await stat(recordDir);
1569
+ sortTime = dirStat.mtimeMs;
1570
+ } catch {
1571
+ sortTime = Number.MAX_SAFE_INTEGER; // unknown age => prune last
1572
+ }
1573
+ }
1574
+ summary.terminal += 1;
1575
+ terminal.push({ name: entry.name, dir: recordDir, sortTime });
1576
+ }
1577
+
1578
+ if (terminal.length <= retentionMax) return summary;
1579
+
1580
+ terminal.sort((a, b) => (a.sortTime - b.sortTime) || (a.name < b.name ? -1 : 1));
1581
+ const excess = terminal.length - retentionMax;
1582
+ for (let i = 0; i < excess; i += 1) {
1583
+ const victim = terminal[i];
1584
+ try {
1585
+ await rm(victim.dir, { recursive: true, force: true, maxRetries: 0 });
1586
+ summary.pruned.push(victim.name);
1587
+ } catch (rmErr) {
1588
+ summary.errors.push(`${victim.name}: ${rmErr?.code || rmErr?.message || 'rm-failed'}`);
1589
+ }
1590
+ }
1591
+ } catch (err) {
1592
+ summary.errors.push(`retention: ${err?.code || err?.message || 'unexpected'}`);
1593
+ }
1594
+ return summary;
1595
+ }
1596
+
1597
+ // ---------------------------------------------------------------------------
1598
+ // Journal creation
1599
+ // ---------------------------------------------------------------------------
1600
+
1601
+ /**
1602
+ * Create a new transaction journal through the safe-fs backend.
1603
+ *
1604
+ * Creates `.release-skill/transactions/<txnId>/journal.json` with initial
1605
+ * PREPARED state, canonical plan, old/new manifest.
1606
+ *
1607
+ * @param {object} options
1608
+ * @param {object} options.backend — safe-fs backend.
1609
+ * @param {object} options.rootHandle — open safe-fs handle for the repo root.
1610
+ * @param {string} [options.root] — repository root path. When provided, the
1611
+ * retention policy prunes the oldest terminal records beyond the cap before
1612
+ * the new record is created. Omit to skip retention (rootHandle-only path).
1613
+ * @param {string} options.transactionId — unique transaction ID.
1614
+ * @param {string} options.planDigest — canonical plan digest.
1615
+ * @param {object} options.canonicalPlan — decoded plan (with Buffer bytes).
1616
+ * @param {object[]} options.oldManifest — snapshot of old entries with
1617
+ * backup bytes (for absent entries, `absent: true`).
1618
+ * @param {object[]} options.newManifest — snapshot of new entries.
1619
+ * @param {number} [options.retentionMax] — override the retention cap
1620
+ * (defaults to DEFAULT_TRANSACTION_RETENTION_MAX).
1621
+ * @returns {Promise<{ journal: object, txnHandle: object }>}
1622
+ */
1623
+ export async function createTransactionJournal({
1624
+ rootHandle,
1625
+ root,
1626
+ transactionId,
1627
+ planDigest,
1628
+ canonicalPlan,
1629
+ oldManifest,
1630
+ newManifest,
1631
+ retentionMax,
1632
+ } = {}) {
1633
+ if (typeof transactionId !== 'string'
1634
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(transactionId)) {
1635
+ throw new ReleaseError(PATH_UNSAFE, 'transactionId is not a safe path segment');
1636
+ }
1637
+ if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
654
1638
  throw new ReleaseError(TRANSACTION_INCOMPLETE, 'planDigest is invalid');
655
1639
  }
656
1640
  const openedHandles = [];
@@ -663,6 +1647,17 @@ export async function createTransactionJournal({
663
1647
  openedHandles,
664
1648
  );
665
1649
 
1650
+ // Retention: prune the oldest terminal records beyond the cap before creating
1651
+ // the new record. Best-effort — never blocks journal creation (see
1652
+ // pruneTerminalTransactionRecords). Operates only on this root's
1653
+ // `.release-skill/transactions`; skipped when `root` is not provided.
1654
+ if (typeof root === 'string' && root.length > 0) {
1655
+ await pruneTerminalTransactionRecords(
1656
+ join(root, '.release-skill', 'transactions'),
1657
+ { retentionMax },
1658
+ );
1659
+ }
1660
+
666
1661
  // Create the transaction directory
667
1662
  try {
668
1663
  await txnParent.mkdir(transactionId, 0o700);
@@ -725,9 +1720,15 @@ export async function createTransactionJournal({
725
1720
  /**
726
1721
  * Read and validate the journal from a transaction handle.
727
1722
  *
1723
+ * Accepts both durable shapes: the full journal (recovery authority for
1724
+ * non-terminal states, and the pre-convergence record of a terminal state
1725
+ * left behind by a crash/IO failure at 'before-terminal-receipt-write') and
1726
+ * the explicitly versioned terminal receipt for converged COMMITTED /
1727
+ * ROLLED_BACK records. Terminal records are returned as validated receipts.
1728
+ *
728
1729
  * @param {object} txnHandle — DirectoryHandle for the transaction directory.
729
1730
  * @param {string} transactionId — for error context.
730
- * @returns {Promise<object>} Validated journal data.
1731
+ * @returns {Promise<object>} Validated journal data (full journal or receipt).
731
1732
  * @throws {ReleaseError} TRANSACTION_INCOMPLETE if missing or corrupt.
732
1733
  */
733
1734
  export async function readJournal(txnHandle, transactionId) {
@@ -757,6 +1758,14 @@ export async function readJournal(txnHandle, transactionId) {
757
1758
  /**
758
1759
  * Write a journal state transition through the transaction handle.
759
1760
  *
1761
+ * When the transition lands a PRUNABLE_TERMINAL state (COMMITTED /
1762
+ * ROLLED_BACK) and `convergeTerminal` is not false, the record is converged
1763
+ * to the explicitly versioned terminal receipt after the transition journal
1764
+ * is durable (see convergeTerminalRecord). Callers that run their own
1765
+ * convergence phase with their own fault-point ordering (the
1766
+ * applyWriteSetUnderLock 'after-committed' durable point) pass
1767
+ * `convergeTerminal: false`.
1768
+ *
760
1769
  * @param {object} options
761
1770
  * @param {object} options.txnHandle — DirectoryHandle.
762
1771
  * @param {string} options.transactionId — for error context.
@@ -764,7 +1773,13 @@ export async function readJournal(txnHandle, transactionId) {
764
1773
  * check for initial write).
765
1774
  * @param {string} options.to — target state.
766
1775
  * @param {number} [options.entryIndex] — entry index for write-ahead.
767
- * @returns {Promise<object>} Updated journal.
1776
+ * @param {Function} [options.faultInjector] — fault injection forwarded to
1777
+ * terminal convergence ('before-terminal-receipt-write' /
1778
+ * 'after-terminal-receipt-write').
1779
+ * @param {boolean} [options.convergeTerminal=true] — set false to defer
1780
+ * terminal convergence to the caller.
1781
+ * @returns {Promise<object>} Updated journal — the validated terminal
1782
+ * receipt when the transition landed a terminal state and convergence ran.
768
1783
  * @throws {ReleaseError} INVALID_STATE_TRANSITION on invalid transition.
769
1784
  */
770
1785
  export async function writeJournalTransition({
@@ -773,6 +1788,8 @@ export async function writeJournalTransition({
773
1788
  from,
774
1789
  to,
775
1790
  entryIndex,
1791
+ faultInjector,
1792
+ convergeTerminal = true,
776
1793
  } = {}) {
777
1794
  if (typeof from !== 'string' || !VALID_STATES.has(from)) {
778
1795
  throw new ReleaseError(
@@ -814,6 +1831,10 @@ export async function writeJournalTransition({
814
1831
 
815
1832
  await writeJsonViaHandle(txnHandle, 'journal.json', journal);
816
1833
 
1834
+ if (convergeTerminal && PRUNABLE_TERMINAL_STATES.has(journal.state)) {
1835
+ return await convergeTerminalRecord({ txnHandle, transactionId, faultInjector });
1836
+ }
1837
+
817
1838
  return journal;
818
1839
  }
819
1840