artshelf 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ const REVIEW_SAFETY = {
7
7
  noResolveRan: true,
8
8
  noDeleteRan: true
9
9
  };
10
- export function reviewNextAction(summary, scope, ledgerPath) {
10
+ export function reviewNextAction(summary, scope, ledgerPath, registryPath) {
11
11
  const broken = summary.invalid + summary.stale;
12
12
  const review = statusCommand(scope, "review", ledgerPath);
13
13
  if (broken > 0) {
@@ -18,25 +18,30 @@ export function reviewNextAction(summary, scope, ledgerPath) {
18
18
  const dryRun = scope === "all" ? "artshelf cleanup --dry-run --all" : `artshelf cleanup --dry-run${ledgerPath ? ` --ledger ${ledgerPath}` : ""}`;
19
19
  return `run \`${dryRun}\` to generate plans, then \`artshelf cleanup --execute --plan-id <id> --ledger <path>\` for each reviewed plan`;
20
20
  }
21
- if (summary.missingPath > 0) {
22
- return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
21
+ if (summary.missingPath > 0 || summary.reconcileEntries > 0 || summary.reconcileBlocked > 0) {
22
+ const reconcile = scope === "all" ? `artshelf reconcile --dry-run --all${registryPath ? ` --registry ${registryPath}` : ""}` : `artshelf reconcile --dry-run --ledger ${ledgerPath}`;
23
+ return `run \`${reconcile} --json\` and then \`${review}\` to surface reconcile-ready approvals; nothing is auto-executable`;
23
24
  }
24
25
  return "nothing to do — no broken ledgers and no due, manual-review, missing-path, or executable cleanup entries";
25
26
  }
26
27
  export function printReviewAll(results, summary, nextAction, registryPath) {
27
- const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
28
+ const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath + summary.reconcileEntries + summary.reconcileBlocked > 0;
28
29
  process.stdout.write(`${attentionGlyph(needsAttention)} artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
29
30
  process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
30
31
  printReview(results);
31
- process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
32
+ process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped} · reconcile ${summary.reconcileEntries} · blocked ${summary.reconcileBlocked}\n`);
32
33
  process.stdout.write(`next: ${nextAction}\n`);
33
34
  }
34
35
  export function printReview(results) {
35
36
  for (const result of results) {
36
37
  const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
37
- const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0;
38
+ const reconcileEntries = result.reconcile?.plan.entries.length ?? 0;
39
+ const reconcileBlocked = result.reconcile?.plan.blocked.length ?? 0;
40
+ const reconcileDrift = reconcileEntries + reconcileBlocked;
41
+ const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0 || reconcileDrift > 0;
42
+ const reconcileDetail = reconcileDrift > 0 ? `; reconcile: ${reconcileEntries} entries, ${reconcileBlocked} blocked` : "";
38
43
  process.stdout.write(`${attentionGlyph(needsAttention)} [${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
39
- process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
44
+ process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped${reconcileDetail}\n`);
40
45
  process.stdout.write(`ledger: ${result.ledger.path}\n`);
41
46
  }
42
47
  }
@@ -60,7 +65,15 @@ function buildReviewDecisions(results, scope) {
60
65
  });
61
66
  continue;
62
67
  }
63
- const missingPath = due.filter((entry) => entry.dueStatus === "missing-path");
68
+ const handledReconcileIds = new Set([
69
+ ...(result.reconcile?.plan.entries.map((entry) => entry.id) ?? []),
70
+ ...(result.reconcile?.plan.blocked.map((entry) => entry.id) ?? [])
71
+ ]);
72
+ const reconcileActions = buildReconcileDecisions(result, scope);
73
+ readyForApproval.push(...reconcileActions.readyForApproval);
74
+ needsReviewFirst.push(...reconcileActions.needsReviewFirst);
75
+ blocked.push(...reconcileActions.blocked);
76
+ const missingPath = due.filter((entry) => entry.dueStatus === "missing-path" && !handledReconcileIds.has(entry.id));
64
77
  const trashSafe = due.filter((entry) => entry.dueStatus === "due" && entry.cleanup === "trash");
65
78
  const inspectItems = due.filter((entry) => entry.dueStatus === "manual-review" ||
66
79
  (entry.dueStatus === "due" && (entry.cleanup === "review" || entry.cleanup === "delete")));
@@ -103,6 +116,57 @@ function buildReviewDecisions(results, scope) {
103
116
  }
104
117
  return { readyForApproval, needsReviewFirst, blocked };
105
118
  }
