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
@@ -13,6 +13,7 @@
13
13
  import { randomBytes } from 'node:crypto';
14
14
  import { relative } from 'node:path';
15
15
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
16
+ import { redactSensitivePaths } from '../core/redact.mjs';
16
17
  import { canonicalArtifactPath } from './path-key.mjs';
17
18
  import { acquireProjectLock } from './project-lock.mjs';
18
19
 
@@ -32,6 +33,7 @@ import {
32
33
  recordAppliedEntry,
33
34
  createBackup,
34
35
  writeRecoveryRequiredFile,
36
+ convergeTerminalRecord,
35
37
  } from './transaction-journal.mjs';
36
38
 
37
39
  // ---------------------------------------------------------------------------
@@ -499,20 +501,20 @@ async function readCurrentEntry(handle, path) {
499
501
  // ---------------------------------------------------------------------------
500
502
 
501
503
  /**
502
- * Full preflight validation with zero filesystem side effects.
504
+ * Closed artifact-plan v1 schema validation (zero filesystem side effects).
503
505
  *
504
506
  * 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
+ * schema, entry schema/decoding, Buffer bytes range (0..255), and unknown
508
+ * fields/kinds. Path safety, path uniqueness, and full CAS are handled by
509
+ * the generic write-set preflight (performWriteSetPreflightAndCas).
507
510
  *
508
- * @param {object} handle — root DirectoryHandle.
509
511
  * @param {object} plan — decoded plan (bytes decoded in-place).
510
512
  * @param {string} planPath — for error context.
511
513
  * @throws {ReleaseError} On any validation failure.
512
514
  */
513
- async function performPreflightAndCas(handle, plan, planPath) {
515
+ function assertArtifactPlanClosedSchema(plan, planPath) {
514
516
  if (!plan || typeof plan !== 'object') {
515
- throw new ReleaseError(PLAN_STALE, 'plan is not a valid object', { path: planPath });
517
+ throw new ReleaseError(PLAN_STALE, 'plan is not a valid object', { path: redactSensitivePaths(planPath) });
516
518
  }
517
519
 
518
520
  if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1') {
@@ -570,7 +572,7 @@ async function performPreflightAndCas(handle, plan, planPath) {
570
572
  throw new ReleaseError(TRANSACTION_INCOMPLETE, 'plan nextAction must be the apply command');
571
573
  }
572
574
  if (!Array.isArray(plan.artifacts)) {
573
- throw new ReleaseError(PLAN_STALE, 'plan missing artifacts array', { path: planPath });
575
+ throw new ReleaseError(PLAN_STALE, 'plan missing artifacts array', { path: redactSensitivePaths(planPath) });
574
576
  }
575
577
 
576
578
  // P0-6: Validate no unknown plan fields (closed schema)
@@ -634,19 +636,51 @@ async function performPreflightAndCas(handle, plan, planPath) {
634
636
  validateAndDecodeEntry(artifact.newEntry, 'newEntry');
635
637
  validateAndDecodeEntry(artifact.oldEntry, 'oldEntry');
636
638
  }
639
+ }
640
+
641
+ /**
642
+ * Generic write-set preflight with zero filesystem side effects.
643
+ *
644
+ * Validates every write-set item (id, closed entry schema/decoding for
645
+ * oldEntry/newEntry, Buffer bytes range 0..255), path safety, path
646
+ * uniqueness, and runs the full CAS for every old entry BEFORE any target
647
+ * mutation may happen. Shared by the artifact-plan v1 path and the generic
648
+ * applyWriteSetUnderLock entry (docs-refresh and future write sets).
649
+ *
650
+ * @param {object} handle — root DirectoryHandle.
651
+ * @param {object[]} writeSet — items shaped { id, path, oldEntry, newEntry }.
652
+ * @throws {ReleaseError} On any validation failure or CAS mismatch.
653
+ */
654
+ async function performWriteSetPreflightAndCas(handle, writeSet) {
655
+ if (!Array.isArray(writeSet) || writeSet.length === 0) {
656
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'writeSet must be a non-empty array');
657
+ }
658
+
659
+ // Validate and decode all entries (in-place bytes modification)
660
+ for (const item of writeSet) {
661
+ if (!item || typeof item !== 'object' || Array.isArray(item)
662
+ || typeof item.id !== 'string' || item.id.length === 0) {
663
+ throw new ReleaseError(
664
+ TRANSACTION_INCOMPLETE,
665
+ 'writeSet item missing id or id is not a string',
666
+ );
667
+ }
668
+ validateAndDecodeEntry(item.newEntry, 'newEntry');
669
+ validateAndDecodeEntry(item.oldEntry, 'oldEntry');
670
+ }
637
671
 
638
672
  // Path validation
639
- for (const artifact of plan.artifacts) {
640
- validatePath(artifact.path);
673
+ for (const item of writeSet) {
674
+ validatePath(item.path);
641
675
  }
642
- validatePathUniqueness(plan.artifacts);
676
+ validatePathUniqueness(writeSet);
643
677
 
644
678
  // 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);
679
+ for (const item of writeSet) {
680
+ const canonicalPath = item.path.endsWith('/')
681
+ ? item.path.slice(0, -1)
682
+ : item.path;
683
+ await assertFullCas(handle, item.oldEntry, canonicalPath);
650
684
  }
651
685
  }
652
686
 
@@ -859,6 +893,9 @@ async function checkTargetUnchanged(handle, artifacts) {
859
893
  * @param {Error} options.originalError — the error that triggered recovery.
860
894
  * @param {object[]} options.artifacts — plan artifacts.
861
895
  * @param {boolean} options.journalCreated — whether journal exists.
896
+ * @param {string} [options.recoverCommand] — optional caller-supplied unique
897
+ * recover command; defaults to the authoritative artifacts recover command
898
+ * bound to the transaction id.
862
899
  * @returns {Promise<{ recoveryError: Error|null, targetUnchanged: boolean }>}
863
900
  */
864
901
  async function tryRecoveryProtocol({
@@ -868,6 +905,7 @@ async function tryRecoveryProtocol({
868
905
  originalError,
869
906
  artifacts,
870
907
  journalCreated,
908
+ recoverCommand,
871
909
  }) {
872
910
  // P0-8: Journal建立前失败不得谎称recovery
873
911
  if (!journalCreated) {
@@ -890,8 +928,11 @@ async function tryRecoveryProtocol({
890
928
  journalState = 'unreadable';
891
929
  }
892
930
 
893
- // P0-8: Unique recover command
894
- const recover = `release-skill artifacts recover --transaction ${transactionId}`;
931
+ // P0-8: Unique recover command (callers may bind their own recover command
932
+ // family; the default stays the authoritative artifacts recover command).
933
+ const recover = typeof recoverCommand === 'string' && recoverCommand.length > 0
934
+ ? recoverCommand
935
+ : `release-skill artifacts recover --transaction ${transactionId}`;
895
936
 
896
937
  let recoveryStatePersisted = journalState === 'RECOVERY_REQUIRED';
897
938
  let transitionErrorCode = null;
@@ -964,53 +1005,81 @@ function generateTransactionId(clock) {
964
1005
  // ---------------------------------------------------------------------------
965
1006
 
966
1007
  /**
967
- * Apply an artifact plan with durable transaction journaling.
1008
+ * Generic durable write-set application under a caller-held project lock.
1009
+ *
1010
+ * Implements the shared transaction core: multi-file preflight with full CAS
1011
+ * BEFORE the first target mutation, safe-fs probe, durable journal
1012
+ * (PREPARED → APPLYING → APPLIED → VERIFYING → COMMITTED), per-entry
1013
+ * write-ahead recording, per-entry re-CAS with the backup taken from the
1014
+ * SAME stable read, identity-bound createTemp+rename writes, manifest
1015
+ * verification, and the RECOVERY_REQUIRED recovery protocol with the unique
1016
+ * recover command on mid-flight failure.
968
1017
  *
969
- * All filesystem mutations use the safe-fs backend. No Node path writes.
1018
+ * The canonicalPlan is persisted as the journal authority; its schema is
1019
+ * validated by the transaction journal dispatch (artifact-plan v1 or
1020
+ * docs-refresh v1). The plan carries NO target bytes in the docs-refresh
1021
+ * case — new bytes live only in the write set and the journal manifests.
1022
+ *
1023
+ * ALL transactional filesystem mutations (journal, backup, target files) go
1024
+ * through the safe-fs backend DirectoryHandle; no Node path-based writes are
1025
+ * used for them. The only exception is the best-effort retention prune that
1026
+ * runs inside createTransactionJournal — see pruneTerminalTransactionRecords
1027
+ * in transaction-journal.mjs.
970
1028
  *
971
1029
  * @param {object} options
972
1030
  * @param {string} options.root — Repository root (absolute).
973
- * @param {string} options.planPathPath to the artifact plan file.
974
- * @param {string} options.planDigestExpected plan digest.
975
- * @param {object} [options.safeFs]Safe filesystem backend.
1031
+ * @param {object[]} options.writeSetitems shaped { id, path, oldEntry, newEntry }.
1032
+ * @param {object} options.canonicalPlanjournal authority plan (closed schema).
1033
+ * @param {string} options.planDigestsha256:<64hex> digest binding the journal.
1034
+ * @param {object} [options.safeFs] — Safe filesystem backend (required).
976
1035
  * @param {Function} [options.faultInjector] — Fault injection for testing.
977
- * @param {Function} [options.clock] — Clock function for timestamps.
1036
+ * @param {Function} [options.clock] — Clock function for transaction ids.
1037
+ * @param {Function} [options.assertLockOwner] — Caller-held lock assertion.
1038
+ * @param {string} [options.recoverCommand] — Optional unique recover command.
1039
+ * @param {object} [options.rootHandle] — Internal reuse: an already-open root
1040
+ * handle owned by the caller (not closed here).
1041
+ * @param {number} [options.transactionRetentionMax] — Optional cap on retained
1042
+ * terminal transaction records (defaults to DEFAULT_TRANSACTION_RETENTION_MAX).
978
1043
  * @returns {Promise<TransactionResult>}
979
- * @throws {ReleaseError} On validation failure or CAS mismatch.
1044
+ * @throws {ReleaseError} On validation failure, CAS mismatch, or mid-flight
1045
+ * failure (RECOVERY_REQUIRED protocol).
980
1046
  */
981
- async function applyArtifactPlanUnderLock({
1047
+ export async function applyWriteSetUnderLock({
982
1048
  root,
983
- planPath,
1049
+ writeSet,
1050
+ canonicalPlan,
984
1051
  planDigest,
985
1052
  safeFs,
986
1053
  faultInjector,
987
1054
  clock,
988
1055
  assertLockOwner = async () => {},
1056
+ recoverCommand,
1057
+ rootHandle = null,
1058
+ transactionRetentionMax,
989
1059
  } = {}) {
990
1060
  // === PHASE 0: validate inputs and safe-fs availability ===
991
1061
 
992
1062
  if (!root || typeof root !== 'string') {
993
1063
  throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
994
1064
  }
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
1065
  if (!safeFs) {
1005
1066
  throw new ReleaseError(
1006
1067
  SAFE_WRITE_UNAVAILABLE,
1007
1068
  'safe filesystem backend is required',
1008
1069
  );
1009
1070
  }
1071
+ if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
1072
+ throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
1073
+ }
1074
+ if (!Array.isArray(writeSet) || writeSet.length === 0) {
1075
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'writeSet must be a non-empty array');
1076
+ }
1077
+ if (!canonicalPlan || typeof canonicalPlan !== 'object' || Array.isArray(canonicalPlan)) {
1078
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'canonicalPlan must be an object');
1079
+ }
1010
1080
 
1011
- // === PHASE 1: validate plan file through safe-fs ===
1012
-
1013
- const handle = await safeFs.openRoot(root);
1081
+ const handle = rootHandle ?? await safeFs.openRoot(root);
1082
+ const ownsRootHandle = rootHandle === null;
1014
1083
  let txnResult;
1015
1084
  const assertLockAuthority = async () => {
1016
1085
  try {
@@ -1021,59 +1090,9 @@ async function applyArtifactPlanUnderLock({
1021
1090
  }
1022
1091
  };
1023
1092
  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
1093
  // === PHASE 3: full preflight + CAS (zero side effects) ===
1075
1094
 
1076
- await performPreflightAndCas(handle, plan, planPath);
1095
+ await performWriteSetPreflightAndCas(handle, writeSet);
1077
1096
 
1078
1097
  if (faultInjector) {
1079
1098
  await faultInjector('preflight-complete');
@@ -1097,24 +1116,21 @@ async function applyArtifactPlanUnderLock({
1097
1116
  const transactionId = generateTransactionId(clock);
1098
1117
  let journalCreated = false;
1099
1118
 
1100
- const oldManifest = await buildOldManifest(handle, plan.artifacts);
1101
- const newManifest = buildNewManifest(plan.artifacts);
1119
+ const oldManifest = await buildOldManifest(handle, writeSet);
1120
+ const newManifest = buildNewManifest(writeSet);
1102
1121
 
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
- }
1122
+ await assertLockAuthority();
1123
+ txnResult = await createTransactionJournal({
1124
+ rootHandle: handle,
1125
+ root,
1126
+ transactionId,
1127
+ planDigest,
1128
+ canonicalPlan,
1129
+ oldManifest,
1130
+ newManifest,
1131
+ retentionMax: transactionRetentionMax,
1132
+ });
1133
+ journalCreated = true;
1118
1134
 
1119
1135
  const { txnHandle } = txnResult;
1120
1136
 
@@ -1131,11 +1147,11 @@ async function applyArtifactPlanUnderLock({
1131
1147
  });
1132
1148
  if (faultInjector) await faultInjector('after-applying-transition');
1133
1149
 
1134
- // === PHASE 6: apply each artifact with write-ahead journaling ===
1150
+ // === PHASE 6: apply each entry with write-ahead journaling ===
1135
1151
 
1136
1152
  const results = [];
1137
- for (let i = 0; i < plan.artifacts.length; i++) {
1138
- const artifact = plan.artifacts[i];
1153
+ for (let i = 0; i < writeSet.length; i++) {
1154
+ const item = writeSet[i];
1139
1155
 
1140
1156
  // Write-ahead: record entry index BEFORE mutation
1141
1157
  await assertLockAuthority();
@@ -1143,24 +1159,24 @@ async function applyArtifactPlanUnderLock({
1143
1159
  txnHandle,
1144
1160
  transactionId,
1145
1161
  entryIndex: i,
1146
- entry: { id: artifact.id, path: artifact.path, status: 'pending' },
1162
+ entry: { id: item.id, path: item.path, status: 'pending' },
1147
1163
  });
1148
1164
  if (faultInjector) await faultInjector(`after-entry-pending:${i}`);
1149
1165
 
1150
1166
  // 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);
1167
+ const canonicalPath = item.path.endsWith('/')
1168
+ ? item.path.slice(0, -1)
1169
+ : item.path;
1170
+ const current = await assertFullCas(handle, item.oldEntry, canonicalPath);
1155
1171
 
1156
1172
  // P0-4: Create backup from the SAME stable read as CAS verification
1157
1173
  // backup bytes must come from this CAS read, not a separate one
1158
- if (artifact.oldEntry && artifact.oldEntry.kind === 'regular') {
1174
+ if (item.oldEntry && item.oldEntry.kind === 'regular') {
1159
1175
  // The exact bytes and unforgeable identity token come from the same
1160
1176
  // 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}`;
1177
+ const expectedOldSha = item.oldEntry.sha256.startsWith('sha256:')
1178
+ ? item.oldEntry.sha256
1179
+ : `sha256:${item.oldEntry.sha256}`;
1164
1180
  if (current.sha256 !== expectedOldSha) {
1165
1181
  throw new ReleaseError(
1166
1182
  PLAN_STALE,
@@ -1173,7 +1189,7 @@ async function applyArtifactPlanUnderLock({
1173
1189
  txnHandle,
1174
1190
  transactionId,
1175
1191
  entryIndex: i,
1176
- oldEntry: { ...artifact.oldEntry, bytes: current.bytes },
1192
+ oldEntry: { ...item.oldEntry, bytes: current.bytes },
1177
1193
  });
1178
1194
  } else {
1179
1195
  await assertLockAuthority();
@@ -1186,12 +1202,12 @@ async function applyArtifactPlanUnderLock({
1186
1202
  }
1187
1203
  if (faultInjector) await faultInjector(`after-entry-backup:${i}`);
1188
1204
 
1189
- // Apply artifact mutation through safe-fs handle
1205
+ // Apply the mutation through the safe-fs handle
1190
1206
  if (faultInjector) await faultInjector(`before-entry-mutation:${i}`);
1191
1207
  await assertLockAuthority();
1192
1208
  await applySingleArtifact(
1193
1209
  handle,
1194
- artifact,
1210
+ item,
1195
1211
  current?.kind === 'regular' ? current.identityToken : null,
1196
1212
  );
1197
1213
  if (faultInjector) await faultInjector(`after-entry-mutation:${i}`);
@@ -1202,11 +1218,11 @@ async function applyArtifactPlanUnderLock({
1202
1218
  txnHandle,
1203
1219
  transactionId,
1204
1220
  entryIndex: i,
1205
- entry: { id: artifact.id, path: artifact.path, status: 'applied' },
1221
+ entry: { id: item.id, path: item.path, status: 'applied' },
1206
1222
  });
1207
1223
  if (faultInjector) await faultInjector(`after-entry-applied:${i}`);
1208
1224
 
1209
- results.push({ id: artifact.id, path: artifact.path, applied: true });
1225
+ results.push({ id: item.id, path: item.path, applied: true });
1210
1226
 
1211
1227
  }
1212
1228
 
@@ -1230,19 +1246,36 @@ async function applyArtifactPlanUnderLock({
1230
1246
  });
1231
1247
  if (faultInjector) await faultInjector('after-verifying-transition');
1232
1248
 
1233
- await verifyManifest(handle, plan.artifacts);
1249
+ await verifyManifest(handle, writeSet);
1234
1250
  if (faultInjector) await faultInjector('after-verify');
1235
1251
 
1236
1252
  await assertLockAuthority();
1253
+ // The full COMMITTED journal becomes durable first (the 'after-committed'
1254
+ // durable point); terminal convergence runs as a POST-COMMITTED phase so
1255
+ // a crash at 'after-committed' still leaves the complete full record.
1237
1256
  await writeJournalTransition({
1238
1257
  txnHandle,
1239
1258
  transactionId,
1240
1259
  from: 'VERIFYING',
1241
1260
  to: 'COMMITTED',
1261
+ convergeTerminal: false,
1242
1262
  });
1243
1263
  if (faultInjector) await faultInjector('after-committed');
1244
1264
 
1245
- const finalJournal = await readJournal(txnHandle, transactionId);
1265
+ // === PHASE 8: terminal convergence to the versioned receipt (AC-1) ===
1266
+ // Atomically rewrites journal.json as the small terminal receipt
1267
+ // ('before-terminal-receipt-write' / 'after-terminal-receipt-write' fault
1268
+ // points), then removes the now-unneeded backups/ and RECOVERY_REQUIRED
1269
+ // marker. A convergence failure rejects honestly (TRANSACTION_INCOMPLETE
1270
+ // with terminalReceiptPersisted/targetApplied) and keeps the complete
1271
+ // verifiable COMMITTED record on disk — see convergeTerminalRecord.
1272
+ await assertLockAuthority();
1273
+ const finalJournal = await convergeTerminalRecord({
1274
+ txnHandle,
1275
+ transactionId,
1276
+ faultInjector,
1277
+ recoverCommand,
1278
+ });
1246
1279
 
1247
1280
  return Object.freeze({
1248
1281
  transactionId,
@@ -1261,6 +1294,16 @@ async function applyArtifactPlanUnderLock({
1261
1294
  if (applyErr?.lockOwnershipLost === true) {
1262
1295
  throw applyErr;
1263
1296
  }
1297
+ // Terminal receipt convergence failure AFTER the target was applied,
1298
+ // verified, and durably COMMITTED: the complete verifiable record is
1299
+ // already on disk at its latest durable state (the full COMMITTED
1300
+ // journal, or the durable receipt if cleanup failed). The RECOVERY_REQUIRED
1301
+ // protocol must NOT run — COMMITTED has no outgoing transitions, the
1302
+ // target must never be reported as needing rollback, and re-running
1303
+ // convergence completes the record. Propagate the honest error verbatim.
1304
+ if (applyErr?.terminalReceiptConvergenceFailed === true) {
1305
+ throw applyErr;
1306
+ }
1264
1307
 
1265
1308
  const { recoveryError } = await tryRecoveryProtocol({
1266
1309
  rootHandle: handle,
@@ -1270,8 +1313,9 @@ async function applyArtifactPlanUnderLock({
1270
1313
  typeof applyErr?.code === 'string' ? applyErr.code : TRANSACTION_INCOMPLETE,
1271
1314
  applyErr?.message || 'safe filesystem operation failed',
1272
1315
  ),
1273
- artifacts: plan.artifacts,
1316
+ artifacts: writeSet,
1274
1317
  journalCreated,
1318
+ recoverCommand,
1275
1319
  });
1276
1320
 
1277
1321
  if (recoveryError) {
@@ -1289,15 +1333,154 @@ async function applyArtifactPlanUnderLock({
1289
1333
  closeError = error;
1290
1334
  }
1291
1335
  }
1292
- try {
1293
- await handle.close();
1294
- } catch (error) {
1295
- closeError ??= error;
1336
+ if (ownsRootHandle) {
1337
+ try {
1338
+ await handle.close();
1339
+ } catch (error) {
1340
+ closeError ??= error;
1341
+ }
1296
1342
  }
1297
1343
  if (closeError) throw closeError;
1298
1344
  }
1299
1345
  }
1300
1346
 
1347
+ /**
1348
+ * Apply an artifact plan with durable transaction journaling.
1349
+ *
1350
+ * Reads and digest-verifies the artifact-plan v1 plan file through safe-fs
1351
+ * handles, validates the closed v1 schema, then delegates the write set to
1352
+ * the generic applyWriteSetUnderLock transaction core. All filesystem
1353
+ * mutations use the safe-fs backend. No Node path writes.
1354
+ *
1355
+ * @param {object} options
1356
+ * @param {string} options.root — Repository root (absolute).
1357
+ * @param {string} options.planPath — Path to the artifact plan file.
1358
+ * @param {string} options.planDigest — Expected plan digest.
1359
+ * @param {object} [options.safeFs] — Safe filesystem backend.
1360
+ * @param {Function} [options.faultInjector] — Fault injection for testing.
1361
+ * @param {Function} [options.clock] — Clock function for timestamps.
1362
+ * @returns {Promise<TransactionResult>}
1363
+ * @throws {ReleaseError} On validation failure or CAS mismatch.
1364
+ */
1365
+ async function applyArtifactPlanUnderLock({
1366
+ root,
1367
+ planPath,
1368
+ planDigest,
1369
+ safeFs,
1370
+ faultInjector,
1371
+ clock,
1372
+ assertLockOwner = async () => {},
1373
+ } = {}) {
1374
+ // === PHASE 0: validate inputs and safe-fs availability ===
1375
+
1376
+ if (!root || typeof root !== 'string') {
1377
+ throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string');
1378
+ }
1379
+ if (!planPath || typeof planPath !== 'string') {
1380
+ throw new ReleaseError(MISSING_PARAMETERS, 'planPath is required');
1381
+ }
1382
+ if (!planDigest || typeof planDigest !== 'string') {
1383
+ throw new ReleaseError(MISSING_PARAMETERS, 'planDigest is required');
1384
+ }
1385
+ if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
1386
+ throw new ReleaseError(PLAN_STALE, 'planDigest must be a sha256 digest');
1387
+ }
1388
+ if (!safeFs) {
1389
+ throw new ReleaseError(
1390
+ SAFE_WRITE_UNAVAILABLE,
1391
+ 'safe filesystem backend is required',
1392
+ );
1393
+ }
1394
+
1395
+ // === PHASE 1: validate plan file through safe-fs ===
1396
+
1397
+ const handle = await safeFs.openRoot(root);
1398
+ try {
1399
+
1400
+ // Convert the absolute plan path to a canonical root-relative path before
1401
+ // any fd-relative access. This rejects root itself and all escape spellings.
1402
+ const relPlanPath = canonicalArtifactPath(relative(root, planPath)).path;
1403
+ const planFileData = await withParentHandle(handle, relPlanPath, async (parent, leaf) => {
1404
+ const planEntry = await parent.readEntry(leaf);
1405
+ if (!planEntry || planEntry.kind === 'absent') {
1406
+ throw new ReleaseError(PLAN_STALE, 'plan file does not exist', { path: redactSensitivePaths(planPath) });
1407
+ }
1408
+ const planIsRegular = planEntry.kind === 'regular'
1409
+ || planEntry.type === 'file'
1410
+ || planEntry.type === 'blob';
1411
+ if (!planIsRegular) {
1412
+ throw new ReleaseError(PATH_UNSAFE, 'plan path is not a regular file', { path: redactSensitivePaths(planPath) });
1413
+ }
1414
+ if (typeof planEntry.nlink === 'number' && planEntry.nlink !== 1) {
1415
+ throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
1416
+ }
1417
+ const data = await parent.readFile(leaf);
1418
+ if (!data) {
1419
+ throw new ReleaseError(PLAN_STALE, 'plan file is unreadable', { path: redactSensitivePaths(planPath) });
1420
+ }
1421
+ if (Number(data.nlink) !== 1) {
1422
+ throw new ReleaseError(PATH_UNSAFE, 'plan file has unexpected hard link count');
1423
+ }
1424
+ return data;
1425
+ });
1426
+
1427
+ let plan;
1428
+ try {
1429
+ plan = JSON.parse(planFileData.bytes.toString('utf8'));
1430
+ } catch (err) {
1431
+ throw new ReleaseError(
1432
+ PLAN_STALE,
1433
+ 'plan file is not valid JSON',
1434
+ { path: redactSensitivePaths(planPath), error: err.message },
1435
+ );
1436
+ }
1437
+
1438
+ // === PHASE 2: recompute canonical plan digest ===
1439
+
1440
+ const recomputedDigest = computeCanonicalPlanDigest(plan);
1441
+ if (plan.planDigest !== planDigest || recomputedDigest !== planDigest) {
1442
+ throw new ReleaseError(
1443
+ PLAN_STALE,
1444
+ 'plan digest does not match expected',
1445
+ { expected: planDigest, embedded: plan.planDigest, actual: recomputedDigest },
1446
+ );
1447
+ }
1448
+
1449
+ // === PHASE 3: closed artifact-plan v1 schema (zero side effects) ===
1450
+
1451
+ assertArtifactPlanClosedSchema(plan, planPath);
1452
+
1453
+ // === Delegate to the generic write-set transaction core ===
1454
+ // The closed v1 schema guarantees every artifact carries id/path/oldEntry/
1455
+ // newEntry (bytes decoded in place). Path safety, path uniqueness, full
1456
+ // CAS, probing, durable journaling, per-entry CAS/backup, manifest
1457
+ // verification, recovery, and the fault-injector point names all come from
1458
+ // the shared applyWriteSetUnderLock core.
1459
+ const writeSet = plan.artifacts.map((artifact) => ({
1460
+ id: artifact.id,
1461
+ path: artifact.path,
1462
+ oldEntry: artifact.oldEntry,
1463
+ newEntry: artifact.newEntry,
1464
+ }));
1465
+ return await applyWriteSetUnderLock({
1466
+ root,
1467
+ writeSet,
1468
+ canonicalPlan: plan,
1469
+ planDigest,
1470
+ safeFs,
1471
+ faultInjector,
1472
+ clock,
1473
+ assertLockOwner,
1474
+ rootHandle: handle,
1475
+ });
1476
+ } finally {
1477
+ // The root handle is owned by this wrapper; the generic core reuses it
1478
+ // without closing it (mirrors the pre-refactor single-handle lifecycle).
1479
+ // A close failure after any outcome fails closed.
1480
+ await handle.close();
1481
+ }
1482
+ }
1483
+
1301
1484
  /**
1302
1485
  * Public apply entry. The shared project lock is held from plan read and
1303
1486
  * preflight through COMMITTED or durable RECOVERY_REQUIRED. Direct API users