119
+ function buildReconcileDecisions(result, _scope) {
120
+ if (!result.reconcile)
121
+ return { readyForApproval: [], needsReviewFirst: [], blocked: [] };
122
+ const readyForApproval = [];
123
+ const needsReviewFirst = [];
124
+ const blocked = [];
125
+ const hasReviewedPlan = Boolean(result.reconcile.reviewedPlan && result.reconcile.reviewedPlan.planId !== "not-created");
126
+ const reviewedPlanId = result.reconcile.reviewedPlan?.planId ?? null;
127
+ const byCategory = {
128
+ remap: [],
129
+ "resolve-missing": [],
130
+ "resolve-stale-trash": [],
131
+ "registry-remap": [],
132
+ blocked: []
133
+ };
134
+ for (const finding of result.reconcile.plan.entries.concat(result.reconcile.plan.blocked)) {
135
+ byCategory[finding.category].push(finding);
136
+ }
137
+ const reconcileActionCategories = ["remap", "resolve-missing", "resolve-stale-trash", "registry-remap"];
138
+ for (const category of reconcileActionCategories) {
139
+ const entries = byCategory[category];
140
+ if (entries.length === 0)
141
+ continue;
142
+ const ids = entries.map((entry) => entry.id).sort();
143
+ const label = `Review ${entries.length} reconcile ${category} finding(s) in ${result.ledger.name}`;
144
+ const reason = `recorded paths are ${category === "remap" ? "safe to remap" : "stale and require manual review before execution"}`;
145
+ const decision = {
146
+ label,
147
+ itemIds: ids,
148
+ actionType: "reconcile",
149
+ approvalTarget: hasReviewedPlan ? `approve artshelf reconcile ledger ${result.ledger.path} plan ${reviewedPlanId}` : null,
150
+ reason,
151
+ nextStep: hasReviewedPlan
152
+ ? `run \`artshelf reconcile --execute --plan-id ${reviewedPlanId} --ledger ${result.ledger.path}\``
153
+ : `run \`artshelf reconcile --dry-run --ledger ${result.ledger.path} --json\`, then approve with \`approve artshelf reconcile ledger ${result.ledger.path} plan <plan-id>\``
154
+ };
155
+ (hasReviewedPlan ? readyForApproval : needsReviewFirst).push(decision);
156
+ }
157
+ if (byCategory.blocked.length > 0) {
158
+ const entries = byCategory.blocked;
159
+ blocked.push({
160
+ label: `Review ${entries.length} blocked reconcile finding(s) in ${result.ledger.name}`,
161
+ itemIds: entries.map((entry) => entry.id).sort(),
162
+ actionType: "reconcile",
163
+ approvalTarget: null,
164
+ reason: "path drift is ambiguous or unsafe and needs manual investigation",
165
+ nextStep: `run \`artshelf reconcile --dry-run --ledger ${result.ledger.path} --json\`, then handle each item manually`
166
+ });
167
+ }
168
+ return { readyForApproval, needsReviewFirst, blocked };
169
+ }
106
170
  function reviewCounts(summary) {
107
171
  return {
108
172
  due: summary.due,
@@ -131,7 +195,7 @@ export function buildReviewAgentPacketAll(results, summary, registry) {
131
195
  needsReviewFirst: groups.needsReviewFirst,
132
196
  blocked: groups.blocked,
133
197
  safety: REVIEW_SAFETY,
134
- nextAction: reviewNextAction(summary, "all"),
198
+ nextAction: reviewNextAction(summary, "all", undefined, registry.path),
135
199
  verification: `artshelf review --all --agent --registry ${registry.path}`
136
200
  };
137
201
  }
@@ -14,7 +14,7 @@ export function statusCommand(scope, command, ledgerPath) {
14
14
  return `artshelf ${command} --all`;
15
15
  return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
16
16
  }
17
- function statusNextAction(blockers, counts, scope, ledgerPath) {
17
+ function statusNextAction(blockers, counts, scope, ledgerPath, registryPath) {
18
18
  if (blockers.length > 0) {
19
19
  const verify = statusCommand(scope, "status", ledgerPath);
20
20
  return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
@@ -27,7 +27,8 @@ function statusNextAction(blockers, counts, scope, ledgerPath) {
27
27
  return `run \`${review}\` to inspect manual-review records; nothing is auto-executed`;
28
28
  }
29
29
  if (counts.missingPath > 0) {
30
- return "inspect missing-path records and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
30
+ const reconcile = scope === "all" ? `artshelf reconcile --dry-run --all${registryPath ? ` --registry ${registryPath}` : ""}` : `artshelf reconcile --dry-run --ledger ${ledgerPath}`;
31
+ return `run \`${reconcile} --json\` and then \`${review}\` to surface reconcile-ready approvals; nothing is auto-executable`;
31
32
  }
32
33
  return "nothing due — no broken ledgers and no due, manual-review, missing-path, or pending cleanup entries";
33
34
  }
@@ -58,7 +59,7 @@ export function buildStatusAgentPacketAll(report) {
58
59
  counts,
59
60
  attention: statusAttention(counts),
60
61
  blockers,
61
- nextAction: statusNextAction(blockers, counts, "all"),
62
+ nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath),
62
63
  verification: `artshelf status --all --agent --registry ${report.registryPath}`
63
64
  };
64
65
  }
@@ -188,7 +188,8 @@ Resolved records stay in the audit trail but no longer participate in due or cle
188
188
 
189
189
  Review runs validate, due, and cleanup plan preview without moving files or
190
190
  writing a plan. With --all, review adds aggregate triage counts and the next
191
- safe action.
191
+ safe action, including reconcile entry and blocked counts when path drift is
192
+ detected.
192
193
 
193
194
  Render modes:
194
195
  (default) Human summary of validation, triage counts, and the next safe action.
@@ -38,8 +38,8 @@
38
38
  <h1>Execute only what was reviewed and approved.</h1>
39
39
  <p class="lede">
40
40
  Clean is meant to be boring. The human approves one plan, the agent runs
41
- exactly that plan, writes a receipt, and checks that the next review
42
- comes back quiet.
41
+ exactly that plan, leaves durable receipt evidence before moving files,
42
+ and checks that the next review comes back quiet.
43
43
  </p>
44
44
 
45
45
  <section>
@@ -90,10 +90,11 @@ artshelf resolve &lt;id&gt; --status resolved --reason &lt;text&gt;</code></pre>
90
90
  <h2>Verify quiet</h2>
91
91
  <p>After cleanup execute or resolve, verify with <code>artshelf review --all --json</code>.</p>
92
92
  <p>
93
- Execution writes a receipt and updates touched ledger records to
94
- <code>trashed</code>, <code>review-required</code>, or
95
- <code>cleanup-refused</code>. Generated plans and receipts are recorded as
96
- <code>owner=artshelf</code> artifacts.
93
+ Execution writes a started receipt before the first move, completes it after
94
+ ledger updates, and updates touched ledger records to <code>trashed</code>,
95
+ <code>review-required</code>, or <code>cleanup-refused</code>. Rerunning the same
96
+ plan id resumes or idempotently replays durable receipt/trash evidence.
97
+ Generated plans and receipts are recorded as <code>owner=artshelf</code> artifacts.
97
98
  </p>
98
99
  </section>
99
100
  </article>
@@ -111,6 +111,13 @@ artshelf doctor --agent
111
111
  <span class="c"># lightweight counts, cron-friendly</span>
112
112
  artshelf status --all --json
113
113
  artshelf status --all --agent</code></pre>
114
+ <p>
115
+ If any ledger is stale or missing-path, run the safe sequence in order:
116
+ <code>review --all --json</code> for affected classifications,
117
+ <code>validate --all --json</code> for provenance signals, then
118
+ <code>reconcile --dry-run --all --json --registry &lt;registry-path&gt;</code>
119
+ before any approve/execute step.
120
+ </p>
114
121
  <p>
115
122
  Use <code>--agent</code> for concise monitor decisions and exact next
116
123
  actions. Use <code>--json</code> instead when the job needs full
@@ -44,10 +44,15 @@
44
44
  <section>
45
45
  <h2>Daily review workflow</h2>
46
46
  <ol>
47
- <li>Read <code>ledgers list --json</code>, <code>review --all --agent</code>, and <code>trash list --all --json</code>.</li>
47
+ <li>Start with health and registry-level context: <code>status --all --agent</code>,
48
+ then read raw pressure points from <code>review --all --agent</code>
49
+ and <code>trash list --all --json</code>.</li>
50
+ <li>For stale or missing path warnings, run <code>validate --all --json</code>
51
+ and then <code>reconcile --dry-run --all --json --registry &lt;registry-path&gt;</code>
52
+ to produce reviewed-safe plans.</li>
48
53
  <li>Run explicit-ledger purge dry-runs only when old trash needs review.</li>
49
54
  <li>Classify each candidate: <code>trash-safe</code>, <code>needs-human-review</code>, <code>resolve-candidate</code>, or <code>registry-problem</code>.</li>
50
- <li>Ask only with exact ledger path, reviewed plan id, or ids.</li>
55
+ <li>Execute the exact approval path only after review packet verification: exact ledger path and reviewed plan id or ids.</li>
51
56
  </ol>
52
57
  <p>
53
58
  Prefer the built-in <code>--agent</code> packet when an acting agent
@@ -104,7 +109,8 @@ Dry-run only. No execute, resolve, or delete ran.</code></pre>
104
109
  </p>
105
110
  <pre><code>approve artshelf cleanup ledger &lt;ledger-path&gt; plan &lt;plan-id&gt;
106
111
  approve artshelf trash purge ledger &lt;ledger-path&gt; plan &lt;purge-plan-id&gt;
107
- approve artshelf resolve missing ledger &lt;ledger-path&gt; ids &lt;id...&gt;</code></pre>
112
+ approve artshelf resolve missing ledger &lt;ledger-path&gt; ids &lt;id...&gt;
113
+ approve artshelf reconcile ledger &lt;ledger-path&gt; plan &lt;plan-id&gt;</code></pre>
108
114
  <div class="callout" data-kind="boundary">
109
115
  <span class="callout-label">Hard boundary</span>
110
116
  <p>
@@ -27,27 +27,41 @@
27
27
  "skipped": 2,
28
28
  "refused": 0,
29
29
  "manualReview": 1,
30
- "missingPath": 1,
30
+ "missingPath": 2,
31
31
  "trashed": 0
32
32
  },
33
33
  "decisionSummary": {
34
- "readyForApproval": 2,
35
- "needsReviewFirst": 1,
36
- "blocked": 0
34
+ "readyForApproval": 3,
35
+ "needsReviewFirst": 2,
36
+ "blocked": 1
37
37
  },
38
38
  "decisionGroups": {
39
39
  "readyForApproval": [
40
40
  {
41
41
  "label": "Clean up temp debug output",
42
- "itemIds": ["shf_20260606_120000_ab12"],
42
+ "itemIds": [
43
+ "shf_20260606_120000_ab12"
44
+ ],
43
45
  "actionType": "cleanup",
44
46
  "approvalTarget": "approve artshelf cleanup ledger /path/to/example-project/.artshelf/ledger.jsonl plan plan_20260606_120000_ab12",
45
47
  "reason": "Disposable temp artifact has a reviewed cleanup plan.",
46
48
  "nextStep": "Approve the exact cleanup plan to move the artifact into Artshelf trash."
47
49
  },
50
+ {
51
+ "label": "Review 1 reconcile remap finding(s) in example-project",
52
+ "itemIds": [
53
+ "shf_20260606_121500_gh78"
54
+ ],
55
+ "actionType": "reconcile",
56
+ "approvalTarget": "approve artshelf reconcile ledger /path/to/example-project/.artshelf/ledger.jsonl plan plan_20260606_122000_repl",
57
+ "reason": "Parent folder drift looks safe to reconcile after inspection and review.",
58
+ "nextStep": "run `artshelf reconcile --execute --plan-id plan_20260606_122000_repl --ledger /path/to/example-project/.artshelf/ledger.jsonl` after approval."
59
+ },
48
60
  {
49
61
  "label": "Resolve missing report record",
50
- "itemIds": ["shf_20260606_120500_cd34"],
62
+ "itemIds": [
63
+ "shf_20260606_120500_cd34"
64
+ ],
51
65
  "actionType": "resolve-missing",
52
66
  "approvalTarget": "approve artshelf resolve missing ledger /path/to/example-project/.artshelf/ledger.jsonl ids shf_20260606_120500_cd34",
53
67
  "reason": "The report path is already missing.",
@@ -56,17 +70,40 @@
56
70
  ],
57
71
  "needsReviewFirst": [
58
72
  {
59
- "label": "Inspect lifecycle smoke report",
60
- "itemIds": ["shf_20260606_121000_ef56"],
61
- "actionType": "inspect",
73
+ "label": "Review 1 reconcile resolve-stale-trash finding(s) in example-project",
74
+ "itemIds": [
75
+ "shf_20260606_121800_jk90"
76
+ ],
77
+ "actionType": "reconcile",
78
+ "approvalTarget": null,
79
+ "reason": "Trash references point to trashed artifacts whose originals moved or got deleted.",
80
+ "nextStep": "run `artshelf reconcile --dry-run --ledger /path/to/example-project/.artshelf/ledger.jsonl --json`, then approve with `approve artshelf reconcile ledger /path/to/example-project/.artshelf/ledger.jsonl plan <plan-id>`"
81
+ },
82
+ {
83
+ "label": "Review 1 reconcile registry-remap finding(s) in example-project",
84
+ "itemIds": [
85
+ "shf_20260606_121900_lm01"
86
+ ],
87
+ "actionType": "reconcile",
62
88
  "approvalTarget": null,
63
- "reason": "cleanup=review means the artifact should be inspected before closing.",
64
- "nextStep": "Inspect the path, then choose keep, change retention, resolve, or clean up later."
89
+ "reason": "Ledger name or registry path drift needs explicit review before remapping records.",
90
+ "nextStep": "run `artshelf reconcile --dry-run --ledger /path/to/example-project/.artshelf/ledger.jsonl --json`, then approve with `approve artshelf reconcile ledger /path/to/example-project/.artshelf/ledger.jsonl plan <plan-id>`"
65
91
  }
66
92
  ],
67
- "blocked": []
93
+ "blocked": [
94
+ {
95
+ "label": "Review 1 reconcile blocked finding(s) in example-project",
96
+ "itemIds": [
97
+ "shf_20260606_122200_qr11"
98
+ ],
99
+ "actionType": "reconcile",
100
+ "approvalTarget": null,
101
+ "reason": "Path drift is ambiguous or unsafe and needs manual investigation.",
102
+ "nextStep": "Run a manual artifact-by-artifact review and decide whether to keep, resolve, or move the ledger entry."
103
+ }
104
+ ]
68
105
  },
69
- "recommendation": "Approve the reviewed cleanup plan for the disposable temp directory, then resolve the missing record after confirming it is no longer needed.",
106
+ "recommendation": "Run reconcile dry-run for stale/missing path entries, then follow the exact approvals returned in the review packet.",
70
107
  "items": [
71
108
  {
72
109
  "id": "shf_20260606_120000_ab12",
@@ -87,13 +124,31 @@
87
124
  "note": "Resolution updates only the ledger and does not move or delete files."
88
125
  },
89
126
  {
90
- "id": "shf_20260606_121000_ef56",
91
- "path": "/tmp/lifecycle-smoke-report.json",
92
- "classification": "needs-human-review",
93
- "proposedAction": "inspect before choosing cleanup or retention",
127
+ "id": "shf_20260606_121800_jk90",
128
+ "path": "/tmp/stale-trash-record.json",
129
+ "classification": "resolve-candidate",
130
+ "proposedAction": "reconcile stale trashed entry after review",
131
+ "dueStatus": "trashed",
132
+ "reason": "trash path is stale after source artifact delete",
133
+ "note": "Run reconcile to confirm whether to rewrite the artifact path or resolve manually."
134
+ },
135
+ {
136
+ "id": "shf_20260606_121900_lm01",
137
+ "path": "/path/to/example-project/src/legacy/file.txt",
138
+ "classification": "registry-problem",
139
+ "proposedAction": "inspect registry-backed path drift",
140
+ "dueStatus": "missing-path",
141
+ "reason": "Artifact moved by repository migration from .shelf to .artshelf",
142
+ "note": "Review registry identity and run reconcile dry-run before executing any path updates."
143
+ },
144
+ {
145
+ "id": "shf_20260606_122200_qr11",
146
+ "path": "/tmp/blocked-dupe-path",
147
+ "classification": "registry-problem",
148
+ "proposedAction": "manual investigation required",
94
149
  "dueStatus": "manual-review",
95
- "reason": "cleanup=review artifact retained for manual inspection",
96
- "note": "No approval target is shown until the review decision is known."
150
+ "reason": "reconstructed paths conflicted with another active artifact",
151
+ "note": "Blocked finding intentionally requires human disambiguation."
97
152
  }
98
153
  ],
99
154
  "alternatives": [
@@ -130,10 +130,11 @@ artshelf doctor [--agent] [--json]</code></pre>
130
130
  <p>
131
131
  <code>review</code> runs validate, due, and a cleanup plan preview in one pass; no-op
132
132
  previews report <code>not-created</code>, and <code>--all</code> adds an aggregate triage
133
- summary plus the next safe action. <code>status</code> is the lightweight dashboard of
134
- counts; <code>--all --json</code> is cron-friendly. <code>doctor</code> reports CLI version,
135
- resolved paths, registry health, and the cleanup safety posture. All three also accept
136
- <code>--agent</code> for a deterministic, token-efficient decision packet (see Output mode).
133
+ summary, including reconcile entry and blocked counts, plus the next safe action.
134
+ <code>status</code> is the lightweight dashboard of counts; <code>--all --json</code> is
135
+ cron-friendly. <code>doctor</code> reports CLI version, resolved paths, registry health,
136
+ and the cleanup safety posture. All three also accept <code>--agent</code> for a
137
+ deterministic, token-efficient decision packet (see Output mode).
137
138
  </p>
138
139
  </section>
139
140
 
@@ -169,7 +170,9 @@ artshelf cleanup --execute --plan-id &lt;id&gt; [--ledger &lt;path&gt;] [--json]
169
170
  <code>--dry-run</code> creates and registers a cleanup plan without moving files;
170
171
  no-op dry-runs report <code>not-created</code>, and matching dry-runs reuse the
171
172
  existing plan id. <code>--execute</code> is approval-only for one reviewed plan id:
172
- it writes a receipt, registers the receipt artifact, and updates touched records in the ledger.
173
+ it writes a started receipt before the first move, completes and registers the receipt
174
+ artifact, updates touched records in the ledger, and can resume or idempotently replay
175
+ the same plan id from durable receipt/trash evidence.
173
176
  </p>
174
177
  <div class="callout" data-kind="boundary">
175
178
  <span class="callout-label">Hard boundary</span>