in2-agent-server 0.20260821.185429

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 (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. package/viewer/server.mjs +433 -0
@@ -0,0 +1,1360 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { runProjection, ScopeTooLargeError } from "../projection/project.js";
4
+ import { diffMetadata } from "../projection/diff.js";
5
+ import { walkSourceTree } from "./sourceTree.js";
6
+ import { runDirectApply } from "./applyLoop.js";
7
+ import { SETUP_AUDIT_TRAIL_SOQL, TOOLING_CUSTOM_FIELD_SOQL, classifyRollback, isUnverifiedLapse, recheckUndeleteWindow, tableEnumOrId } from "./rollback.js";
8
+ // `direct_rollback`'s ENGINE (plan 2026-08-02-002 U10; origin R17, R19–R21; AE4, AE5).
9
+ //
10
+ // U9 classifies and touches nothing. This module is the other half: it supplies the live-org reads
11
+ // the classifier declares as a seam, decides what the tool may DO with each class, writes the cards
12
+ // an operator reads, and — for the components that survive all of that — executes through
13
+ // `runDirectApply` so a rollback is an ordinary wave with its own bundle, verdicts, diff and
14
+ // revert-marked commit (R21).
15
+ //
16
+ // ---------------------------------------------------------------------------------------------
17
+ // PARTIAL BY DEFAULT IS THE WHOLE POSTURE (R17, R20)
18
+ //
19
+ // A rollback selection is a list of independent decisions, not a transaction. An irreversible
20
+ // component is refused and the rest of the selection still executes; a lossy one waits for its own
21
+ // named ack while its clean neighbours go; an undelete-window one leaves through Setup and cannot
22
+ // block anything. The one place this is NOT true is Salesforce's own destructive package, which
23
+ // rolls back whole — so a rollback whose deletes are refused by the org takes its own deletes down
24
+ // with them, and nothing here can change that. What this module guarantees is that a refusal HERE
25
+ // never becomes a refusal THERE.
26
+ //
27
+ // ---------------------------------------------------------------------------------------------
28
+ // THE ADDRESS IS (sessionId, wave), NEVER A SHA (R17)
29
+ //
30
+ // The mirror is a rebuildable cache and every rebuild rewrites its SHAs by design (the materializer
31
+ // version is a commit trailer). `direct_history` says so on every line it prints, and this tool is
32
+ // the reason it has to: a rollback addressed by a SHA an operator wrote down last week is a
33
+ // rollback aimed at whatever that SHA means today, which is nothing. The bundle directory
34
+ // `<session>/direct/<wave>` is the stable identity, so that is the address.
35
+ //
36
+ // ---------------------------------------------------------------------------------------------
37
+ // A CHECK THAT WAS ATTEMPTED IS NOT A CHECK THAT ANSWERED
38
+ //
39
+ // The doctrine everything above rests on is that a check which did not run is not a check that
40
+ // found nothing — it is why NOT_COMPARABLE is an ack, why presence withholds instead of defaulting
41
+ // to absent, and why the not-read branch of the plan header spells out that no divergence is
42
+ // reported because none was looked for. The SUMMARY LINES are where that discipline is easiest to
43
+ // lose, because a summary is a boolean's natural home and these two facts do not fit in one:
44
+ //
45
+ // ATTEMPTED a reader was built, a probe object exists, the call went out
46
+ // ANSWERED the org came back with something to compare against
47
+ //
48
+ // An org token that expires mid-session separates them completely — the reader CONSTRUCTS, because
49
+ // a refresh token is only spent on the first request, and then every query behind it fails.
50
+ // `LiveStateReport` here and `ProbeOutcome` in the classifier each carry BOTH halves in one status,
51
+ // plus what the gap leaves UNVERIFIED, and `liveStateLines` / `probeLines` render the partial cases
52
+ // as partial. No summary line in this module may resolve off the attempted half alone — and neither
53
+ // type carries a separate `attempted` boolean, because a boolean holding half of what the status
54
+ // holds all of is the exact shape that caused this.
55
+ // ---------------------------------------------------------------------------------------------
56
+ // the live-org probe — the implementation of U9's seam
57
+ // ---------------------------------------------------------------------------------------------
58
+ /** SOQL literals here are org-derived names; anything outside this shape is refused, never quoted. */
59
+ const SOQL_SAFE = /^[A-Za-z0-9_]{1,80}$/;
60
+ /**
61
+ * The object as the Tooling API addresses it — and this is NOT the API name for a custom object.
62
+ *
63
+ * MEASURED, T-Pinto 00Dfn00000BFjbBEAT, 2026-08-03 (read-only):
64
+ *
65
+ * TableEnumOrId = 'PV_Asset__c' -> 0 rows
66
+ * TableEnumOrId = '01Ifn000003E4hx' -> 8 rows (the object's EntityDefinition DurableId)
67
+ * TableEnumOrId = 'Account' -> 361 rows (a standard object IS its own DurableId)
68
+ *
69
+ * 2026-08-02-005 probed a standard object only, so its `TableEnumOrId='Account'` recipe reads as
70
+ * general and is not. Getting this wrong is SILENT in the dangerous direction: zero Tooling rows
71
+ * means an empty deleted set, which the classifier reads as "Salesforce has purged it" and cards as
72
+ * permanent data loss over a field still sitting in Setup's Deleted Fields list. Hence one
73
+ * `EntityDefinition` read per object before anything else, cached for the run.
74
+ *
75
+ * The labels come along because the audit trail needs them (`scopeAuditRows`), and both are one
76
+ * query.
77
+ */
78
+ async function resolveObject(reader, objectName) {
79
+ if (!SOQL_SAFE.test(objectName)) {
80
+ throw new Error(`refusing to query for object name ${JSON.stringify(objectName)} — not a plain API name.`);
81
+ }
82
+ const result = await reader.toolingQuery(`SELECT DurableId, QualifiedApiName, Label, PluralLabel FROM EntityDefinition WHERE QualifiedApiName = '${objectName}'`);
83
+ const row = result.records[0];
84
+ // NO FALLBACK TO THE API NAME. Falling back used to look harmless — a standard object IS its own
85
+ // DurableId — but it is the same zero-rows trap from the other side: for a CUSTOM object the API
86
+ // name returns 0 Tooling rows, which is an empty deleted set, which classifies as LOSSY_RECREATE
87
+ // and tells an operator the data is permanently gone about a field still sitting in Setup. An
88
+ // unresolvable object is a probe that COULD NOT RUN, and `probeObject` turns that into presence
89
+ // UNVERIFIED — the safe direction, and the one this module states everywhere else.
90
+ if (typeof row?.DurableId !== "string" || row.DurableId.length === 0) {
91
+ throw new Error(`EntityDefinition has no DurableId for ${objectName}, so CustomField.TableEnumOrId cannot be addressed for it. ` +
92
+ `Querying by API name instead would return zero rows for a custom object, which reads downstream as "Salesforce ` +
93
+ `purged this field and its data" — so presence is left UNVERIFIED rather than guessed at.`);
94
+ }
95
+ const durableId = tableEnumOrId(row.DurableId);
96
+ return {
97
+ durableId,
98
+ // A standard object's DurableId IS its API name; a custom one's is an `01I…` key. Derived from
99
+ // the org's own answer rather than sniffed off a `__c` suffix, which would also have to know
100
+ // about `__mdt`, `__e`, `__b` and `__x`.
101
+ custom: durableId !== objectName,
102
+ labels: [row?.Label, row?.PluralLabel]
103
+ .filter((label) => typeof label === "string" && label.length > 0)
104
+ .map((label) => label.toLowerCase())
105
+ };
106
+ }
107
+ /**
108
+ * The audit rows that plausibly concern THIS object, from a query that cannot be scoped to it.
109
+ *
110
+ * `Display` is unfilterable server-side (probe §4 trap 1) and the object appears in two different
111
+ * places depending on the object's kind, both measured:
112
+ *
113
+ * standard (`deletedCF`) Section = "Customize Accounts" — the PLURAL label
114
+ * custom (`deletedCFCustom`) Section = "Custom Objects" — no object at all; the object is
115
+ * the Display tail, "… from PV Assets" — again the PLURAL label
116
+ *
117
+ * (The second row is new here: 2026-08-02-005 §4 measured the standard shape and inferred the
118
+ * custom one from its rendering. Confirmed against T-Pinto's two `PV_Asset__c` deletions on
119
+ * 2026-08-03.)
120
+ *
121
+ * NARROWING ONLY, NEVER EMPTYING. If the filter matches nothing while the unscoped set is
122
+ * non-empty, the unscoped set is returned: this is corroboration for a timestamp the bundle usually
123
+ * already has, and every fact it contributes is reported INFERRED, so an over-broad set costs
124
+ * precision (`auditRowsMatched > 1`, which the classifier surfaces) while an empty one silently
125
+ * removes the only cross-check there is.
126
+ */
127
+ export function scopeAuditRows(rows, identity, sections) {
128
+ if (identity.labels.length === 0)
129
+ return rows;
130
+ const matches = rows.filter((row, index) => {
131
+ if (identity.custom) {
132
+ const display = row.display.toLowerCase();
133
+ const sep = display.lastIndexOf(" from ");
134
+ return sep >= 0 && identity.labels.includes(display.slice(sep + " from ".length).trim());
135
+ }
136
+ const section = (sections?.[index] ?? "").toLowerCase();
137
+ return identity.labels.some((label) => section === `customize ${label}`);
138
+ });
139
+ return matches.length > 0 ? matches : rows;
140
+ }
141
+ /**
142
+ * The three read-only surfaces U9's `RollbackOrgProbe` declares, over the live org.
143
+ *
144
+ * READ-ONLY BY CONSTRUCTION: two Tooling queries and one SOQL query, no metadata call, no deploy,
145
+ * nothing that could undelete. The classifier cannot reach an org at all; this is the only place
146
+ * that can, which is what keeps U9 testable against transcribed fixtures.
147
+ */
148
+ export function liveRollbackProbe(reader) {
149
+ const identities = new Map();
150
+ const identity = (objectName) => {
151
+ let pending = identities.get(objectName);
152
+ if (!pending) {
153
+ pending = resolveObject(reader, objectName);
154
+ identities.set(objectName, pending);
155
+ }
156
+ return pending;
157
+ };
158
+ return {
159
+ async toolingCustomFields(objectName) {
160
+ const object = await identity(objectName);
161
+ const result = await reader.toolingQuery(TOOLING_CUSTOM_FIELD_SOQL(object.durableId));
162
+ return result.records
163
+ .filter((row) => typeof row.DeveloperName === "string")
164
+ .map((row) => ({ developerName: row.DeveloperName, namespacePrefix: row.NamespacePrefix ?? null }));
165
+ },
166
+ /**
167
+ * The LIVE custom-field set, from `FieldDefinition` rather than `listMetadata`.
168
+ *
169
+ * 2026-08-02-005 §3 established `Tooling ∖ listMetadata` as the detector and warned off
170
+ * `describe` as the live baseline (FLS hides 133 live fields on this org, which would read as
171
+ * deletions). `FieldDefinition` is the third candidate and the probe dismissed it in one line
172
+ * for showing "nothing" for deleted fields — which is exactly the property the live side needs.
173
+ * Measured as a set, T-Pinto, 2026-08-03:
174
+ *
175
+ * Account: Tooling 361, FieldDefinition 327, difference 34, all `_del`, reverse empty
176
+ * PV_Asset__c: Tooling 8, FieldDefinition 6, difference 2, both `_del`, reverse empty
177
+ *
178
+ * The Account numbers are the addendum's `listMetadata` numbers exactly (361/327/34/0), so this
179
+ * reproduces the sanctioned detector rather than substituting a weaker one — and it is scoped
180
+ * to ONE OBJECT over the Tooling connection the reader already holds, where `listMetadata`
181
+ * returns the whole org (~10.8 MB measured) and needs a Metadata API call this reader does not
182
+ * expose. `EntityDefinition.QualifiedApiName` filters both object kinds with no durable-id
183
+ * indirection, and `QualifiedApiName` carries the namespace the way `listMetadata` does
184
+ * (`SBQQ__Foo__c`), which is the refinement the whole detector turns on.
185
+ */
186
+ async listCustomFields(objectName) {
187
+ if (!SOQL_SAFE.test(objectName))
188
+ return [];
189
+ const result = await reader.toolingQuery(`SELECT QualifiedApiName FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '${objectName}'`);
190
+ return result.records
191
+ .map((row) => row.QualifiedApiName)
192
+ .filter((name) => typeof name === "string" && name.endsWith("__c"))
193
+ .map((name) => `${objectName}.${name}`)
194
+ .sort();
195
+ },
196
+ async fieldDeleteAudit(objectName) {
197
+ // Optional on the reader interface, and its absence is not a failure: the audit trail is
198
+ // corroboration for a timestamp the bundle's own mutation window usually supplies, so losing
199
+ // it costs an INFERRED term and never a class.
200
+ if (!reader.soqlQuery)
201
+ return [];
202
+ const object = await identity(objectName);
203
+ const result = await reader.soqlQuery(SETUP_AUDIT_TRAIL_SOQL(!object.custom));
204
+ const rows = [];
205
+ const sections = [];
206
+ for (const row of result.records) {
207
+ if (typeof row.CreatedDate !== "string" || typeof row.Display !== "string")
208
+ continue;
209
+ rows.push({ createdDate: row.CreatedDate, display: row.Display });
210
+ sections.push(typeof row.Section === "string" ? row.Section : "");
211
+ }
212
+ return scopeAuditRows(rows, object, sections);
213
+ }
214
+ };
215
+ }
216
+ const componentKey = (c) => `${c.typeName}:${c.fullName}`;
217
+ /**
218
+ * A component read as `found: false` — but is that ABSENCE or a read that failed?
219
+ *
220
+ * The projection records both as `found: false` with an `errors` array, and the difference is the
221
+ * whole preflight: absence compared against a recorded post-image produces "this component is gone,
222
+ * something changed it outside this history", which is a confident accusation to make out of a
223
+ * network error. `runProjection`'s catch-all writes `read failed: …`; a genuine miss writes only
224
+ * the reader's `<Type>:<name> not found`. Anything that is not recognisably the latter is treated
225
+ * as UNKNOWN and the key is omitted, which the classifier reads as "no live state supplied".
226
+ */
227
+ function isVerifiedAbsence(errors) {
228
+ if (!errors || errors.length === 0)
229
+ return true;
230
+ return errors.every((error) => /\bnot found$/i.test(error.trim()));
231
+ }
232
+ /**
233
+ * Read live state for the components a rollback would touch (origin R16).
234
+ *
235
+ * Through `runProjection`, deliberately, rather than by calling the snapshot readers directly: the
236
+ * recorded side of every comparison was written by that same function, and a preflight whose two
237
+ * sides come from two readers is the NOT_COMPARABLE finding this plan already had to invent once
238
+ * (PV Arm B). Same producer, same shape, and the projection lands in the rollback's own bundle as
239
+ * evidence of what was checked.
240
+ */
241
+ export async function readLiveState(args) {
242
+ const scope = args.components.length;
243
+ if (scope === 0)
244
+ return { live: new Map(), unknown: [], unreadable: [], scope };
245
+ try {
246
+ const result = await runProjection(args.reader, {
247
+ scope: { components: args.components },
248
+ outDir: args.outDir,
249
+ orgAlias: args.orgAlias,
250
+ apiVersion: args.apiVersion,
251
+ label: "rollback-preflight"
252
+ });
253
+ const live = new Map();
254
+ const unknown = [];
255
+ const wanted = new Set(args.components.map(componentKey));
256
+ for (const entry of result.manifest.components) {
257
+ const key = componentKey(entry);
258
+ // Precedent samples ride along on every projection and are NOT this caller's scope. They
259
+ // would only ever add keys nobody asked about, but the mirror already learned once what
260
+ // happens when a sampled component is read as work somebody did.
261
+ if (entry.precedent || !wanted.has(key))
262
+ continue;
263
+ if (entry.found) {
264
+ live.set(key, result.byFile.get(entry.file) ?? null);
265
+ continue;
266
+ }
267
+ if (isVerifiedAbsence(entry.errors))
268
+ live.set(key, null);
269
+ else
270
+ unknown.push(`${key} (${entry.errors?.join("; ") ?? "read failed"})`);
271
+ }
272
+ const unreadable = args.components.map(componentKey).filter((key) => !live.has(key));
273
+ return { live, unknown, unreadable, scope, dir: args.outDir };
274
+ }
275
+ catch (error) {
276
+ // FAIL-OPEN, LOUDLY. A preflight that cannot run must not stop a rollback the operator has
277
+ // decided on — but it must never be mistaken for a preflight that ran and found nothing, which
278
+ // is why the caller prints this error on the card.
279
+ const message = error instanceof ScopeTooLargeError
280
+ ? `${error.message} — narrow the selection with \`components\``
281
+ : error instanceof Error
282
+ ? error.message
283
+ : String(error);
284
+ return { live: new Map(), unknown: [], unreadable: [], scope, error: message };
285
+ }
286
+ }
287
+ /**
288
+ * A NOT_COMPARABLE divergence is an ACK here, though U9 leaves the gate at PROCEEDS.
289
+ *
290
+ * The classifier is right not to move it: not-comparable is the statement that the recorded and
291
+ * observed snapshots were produced by different readers, so their difference is not evidence about
292
+ * the org IN EITHER DIRECTION. It is not drift, and calling it drift is the false accusation PV Arm
293
+ * B produced (an entire Layout "changed" on reader shape alone, org state byte-identical).
294
+ *
295
+ * But the tool's question is different from the classifier's. What PROCEEDS would mean here is
296
+ * "overwrite live state without having been able to look at it", and the R16 preflight exists
297
+ * precisely so an overwrite is never issued blind. The conservative reading costs one ack; the
298
+ * permissive one silently spends the operator's only chance to notice that the check did not run.
299
+ * So: ack, worded as a gap rather than as a change.
300
+ */
301
+ const notComparableAck = (component) => `${component.key}: the R16 preflight could not compare live state to what this wave recorded — ${component.divergence?.detail ?? "the two snapshots are not comparable"} ` +
302
+ `Rolling back overwrites whatever is live now, and nothing here can tell you whether that is what the wave left. This is a CHECK THAT DID NOT RUN, not a change that was found.`;
303
+ /**
304
+ * Same doctrine as `notComparableAck`, for its sibling gap: the preflight ASKED about this
305
+ * component and got no answer, so no drift comparison ran. PROCEEDS here would mean overwriting
306
+ * live state nobody could look at. Worded as a gap, never as a change that was found.
307
+ */
308
+ const unreadableAck = (component) => `${component.key}: the R16 preflight tried to read this component's live state and could not — the read failed or returned nothing. ` +
309
+ `Rolling back overwrites whatever is live now, and this plan cannot tell you whether that matches what the wave left. ` +
310
+ `This is a CHECK THAT DID NOT RUN, not a change that was found.`;
311
+ /** Which components execute, which wait for a named ack, and which only ever get reported. */
312
+ export function gateRollback(plan, ackComponents, unreadable = new Set()) {
313
+ const acked = new Set(ackComponents);
314
+ return plan.components.map((component) => {
315
+ const notComparable = component.divergence?.kind === "NOT_COMPARABLE";
316
+ const unanswered = unreadable.has(component.key);
317
+ const ackReasons = [
318
+ ...component.ackReasons,
319
+ ...(notComparable ? [notComparableAck(component)] : []),
320
+ ...(unanswered ? [unreadableAck(component)] : [])
321
+ ];
322
+ // AE4, ASKED OF THE CLASS AND ASKED FIRST — layer 1 of three.
323
+ //
324
+ // Everything below this line dispatches on the GATE, which is the right question for every other
325
+ // class: the gate is where "this needs an ack" lives, and an ack is a real answer to drift, to an
326
+ // incomplete image, to a sharing-model recalculation. It is NOT a real answer to an in-window
327
+ // deleted field, because the thing an ack would release is the deploy, and the deploy is the
328
+ // harm: a re-create restores the definition, reports success, and throws away every value every
329
+ // record held while Setup still has the field AND ITS DATA.
330
+ //
331
+ // So the class decides here, and no gate can overrule it. This is not belt-and-braces around a
332
+ // hypothetical: `UNDELETE_WINDOW` + `ACK_REQUIRED` was REACHABLE, and by an ordinary route — the
333
+ // delete frees the original API name, something takes it, the R16 preflight finds the
334
+ // disagreement, and U9's divergence handling rewrote OPERATOR_ACTION into an ackable gate. That
335
+ // hole is closed in `rollback.ts` too, but AE4 is U10's invariant and U10 does not own that file.
336
+ //
337
+ // AND THE SAME LAYER COVERS THE LAPSE NOTHING VERIFIED, asked of `rollback.ts`'s own predicate
338
+ // rather than re-derived — a guard that re-derives is a guard that can come to disagree with the
339
+ // rule it guards. Today every producer of that state already sets `OPERATOR_ACTION`, so this
340
+ // changes no disposition; what it removes is the dependence on that. `rollbackWork` throws over
341
+ // one of these, which stops the harm but stops it as a CRASH at the end of a run, after the plan
342
+ // has already printed a release token for a component no acknowledgement may release. The gate is
343
+ // where that belongs, and it is exactly the rewrite `UNDELETE_WINDOW` above already survived.
344
+ if (component.class === "UNDELETE_WINDOW" || isUnverifiedLapse(component)) {
345
+ return { component, disposition: "OPERATOR_ACTION", ackReasons };
346
+ }
347
+ switch (component.gate) {
348
+ case "OPERATOR_ACTION":
349
+ return { component, disposition: "OPERATOR_ACTION", ackReasons };
350
+ case "REFUSED":
351
+ case "NO_ACTION":
352
+ case "BLOCKED_NO_IMAGE":
353
+ return { component, disposition: "REPORTED", ackReasons };
354
+ case "ACK_REQUIRED":
355
+ return acked.has(component.key)
356
+ ? { component, disposition: "EXECUTE", ackKey: component.key, ackReasons }
357
+ : { component, disposition: "HELD_FOR_ACK", ackKey: component.key, ackReasons };
358
+ case "PROCEEDS":
359
+ if (!notComparable && !unanswered)
360
+ return { component, disposition: "EXECUTE", ackReasons };
361
+ return acked.has(component.key)
362
+ ? { component, disposition: "EXECUTE", ackKey: component.key, ackReasons }
363
+ : { component, disposition: "HELD_FOR_ACK", ackKey: component.key, ackReasons };
364
+ }
365
+ });
366
+ }
367
+ /**
368
+ * THE POST-UNDELETE CHECKLIST (origin R19), written here because it existed nowhere.
369
+ *
370
+ * R19 names five things — strip the `_del` suffix, re-check required/unique, field dependencies,
371
+ * master-detail conversion, layout membership — and the plan carries no text for any of them. What
372
+ * follows is that list turned into instructions, and the load-bearing editorial decision is the
373
+ * `basis` field: ONE of the five is measured and four are not, and a checklist that presented all
374
+ * five in the same confident voice would be inventing four Salesforce behaviours to sound complete.
375
+ *
376
+ * The frame that makes the unmeasured four honest AND useful: while a field sits deleted it is
377
+ * unreachable — `FieldDefinition`, `EntityParticle`, `describe` and `readMetadata` are all blind to
378
+ * it and its data is not SOQL-queryable (2026-08-02-005 §2, §5). So nothing could have checked any
379
+ * of these in the window, by anyone, and "verify it" is not hedging — it is the only available
380
+ * answer. The verification wave (`mode: "verify"`) then does the mechanical half against the wave's
381
+ * own pre-image, which is the comparison AE4 asks for.
382
+ */
383
+ export const POST_UNDELETE_CHECKLIST = [
384
+ {
385
+ title: "STRIP THE `_del` SUFFIX FROM THE API NAME",
386
+ basis: "MEASURED",
387
+ detail: "Salesforce renamed the field when it was deleted — `Region__c` became `Region_del__c` — and it comes back under that name, " +
388
+ "so every formula, flow, validation rule, layout, report and integration that names the original is still broken after a successful undelete. " +
389
+ "Two traps, both measured: the LABEL was never renamed and does not track the API name, so the field looks right in Setup while the API name is wrong; " +
390
+ "and the original API name was FREED by the delete, so if anything has taken it since, the rename is refused until that is resolved. " +
391
+ "A second deleted generation takes a numeric suffix (`_del1`), and both generations can sit in the Deleted Fields list at once holding different data."
392
+ },
393
+ {
394
+ title: "RE-CHECK `required` AND `unique`",
395
+ basis: "VERIFY",
396
+ detail: "Compare the restored field against the pre-image this rollback holds. Nothing in the deleted window could have told you whether these survive — " +
397
+ "a deleted field is invisible to describe and FieldDefinition and its data is not queryable — so this is a look, not a formality. " +
398
+ "`required` in particular changes data entry for every user of the object, in both directions."
399
+ },
400
+ {
401
+ title: "RE-CHECK FIELD DEPENDENCIES",
402
+ basis: "VERIFY",
403
+ detail: "A controlling/dependent picklist pair is stored on the DEPENDENT field. If the undeleted field was either half of one, confirm the dependency is back " +
404
+ "and that the value mapping is the one the pre-image records — a dependency that came back empty enforces nothing and reports no error."
405
+ },
406
+ {
407
+ title: "RE-CHECK A MASTER-DETAIL RELATIONSHIP",
408
+ basis: "VERIFY",
409
+ detail: "A master-detail and a lookup are not interchangeable: roll-up summaries, record ownership, sharing and cascade delete all ride on master-detail. " +
410
+ "If the field was a relationship, confirm it came back as the SAME kind, and that any roll-up summary fields on the parent are recalculating."
411
+ },
412
+ {
413
+ title: "RE-ADD IT TO PAGE LAYOUTS AND COMPACT LAYOUTS",
414
+ basis: "VERIFY",
415
+ detail: "Deleting a field removes it from every layout that carried it, and layout membership is a property of the LAYOUT, not of the field — " +
416
+ "so restoring the field cannot restore it. Until this is done the data is back and nobody can see it. " +
417
+ "The wave's pre-image for the affected layouts is on this machine and is what to compare against."
418
+ }
419
+ ];
420
+ /**
421
+ * The card for one deleted field the operator has to handle in Setup: path, window, and checklist.
422
+ *
423
+ * NEVER A DEPLOY, and the card says so first. The failure this exists to prevent is a rollback that
424
+ * "succeeds" by re-creating the field from its pre-image: the definition returns, the deploy reports
425
+ * green, and every value every record held is gone — a second incident caused by the recovery.
426
+ *
427
+ * TWO CARDS IN ONE, because two states reach `OPERATOR_ACTION` and they ask for different things.
428
+ * The in-window field is KNOWN to be sitting in Setup's Deleted Fields list, and the instruction is
429
+ * to undelete it. A field whose window is out on a clock NOTHING CHECKED (`isUnverifiedLapse`) is a
430
+ * question, and the instruction is to go and settle it — because either answer is possible and the
431
+ * two have opposite consequences. Printing the plain undelete card over that state would promise
432
+ * recoverable data nobody verified, which is this module's own defect wearing the other hat; and
433
+ * this card is the ONLY per-component detail an `OPERATOR_ACTION` entry gets, since `renderPlan`
434
+ * prints the disposition line for every other kind.
435
+ *
436
+ * AND IT CARRIES THE DIVERGENCE, which is not decoration. `renderPlan` prints per-component detail
437
+ * for every disposition EXCEPT `OPERATOR_ACTION`, whose whole detail is this card — so a divergence
438
+ * that reached only the disposition list would be dropped silently for exactly the components where
439
+ * it matters most. For an in-window delete the finding has one meaning and it is checklist item 1's
440
+ * measured trap: the wave's recorded post-image is ABSENCE, so a divergence at all means something
441
+ * now holds the API name, and the rename back off `_del` is refused until that is resolved. That is
442
+ * the fact that decides whether the operator's undelete actually finishes.
443
+ */
444
+ export function renderUndeleteCard(component, target) {
445
+ const objectName = component.fullName.split(".")[0] ?? "";
446
+ const window = component.window;
447
+ const candidates = window?.candidates ?? [];
448
+ const divergence = component.divergence;
449
+ const setupPath = `Object Manager → ${objectName} → Fields & Relationships → Deleted Fields → Undelete`;
450
+ // The clock said the window ran out and nothing checked the org against it. Asked of the SAME
451
+ // predicate the classifier gated on, so the card cannot describe a state the gate did not produce.
452
+ const unverifiedLapse = isUnverifiedLapse(component);
453
+ const anchor = window?.basis === "BUNDLE_WINDOW" ? "measured from this wave's own mutation window" : "INFERRED from SetupAuditTrail, joined by field label";
454
+ // `daysRemaining` IS A FLOOR, AND A FLOOR IS NOT AN ELAPSED DAY. `-1` covers everything from one
455
+ // minute past the derived date to a full day past it, so `-(daysRemaining)` printed "1 day(s) ago"
456
+ // over a lapse an hour and a half old — overstating, in the one card whose entire subject is that
457
+ // nothing verified the lapse at all. What the floor DOES establish is a lower bound, so that is
458
+ // what this says: `-1` means less than a day, and `-n` means more than `n - 1` whole days.
459
+ const lapsedAgo = window?.daysRemaining !== null && window?.daysRemaining !== undefined && window.daysRemaining < -1
460
+ ? `more than ${-window.daysRemaining - 1} day(s) ago`
461
+ : "less than a day ago";
462
+ const windowLine = window === undefined || window.basis === "UNVERIFIED"
463
+ ? "Window: NOT ESTABLISHED from this bundle — Setup's own Deleted Fields list is what shows whether it is still there and for how long."
464
+ : unverifiedLapse
465
+ ? `Window: the derived window CLOSED ${window.lapsesAt} (${anchor}) — ${lapsedAgo} BY THE CLOCK, which is the only thing that said so.`
466
+ : `Window: ${window.daysRemaining} day(s) left, closes ${window.lapsesAt} (${anchor}).`;
467
+ const lines = [
468
+ // TITLED FOR WHAT IT ASKS FOR, not for the outcome it cannot promise. The legend and the
469
+ // checklist both stopped stating the undelete as a given ("settled in Setup", "IF YOU FIND IT AND
470
+ // UNDELETE") on the grounds that either answer is possible here; a headline that still announced
471
+ // the conclusion is the same overclaim in the largest type on the card.
472
+ unverifiedLapse ? `SETTLE IN SETUP — ${component.key}` : `UNDELETE — ${component.key}`,
473
+ "",
474
+ " DO NOT RE-CREATE THIS FIELD AS METADATA. The undelete restores the field AND ITS DATA; a",
475
+ " re-create from the pre-image restores the definition only, reports success, and throws every",
476
+ " value every record held away. That is why this component is not part of the deploy below.",
477
+ ...(unverifiedLapse
478
+ ? [
479
+ "",
480
+ " [!] THE WINDOW LOOKS CLOSED, AND NOTHING VERIFIED IT. No live probe answered whether",
481
+ " Salesforce still lists this field as deleted-and-recoverable, so the line below is a",
482
+ " derivation from recorded timestamps, not a reading of the org. This tool will not",
483
+ " re-create the field on that: no acknowledgement releases it, because if the field IS",
484
+ " still listed the re-create destroys exactly the data an undelete would have restored.",
485
+ "",
486
+ " SETTLE IT — open the Setup path below and look:",
487
+ " • If the field IS listed, UNDELETE IT. The data is intact and this window was wrong.",
488
+ " • If it is NOT listed, the field and its data are gone. Re-run this rollback with the",
489
+ " org reachable so the probe can measure that, and the re-create is then offered for",
490
+ " your acknowledgement:",
491
+ ` direct_rollback {sessionId: "${target.sessionId}", wave: "${target.wave}", mode: "plan"}`
492
+ ]
493
+ : []),
494
+ "",
495
+ ` Setup path: ${setupPath}`,
496
+ // THE DELETED NAME IS DATED WHEN NOTHING RE-READ IT — and never dropped.
497
+ //
498
+ // `window.candidates` is only ever written by a probe that ANSWERED (`matchDeletedField` over a
499
+ // live Tooling read), so on an unverified lapse it can only have arrived one way: this plan's
500
+ // earlier pass measured it, and `recheckUndeleteWindow` carried it forward because it had nothing
501
+ // fresher (see its `candidates: presenceVerified ? candidates : component.window.candidates`).
502
+ // Printed unattributed it read as a present-tense reading of the org, three lines under "nothing
503
+ // verified it" — the card contradicting itself in the operator's hands.
504
+ //
505
+ // ATTRIBUTED RATHER THAN REMOVED, because it is the one string that FINDS the field: Salesforce
506
+ // renames a field on the way out, so an operator sent to Deleted Fields without the `_del` name
507
+ // is searching for something Setup does not show under the name they were given.
508
+ ...(candidates[0]
509
+ ? unverifiedLapse
510
+ ? [
511
+ ` This plan's EARLIER reading — taken before the probe stopped answering — listed it under`,
512
+ ` its DELETED name: ${candidates[0]}. SEARCH FOR THAT in Setup; nothing has re-read it since.`
513
+ ]
514
+ : [` Salesforce lists it under its DELETED name: ${candidates[0]}`]
515
+ : []),
516
+ ` ${windowLine}`,
517
+ ...(window?.presenceVerified === false
518
+ ? [" Presence in the Deleted Fields list is UNVERIFIED — no live probe answered. Not the same as absent."]
519
+ : []),
520
+ ...(candidates.length > 1
521
+ ? [
522
+ // THE TWIN OF THE LINE ABOVE, off the same array and dated the same way. "N generations
523
+ // coexist" is a reading of Setup's Deleted Fields list, and on an unverified lapse nothing
524
+ // in this pass read it — the sentence sat four lines under "no live probe answered". The
525
+ // NAMES stay either way: the choice between two generations is the operator's to make in
526
+ // Setup, and they cannot compare deletion dates without knowing which rows to compare.
527
+ unverifiedLapse
528
+ ? ` [!] that EARLIER reading listed ${candidates.length} deleted generations of this API name: ${candidates.join(", ")}.`
529
+ : ` [!] ${candidates.length} deleted generations of this API name coexist: ${candidates.join(", ")}.`,
530
+ " Salesforce suffixes a re-delete (`_del`, `_del1`, …) and both hold DIFFERENT data. Which one",
531
+ " this wave deleted cannot be decided from the API — compare the deletion dates Setup shows",
532
+ " against this wave's own window before you restore one."
533
+ ]
534
+ : []),
535
+ ...(divergence
536
+ ? [
537
+ "",
538
+ ` [!] THE API NAME ${component.fullName} IS OCCUPIED. The R16 preflight compared live state`,
539
+ ` against what wave ${target.wave} recorded — a DELETE, so the record is ABSENCE — and found something`,
540
+ " there. What it found:",
541
+ ...wrap(divergence.detail, 90).map((sentence) => ` ${sentence}`),
542
+ ...(divergence.laterWaves?.length
543
+ ? [` This org's own later delivery accounts for it: ${divergence.laterWaves.join(", ")}.`]
544
+ : []),
545
+ " THIS IS WHAT BLOCKS CHECKLIST ITEM 1 BELOW. The delete FREED the original name (measured), so",
546
+ " renaming the restored field back off its `_del` suffix is refused while anything else holds it.",
547
+ " Decide what happens to the occupant BEFORE you undelete — and note that the occupant is a",
548
+ " DIFFERENT field: it holds none of the data the deleted generation is still holding for you.",
549
+ " Nothing in this tool will deploy over it; that is the operator's decision to make in Setup."
550
+ ]
551
+ : []),
552
+ "",
553
+ // The checklist applies the moment the field comes back, which is exactly as certain as the
554
+ // undelete is. "AFTER YOU UNDELETE" over a field nobody has found yet states the undelete as a
555
+ // given — the same overclaim the block above exists to refuse, restated in a heading.
556
+ unverifiedLapse
557
+ ? " IF YOU FIND IT AND UNDELETE — five checks, none of which the undelete performs for you:"
558
+ : " AFTER YOU UNDELETE — five checks, none of which the undelete performs for you:"
559
+ ];
560
+ POST_UNDELETE_CHECKLIST.forEach((check, index) => {
561
+ lines.push(` ${index + 1}. ${check.title} [${check.basis === "MEASURED" ? "measured" : "verify — not measured"}]`);
562
+ for (const sentence of wrap(check.detail, 96))
563
+ lines.push(` ${sentence}`);
564
+ if (index === 0 && candidates[0]) {
565
+ lines.push(` For this field: rename ${candidates[0]} back to ${component.fullName}.`);
566
+ }
567
+ if (index === 0 && divergence) {
568
+ lines.push(` BLOCKED AS THINGS STAND — ${component.fullName} is occupied; see the finding above.`);
569
+ }
570
+ });
571
+ lines.push("", ` Then run the verification wave, which compares the restored field against wave ${target.wave}'s own`, " pre-image and reports what the undelete did not restore:", ` direct_rollback {sessionId: "${target.sessionId}", wave: "${target.wave}", mode: "verify"}`);
572
+ return {
573
+ key: component.key,
574
+ objectName,
575
+ ...(candidates[0] ? { deletedName: candidates[0] } : {}),
576
+ candidates,
577
+ setupPath,
578
+ window: windowLine,
579
+ ...(divergence ? { divergence: divergence.detail } : {}),
580
+ text: lines.join("\n")
581
+ };
582
+ }
583
+ function wrap(text, width) {
584
+ const out = [];
585
+ let line = "";
586
+ for (const word of text.split(/\s+/)) {
587
+ if (line.length > 0 && line.length + word.length + 1 > width) {
588
+ out.push(line);
589
+ line = word;
590
+ }
591
+ else {
592
+ line = line.length > 0 ? `${line} ${word}` : word;
593
+ }
594
+ }
595
+ if (line.length > 0)
596
+ out.push(line);
597
+ return out;
598
+ }
599
+ export const UNDELETE_SCHEDULE_FILE = "undelete-verification.json";
600
+ /**
601
+ * Turn the executable half of a plan into a wave.
602
+ *
603
+ * The bytes come from the target bundle's `pre/source/` tree — the deployable image a retrieve
604
+ * landed before the wave ran — walked with the SAME walker `direct_apply` uses on an authored tree,
605
+ * so a rollback deploys through the identical path as any other wave. Filtered to the selection
606
+ * rather than deployed wholesale: `pre/source/` holds an image for every component the wave
607
+ * touched, and a partial rollback that quietly redeployed all of them would undo work the operator
608
+ * did not select.
609
+ */
610
+ export async function rollbackWork(bundleDir, selected) {
611
+ // AE4 AT THE CHOKEPOINT — layer 2 of three, and the last one.
612
+ //
613
+ // This function is the ONLY producer of the edits and deletes `runDirectApply` sends, and
614
+ // `runDirectApply` is called exactly once in this module. So this is the single line every
615
+ // component must cross to reach a deploy, and it is the right place to state the invariant as a
616
+ // fact about the system rather than as a property of whoever called it.
617
+ //
618
+ // THROWS RATHER THAN FILTERS, deliberately. Silently dropping the component would let a rollback
619
+ // report a clean partial success while an operator who believed they had released it went looking
620
+ // for a field that never came back — and it would hide a gating bug, which is exactly the failure
621
+ // this guard exists to catch. Nothing downstream of a correct `gateRollback` can trip this; if it
622
+ // ever fires, the gating is wrong and the loud stop is the point.
623
+ const inWindow = selected.filter((component) => component.class === "UNDELETE_WINDOW");
624
+ if (inWindow.length > 0) {
625
+ throw new Error(`AE4 VIOLATION — refusing to build a deploy containing ${inWindow.length} component(s) still inside Salesforce's ` +
626
+ `undelete window: ${inWindow.map((component) => component.key).join(", ")}. An UNDELETE_WINDOW component is ` +
627
+ `recoverable WITH ITS DATA through Setup (Object Manager → Fields & Relationships → Deleted Fields → Undelete) ` +
628
+ `and may NEVER join a deploy: re-creating it from the pre-image restores the definition only, reports success, ` +
629
+ `and destroys every value every record held while the recoverable generation is still sitting in Setup. ` +
630
+ `No acknowledgement releases this — the ack vocabulary cannot buy a re-create, because the re-create is the harm. ` +
631
+ `Reaching this line means the gating that should have carded these components as OPERATOR_ACTION did not; that is ` +
632
+ `a defect in this tool, not something to work around.`);
633
+ }
634
+ // THE SAME INVARIANT WITH THE CLOCK AS ITS ONLY WITNESS — and it asks `rollback.ts`'s own predicate
635
+ // rather than re-deriving the question, because a guard that re-derives is a guard that can come to
636
+ // disagree with the rule it guards. (That is not hypothetical here: U9's divergence handling
637
+ // rewrote a gate the AE4 check above was reading, and the fix was to ask the CLASS instead.)
638
+ //
639
+ // A `LOSSY_RECREATE` reached by a probe that answered is a component Salesforce told us is past
640
+ // recovery, and R20's per-component ack is the right release for it. One reached by a probe that
641
+ // did NOT answer is a component nobody looked at, and the class alone cannot tell the two apart.
642
+ const unverified = selected.filter(isUnverifiedLapse);
643
+ if (unverified.length > 0) {
644
+ throw new Error(`AE4 VIOLATION — refusing to build a deploy containing ${unverified.length} component(s) whose undelete window ` +
645
+ `is UNVERIFIED: ${unverified.map((component) => component.key).join(", ")}. Nothing read Salesforce's Deleted ` +
646
+ `Fields list for ${plural(unverified.length, "this component", "these components")}, so "the window has closed" ` +
647
+ `is a clock's inference and not the org's answer — and if the field is still listed, re-creating it from the ` +
648
+ `pre-image destroys the data an undelete would have restored, while the deploy reports success. These must be ` +
649
+ `carded as OPERATOR_ACTION so the operator settles it in Setup, or re-classified against a reachable org. ` +
650
+ `Reaching this line means the gating did not; that is a defect in this tool, not something to work around.`);
651
+ }
652
+ const deletes = [];
653
+ const needsImage = [];
654
+ for (const component of selected) {
655
+ // Undoing a CREATE is a delete; everything else executable is a redeploy of the pre-image.
656
+ if (component.class === "CLEAN_DELETE")
657
+ deletes.push({ typeName: component.typeName, fullName: component.fullName });
658
+ else
659
+ needsImage.push(component);
660
+ }
661
+ const edits = [];
662
+ const missingImage = [];
663
+ if (needsImage.length > 0) {
664
+ const walked = await walkSourceTree(path.join(bundleDir, "pre", "source")).catch(() => ({ components: [], skipped: [] }));
665
+ const byKey = new Map(walked.components.map((edit) => [componentKey(edit), edit]));
666
+ for (const component of needsImage) {
667
+ const edit = byKey.get(component.key);
668
+ if (edit)
669
+ edits.push(edit);
670
+ else
671
+ missingImage.push(component.key);
672
+ }
673
+ }
674
+ return { edits, deletes, missingImage };
675
+ }
676
+ /**
677
+ * `attempt.json` exists but this machine could not learn a trustworthy attempt number from it —
678
+ * truncated JSON, or an `"attempt"` field that is not a positive integer (`{"attempt":"3"}` and
679
+ * `{"attempt":0}` included: both are shapes `nextWaveAttempt` never writes, and coercing or
680
+ * defaulting either would trust exactly what cannot be trusted).
681
+ *
682
+ * REFUSES rather than falling back to `attempt = 1` (single-attempt, `preRoot = bundleDir`), because
683
+ * that fallback is exactly the failure `resolveRollbackBaseline` exists to prevent: a marker this
684
+ * corrupt could belong to a bundle retried five times, and defaulting it to "never retried" would
685
+ * silently redeploy the mid-wave `pre/` image the whole function exists to keep out of a rollback.
686
+ * A missing marker (`ENOENT`) is different and NOT this case — it is handled by the caller before
687
+ * this is ever reached, because a bundle from before attempt tracking existed is, by construction,
688
+ * single-attempt.
689
+ */
690
+ function unreadableMarkerRefusal(error) {
691
+ const detail = error instanceof Error ? error.message : String(error);
692
+ return {
693
+ ok: false,
694
+ code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
695
+ message: `this bundle's attempt.json exists but is unreadable (${detail}) — the attempt marker is unreadable, so the bundle's retry ` +
696
+ `history cannot be trusted: this machine cannot tell a single-attempt bundle from a retried one, or find attempt 1's ` +
697
+ `archived baseline if it was retried. Deploying its pre/ on the assumption that it was never retried could redeploy the ` +
698
+ `org mid-wave and report a clean rollback. Use direct_history to see what this wave actually delivered.`
699
+ };
700
+ }
701
+ /**
702
+ * Which directory the rollback's pre-image reads come from.
703
+ *
704
+ * The bundle dir is keyed by wave label; attempts share it. Since archivePriorAttempt (tools/
705
+ * direct.ts), attempts/<n> holds each prior attempt's evidence, and the EARLIEST archived attempt
706
+ * that captured a pre-image holds the baseline from before the wave first touched the org — the
707
+ * only state a rollback of the wave can honestly restore, because a later attempt's "before" is an
708
+ * earlier attempt's "after". An archived attempt whose manifest proves it wrote nothing is a no-op
709
+ * that never reached the org and is skipped, not fatal. A multi-attempt bundle with no completed
710
+ * archive predates that fix (or the copy died): its pre/ records the org DURING the wave, and
711
+ * deploying it would report a clean rollback while leaving earlier attempts' changes in place — so
712
+ * that case refuses rather than guesses. The SAME refusal covers a marker this
713
+ * machine cannot read at all: an unparseable or mis-shaped `attempt.json` on a genuinely retried
714
+ * bundle is indistinguishable from corruption, and defaulting it to "single attempt" would redeploy
715
+ * the mid-wave image exactly as confidently as no check at all (see `unreadableMarkerRefusal`).
716
+ */
717
+ export async function resolveRollbackBaseline(bundleDir) {
718
+ let raw;
719
+ try {
720
+ raw = await fs.readFile(path.join(bundleDir, "attempt.json"), "utf8");
721
+ }
722
+ catch (error) {
723
+ if (error?.code === "ENOENT") {
724
+ // No marker at all — a bundle from before attempt tracking is necessarily single-attempt.
725
+ return { ok: true, preRoot: bundleDir };
726
+ }
727
+ // Present in some form (a directory, permission-denied, …) but not readable as a file — the
728
+ // same untrusted-history case as a malformed marker, so the same refusal.
729
+ return unreadableMarkerRefusal(error);
730
+ }
731
+ let attempt;
732
+ try {
733
+ const parsed = JSON.parse(raw);
734
+ // Positive integers only — nextWaveAttempt never writes anything else, so a zero, negative or
735
+ // fractional attempt is corruption wearing a number, and must refuse like any other shape it
736
+ // cannot trust rather than sliding through a `<= 1` comparison as "single attempt".
737
+ if (typeof parsed.attempt !== "number" || !Number.isInteger(parsed.attempt) || parsed.attempt < 1) {
738
+ throw new Error(`"attempt" is ${JSON.stringify(parsed.attempt)}, not a positive integer`);
739
+ }
740
+ attempt = parsed.attempt;
741
+ }
742
+ catch (error) {
743
+ return unreadableMarkerRefusal(error);
744
+ }
745
+ if (attempt === 1)
746
+ return { ok: true, preRoot: bundleDir };
747
+ // Retried. The bundle's own pre/ belongs to the LAST attempt; the honest baseline is the
748
+ // EARLIEST attempt that captured one. Walk the archives in order:
749
+ // - a completed archive (archive.json is archivePriorAttempt's completion receipt) that holds
750
+ // a pre-image → that archive is the baseline; nothing earlier can have touched the org.
751
+ // - a completed archive that recorded NO pre-image → a proven no-op: pre/ is written before
752
+ // any deploy, so an attempt that never wrote it died before it could reach the org. Skip it
753
+ // and keep walking.
754
+ // - no archive.json → either the bundle predates per-attempt archiving or the copy never
755
+ // finished; both are the untrusted-history case, and both refuse.
756
+ const noOps = [];
757
+ for (let n = 1; n < attempt; n++) {
758
+ const archived = path.join(bundleDir, "attempts", String(n));
759
+ const manifest = await readJson(path.join(archived, "archive.json"));
760
+ if (!manifest?.archived) {
761
+ return {
762
+ ok: false,
763
+ code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
764
+ message: `this wave was applied ${attempt} times under one label, and attempt ${n} left no completed archive — either the bundle predates per-attempt archiving, or the archive never finished. The bundle's pre/ was recaptured by the last attempt, so it records the org DURING the wave, not before it. ` +
765
+ `Deploying it would report a clean rollback while leaving earlier attempts' changes in place. ` +
766
+ `Use direct_history to see what each attempt delivered; the mirror commit before attempt 1 holds the true baseline, and restoring from it is a new wave, not this rollback.`
767
+ };
768
+ }
769
+ if (manifest.archived["pre"]) {
770
+ try {
771
+ await fs.access(path.join(archived, "pre", "projection.json"));
772
+ }
773
+ catch {
774
+ return {
775
+ ok: false,
776
+ code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
777
+ message: `this wave was applied ${attempt} times under one label, and attempt ${n}'s archive manifest records a pre-image its directory does not hold — a truncated archive cannot testify to a baseline. ` +
778
+ `Use direct_history to see what each attempt delivered; the mirror commit before attempt 1 holds the true baseline, and restoring from it is a new wave, not this rollback.`
779
+ };
780
+ }
781
+ return {
782
+ ok: true,
783
+ preRoot: archived,
784
+ note: `this wave was applied ${attempt} times under one label; the rollback restores the baseline attempt ${n} captured, from before the wave first changed the org.` +
785
+ (noOps.length > 0 ? ` (Attempt${noOps.length > 1 ? "s" : ""} ${noOps.join(", ")} left no pre-image and never reached the org.)` : "")
786
+ };
787
+ }
788
+ noOps.push(n);
789
+ }
790
+ // Every earlier attempt is a PROVEN no-op: none wrote a pre-image, so none reached the deploy
791
+ // that follows the pre-write — the org went untouched until the attempt that wrote this bundle's
792
+ // own pre/, which is therefore the wave's honest baseline. Refusing here would permanently lock
793
+ // rollback out of the ordinary "first attempt errored out, fix it, re-run the label" flow.
794
+ return {
795
+ ok: true,
796
+ preRoot: bundleDir,
797
+ note: `this wave was applied ${attempt} times under one label, but attempt${noOps.length > 1 ? "s" : ""} ${noOps.join(", ")} left no pre-image and never reached the org — the last attempt's own pre/ is the wave's baseline.`
798
+ };
799
+ }
800
+ function liveStateReport(state, readerError) {
801
+ if (!state) {
802
+ return { status: "NOT_ATTEMPTED", scope: 0, count: 0, error: readerError ?? "no live org reader", unknown: [] };
803
+ }
804
+ const count = state.live.size;
805
+ const status = state.error !== undefined
806
+ ? "FAILED"
807
+ : state.scope === 0
808
+ ? "NOTHING_IN_SCOPE"
809
+ : count === 0
810
+ ? "NONE_READABLE"
811
+ : count < state.scope
812
+ ? "PARTIAL"
813
+ : "READ";
814
+ return { status, scope: state.scope, count, ...(state.error ? { error: state.error } : {}), unknown: state.unknown };
815
+ }
816
+ /**
817
+ * Classify, gate, card — and, in `execute`, deploy.
818
+ *
819
+ * TWO CLASSIFICATION PASSES, and the first one touches nothing. The classifier needs the live map
820
+ * and the probe up front, and which components to read live is decided by the wave's own candidate
821
+ * list, which only the classifier knows. So the first pass runs against the bundle alone (pure
822
+ * disk, no org contact) to learn the candidates, and the second — with live state and the probe —
823
+ * is the plan. Both are handed the same `asOf`, so the window arithmetic cannot move between them.
824
+ */
825
+ export async function runRollback(input) {
826
+ const asOf = input.now ?? new Date().toISOString();
827
+ const notes = [];
828
+ if (input.mode === "verify")
829
+ return verifyUndelete(input, asOf);
830
+ // Pass 1 — the candidate list, from the bundle only.
831
+ const candidates = await classifyRollback({
832
+ bundleDir: input.bundleDir,
833
+ orgId: input.orgId,
834
+ asOf,
835
+ ...(input.components ? { components: input.components } : {}),
836
+ ...(input.preImageRoot ? { preImageRoot: input.preImageRoot } : {})
837
+ });
838
+ // Live state, for the R16 preflight and for nothing else. It is opt-in in the classifier and
839
+ // SILENT when omitted, so the one thing this must never do is skip it quietly.
840
+ // UNDEFINED when there was no reader, rather than a hand-built result carrying an `error` — a read
841
+ // that never happened is not a read that failed, and shaping the two identically is what forced
842
+ // the distinction to travel beside the value as a boolean the caller had to keep in sync.
843
+ let liveState;
844
+ if (input.reader) {
845
+ liveState = await readLiveState({
846
+ reader: input.reader,
847
+ orgAlias: input.orgAlias,
848
+ apiVersion: input.apiVersion,
849
+ outDir: path.join(input.rollbackBundleDir, "preflight"),
850
+ components: candidates.components.map((c) => ({ typeName: c.typeName, fullName: c.fullName }))
851
+ });
852
+ }
853
+ const probe = input.reader ? liveRollbackProbe(input.reader) : undefined;
854
+ // Pass 2 — the plan.
855
+ let plan = await classifyRollback({
856
+ bundleDir: input.bundleDir,
857
+ orgId: input.orgId,
858
+ asOf,
859
+ ...(input.components ? { components: input.components } : {}),
860
+ ...(probe ? { probe } : {}),
861
+ ...(liveState && liveState.live.size > 0 ? { live: liveState.live } : {}),
862
+ ...(input.preImageRoot ? { preImageRoot: input.preImageRoot } : {})
863
+ });
864
+ // THE WINDOW IS RE-ASKED AT EXECUTION TIME, per component. A plan that said "11 days left,
865
+ // undelete in Setup" is a statement about the instant it was made; an operator who takes it to a
866
+ // change board and comes back on day 16 is holding a promise Salesforce has already broken.
867
+ let reclassified = [];
868
+ if (input.mode === "execute") {
869
+ const rechecked = await recheckUndeleteWindow({ plan, asOf, ...(probe ? { probe } : {}) });
870
+ plan = rechecked.plan;
871
+ reclassified = rechecked.reclassified;
872
+ }
873
+ // AN ACK THAT MATCHED NOTHING IS REPORTED, never dropped. `classifyRollback` already does this for
874
+ // an unmatched `components` entry, and the asymmetry was the bug: a mistyped `ackComponents` key
875
+ // silently releases nothing, so the operator reads "HELD — needs a named ack" against a component
876
+ // they believe they just released, and the natural next move is to look for what else is wrong
877
+ // rather than at the key. Named here in the same voice, and it does not block anything: the keys
878
+ // that DID match still release their components.
879
+ const knownKeys = new Set(plan.components.map((component) => component.key));
880
+ const unmatchedAcks = [...new Set(input.ackComponents ?? [])].filter((key) => !knownKeys.has(key)).sort();
881
+ if (unmatchedAcks.length > 0) {
882
+ notes.push(`acknowledged but not classified in this plan, so ${unmatchedAcks.length > 1 ? "these acks released" : "this ack released"} nothing: ${unmatchedAcks.join(", ")}. ` +
883
+ `An ack key is the component's own \`Type:FullName\`, exactly as printed above` +
884
+ `${input.components ? " — and note this call narrowed the plan with `components`, so a real key outside that selection is not in it either" : ""}.`);
885
+ }
886
+ // A component the preflight could not answer for gates like NOT_COMPARABLE: held behind its own
887
+ // key. A WHOLE-preflight failure holds every component the same way: `liveState.error` set means
888
+ // the projection itself threw — expired token, scope too large — and exactly none of the plan's
889
+ // components were compared against live state. The old reading made that the ONE case with an
890
+ // empty hold-set, so a dead token at execute time released every PROCEEDS component to overwrite
891
+ // live state nobody looked at, while a single unreadable component would have been held (gap G2,
892
+ // confirmed 2026-08-18). The header still prints the failure; the hold is what makes it binding.
893
+ // `liveState` UNDEFINED stays distinct: no reader was supplied at all, which plan mode reports as
894
+ // `readerError` — a read that never happened is not a read that failed, and the per-component ack
895
+ // text would claim an attempt that was never made.
896
+ const unreadableKeys = liveState === undefined
897
+ ? new Set()
898
+ : liveState.error === undefined
899
+ ? new Set(liveState.unreadable)
900
+ : new Set(plan.components.map((component) => component.key));
901
+ const gated = gateRollback(plan, input.ackComponents ?? [], unreadableKeys);
902
+ // THE SAME RULE ONE STEP IN: an ack that matched a component NO ACKNOWLEDGEMENT CAN RELEASE.
903
+ //
904
+ // An `OPERATOR_ACTION` component is settled in Setup, and the ack vocabulary deliberately cannot
905
+ // buy its deploy — the deploy is the harm. But the key still MATCHES a real component, so the note
906
+ // above never fires for it, and the operator spends their acknowledgement, reads a card that never
907
+ // mentions it, and goes hunting for the mistake somewhere else. That is precisely the silence the
908
+ // unmatched-ack note exists to end; the fact that this one is inert BY DESIGN is a reason to say
909
+ // so, not a reason to say nothing. Reported only: nothing about the run changes.
910
+ const ackedKeys = new Set(input.ackComponents ?? []);
911
+ const inertAcks = gated
912
+ .filter((entry) => entry.disposition === "OPERATOR_ACTION" && ackedKeys.has(entry.component.key))
913
+ .map((entry) => entry.component.key)
914
+ .sort();
915
+ if (inertAcks.length > 0) {
916
+ notes.push(`acknowledged but settled in Setup rather than by a deploy, so ${inertAcks.length > 1 ? "these acks released" : "this ack released"} nothing: ${inertAcks.join(", ")}. ` +
917
+ `No acknowledgement releases ${plural(inertAcks.length, "this component", "these components")} — what an ack would release is a metadata re-create, ` +
918
+ `and the re-create is the harm this card exists to prevent. Follow the card above instead; it is the whole instruction.`);
919
+ }
920
+ const cards = gated
921
+ .filter((entry) => entry.disposition === "OPERATOR_ACTION")
922
+ .map((entry) => renderUndeleteCard(entry.component, { sessionId: input.sessionId, wave: input.wave }));
923
+ // The verification wave is SCHEDULED when the card is issued, not when the operator remembers.
924
+ // It is written on a plan as well as an execute, because the ordinary undelete flow never calls
925
+ // execute at all: card → the operator undeletes in Setup → verify.
926
+ let schedule;
927
+ if (cards.length > 0) {
928
+ schedule = await writeUndeleteSchedule(input, gated, asOf);
929
+ }
930
+ const result = {
931
+ mode: input.mode,
932
+ sessionId: input.sessionId,
933
+ wave: input.wave,
934
+ orgId: input.orgId,
935
+ asOf,
936
+ plan,
937
+ reclassified,
938
+ dispositions: gated.map((entry) => ({
939
+ key: entry.component.key,
940
+ class: entry.component.class,
941
+ gate: entry.component.gate,
942
+ disposition: entry.disposition,
943
+ ...(entry.ackKey ? { ackKey: entry.ackKey } : {}),
944
+ ackReasons: entry.ackReasons
945
+ })),
946
+ cards,
947
+ ...(schedule ? { schedule } : {}),
948
+ liveState: liveStateReport(liveState, input.readerError),
949
+ notes,
950
+ text: ""
951
+ };
952
+ if (input.mode === "plan") {
953
+ result.text = renderPlan(input, result, gated);
954
+ return result;
955
+ }
956
+ // EXECUTE. Partial by default: whatever is held, refused or carded simply does not travel.
957
+ const executing = gated.filter((entry) => entry.disposition === "EXECUTE").map((entry) => entry.component);
958
+ const work = await rollbackWork(input.preImageRoot ?? input.bundleDir, executing);
959
+ for (const key of work.missingImage) {
960
+ notes.push(`${key} classified as executable but no file for it survives in ${input.wave}'s pre/source tree — nothing was deployed for it. The retrieve that produces that tree is fail-open, so an image can be absent without the wave having failed.`);
961
+ }
962
+ if (work.edits.length === 0 && work.deletes.length === 0) {
963
+ notes.push("nothing executed: no selected component reached an executable state. Every reason is named above — this is the partial-selection posture working, not a failure.");
964
+ result.text = renderPlan(input, result, gated);
965
+ return result;
966
+ }
967
+ if (!input.reader) {
968
+ throw new Error(`cannot execute a rollback without a live org reader (${input.readerError ?? "unavailable"}). Classification above is complete and nothing was changed.`);
969
+ }
970
+ result.applied = await runDirectApply({
971
+ reader: input.reader,
972
+ targetOrg: input.orgAlias,
973
+ apiVersion: input.apiVersion,
974
+ edits: work.edits,
975
+ deletes: work.deletes,
976
+ facetUpdates: [],
977
+ acks: input.ackLints ?? [],
978
+ // R3/R21 — the commit is marked as a revert of the wave it undoes, by (session, wave), which is
979
+ // the same rebuild-stable address this tool was called with. A SHA here would not survive the
980
+ // next materializer change.
981
+ revertOf: { sessionId: input.sessionId, wave: input.wave },
982
+ ...(input.batchId ? { batchId: input.batchId } : {}),
983
+ ...(input.requirementIds?.length ? { requirementIds: input.requirementIds } : {}),
984
+ ...(input.operator ? { operator: input.operator } : {}),
985
+ dryRunOnly: input.dryRunOnly ?? false,
986
+ bundleDir: input.rollbackBundleDir
987
+ });
988
+ result.text = renderPlan(input, result, gated);
989
+ return result;
990
+ }
991
+ async function writeUndeleteSchedule(input, gated, asOf) {
992
+ const manifest = await readJson(path.join(input.preImageRoot ?? input.bundleDir, "pre", "projection.json"));
993
+ const fileFor = new Map((manifest?.components ?? [])
994
+ .filter((entry) => typeof entry?.typeName === "string" && typeof entry?.fullName === "string" && typeof entry?.file === "string")
995
+ .map((entry) => [`${entry.typeName}:${entry.fullName}`, entry.file]));
996
+ const schedule = {
997
+ target: { sessionId: input.sessionId, wave: input.wave },
998
+ orgId: input.orgId,
999
+ scheduledAt: asOf,
1000
+ components: gated
1001
+ .filter((entry) => entry.disposition === "OPERATOR_ACTION")
1002
+ .map((entry) => {
1003
+ const preImageFile = fileFor.get(entry.component.key);
1004
+ return {
1005
+ key: entry.component.key,
1006
+ typeName: entry.component.typeName,
1007
+ fullName: entry.component.fullName,
1008
+ deletedNames: entry.component.window?.candidates ?? [],
1009
+ ...(preImageFile ? { preImageFile } : {}),
1010
+ ...(entry.component.window?.lapsesAt ? { lapsesAt: entry.component.window.lapsesAt } : {})
1011
+ };
1012
+ })
1013
+ };
1014
+ try {
1015
+ await fs.mkdir(input.rollbackBundleDir, { recursive: true });
1016
+ await fs.writeFile(path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE), `${JSON.stringify(schedule, null, 1)}\n`, "utf8");
1017
+ }
1018
+ catch {
1019
+ // The schedule is a convenience for the verification wave, not the card. A rollback plan that
1020
+ // could not write a file still cards the operator correctly, and `mode: "verify"` says plainly
1021
+ // when it has nothing to read.
1022
+ return schedule;
1023
+ }
1024
+ return schedule;
1025
+ }
1026
+ async function readJson(file) {
1027
+ try {
1028
+ return JSON.parse(await fs.readFile(file, "utf8"));
1029
+ }
1030
+ catch {
1031
+ return null;
1032
+ }
1033
+ }
1034
+ /**
1035
+ * AE4's second half: after the operator undeletes, compare the field against its pre-image.
1036
+ *
1037
+ * READS BOTH NAMES, which is the whole reason this cannot be a normal projection. An undelete
1038
+ * restores the field UNDER ITS DELETED NAME (`Region_del__c`) — measured, 2026-08-02-005 §1 — so a
1039
+ * verification that read only the original API name would report a successful restore as "still
1040
+ * absent" and send the operator to re-create it, which is the exact data-loss path R19 exists to
1041
+ * close. Finding it under the deleted name is a PASS with an outstanding rename, and is reported
1042
+ * that way.
1043
+ */
1044
+ async function verifyUndelete(input, asOf) {
1045
+ const base = {
1046
+ mode: "verify",
1047
+ sessionId: input.sessionId,
1048
+ wave: input.wave,
1049
+ orgId: input.orgId,
1050
+ asOf,
1051
+ notes: [],
1052
+ text: ""
1053
+ };
1054
+ const schedule = await readJson(path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE));
1055
+ if (!schedule || schedule.components.length === 0) {
1056
+ base.text =
1057
+ `no verification wave is scheduled for wave ${input.wave}. One is written when a rollback cards an in-window ` +
1058
+ `deleted field for undelete — run \`direct_rollback {sessionId, wave}\` first, undelete in Setup, then come back.`;
1059
+ return base;
1060
+ }
1061
+ if (!input.reader) {
1062
+ base.liveState = liveStateReport(undefined, input.readerError);
1063
+ base.text = `cannot verify: no live org reader (${input.readerError ?? "unavailable"}). Nothing was read and nothing is claimed.`;
1064
+ return base;
1065
+ }
1066
+ // Both names for every carded component — see the doc comment.
1067
+ const wanted = [];
1068
+ for (const entry of schedule.components) {
1069
+ wanted.push({ typeName: entry.typeName, fullName: entry.fullName });
1070
+ for (const deleted of entry.deletedNames)
1071
+ wanted.push({ typeName: entry.typeName, fullName: deleted });
1072
+ }
1073
+ const live = await readLiveState({
1074
+ reader: input.reader,
1075
+ orgAlias: input.orgAlias,
1076
+ apiVersion: input.apiVersion,
1077
+ outDir: path.join(input.rollbackBundleDir, "verify"),
1078
+ components: wanted
1079
+ });
1080
+ // Reported in the same shape a plan reports it, so a structured consumer has ONE place to ask what
1081
+ // a live read answered rather than re-counting findings for this mode.
1082
+ base.liveState = liveStateReport(live, input.readerError);
1083
+ if (live.error) {
1084
+ base.text = `cannot verify: the live read failed (${live.error}). Nothing was read and nothing is claimed.`;
1085
+ return base;
1086
+ }
1087
+ const findings = [];
1088
+ for (const entry of schedule.components) {
1089
+ const preImage = entry.preImageFile
1090
+ ? await readJson(path.join(input.preImageRoot ?? input.bundleDir, "pre", entry.preImageFile))
1091
+ : null;
1092
+ const before = (preImage?.state?.metadata ?? null);
1093
+ const original = live.live.get(entry.key);
1094
+ const deletedKey = entry.deletedNames.map((name) => `${entry.typeName}:${name}`).find((key) => live.live.get(key));
1095
+ const observed = original ?? (deletedKey ? live.live.get(deletedKey) : undefined);
1096
+ const after = (observed?.state?.metadata ?? null);
1097
+ if (!live.live.has(entry.key) && !deletedKey) {
1098
+ findings.push({
1099
+ key: entry.key,
1100
+ state: "UNREADABLE",
1101
+ differences: [],
1102
+ detail: `${entry.key}: the org could not be read for this component under either name, so nothing is claimed about it.`
1103
+ });
1104
+ continue;
1105
+ }
1106
+ if (after === null) {
1107
+ findings.push({
1108
+ key: entry.key,
1109
+ state: "ABSENT",
1110
+ differences: [],
1111
+ detail: `${entry.key} is not in the org under its own name${entry.deletedNames.length ? ` or as ${entry.deletedNames.join(", ")}` : ""}. The undelete has not happened yet, or it restored a different generation. DO NOT re-create it as metadata while the window is open — that trades the data for a definition.`
1112
+ });
1113
+ continue;
1114
+ }
1115
+ // NEVER compare across the rename: `fullName` differs by construction while the field is still
1116
+ // `_del`-suffixed, and reporting that as a restored-wrong facet would bury the real ones. The
1117
+ // rename is checklist item 1 and is reported as its own state, not as a facet difference.
1118
+ const differences = diffMetadata(before === null ? null : withoutFullName(before), withoutFullName(after));
1119
+ const restoredUnderDeletedName = original === undefined || original === null;
1120
+ findings.push({
1121
+ key: entry.key,
1122
+ state: restoredUnderDeletedName ? "RESTORED_UNDER_DELETED_NAME" : "RESTORED",
1123
+ ...(restoredUnderDeletedName && deletedKey ? { liveAs: deletedKey.slice(deletedKey.indexOf(":") + 1) } : {}),
1124
+ differences,
1125
+ detail: restoredUnderDeletedName
1126
+ ? `${entry.key} is BACK, still under its deleted name — checklist item 1 is outstanding: rename it to ${entry.fullName} before anything that references it works again.`
1127
+ : before === null
1128
+ ? `${entry.key} is live under its own name. This bundle holds no pre-image snapshot for it, so what it looked like before the delete cannot be compared here.`
1129
+ : differences.length === 0
1130
+ ? `${entry.key} is live under its own name and matches wave ${input.wave}'s pre-image on every facet the read covers.`
1131
+ : `${entry.key} is live under its own name and differs from wave ${input.wave}'s pre-image on ${differences.length} facet(s) — these are what the undelete did not restore.`
1132
+ });
1133
+ }
1134
+ base.verification = { target: { sessionId: input.sessionId, wave: input.wave }, checkedAt: asOf, findings };
1135
+ base.text = renderVerification(base.verification, input.wave);
1136
+ return base;
1137
+ }
1138
+ const withoutFullName = (metadata) => {
1139
+ const { fullName: _fullName, ...rest } = metadata;
1140
+ return rest;
1141
+ };
1142
+ function renderVerification(report, wave) {
1143
+ // SAME RULE AS THE PLAN HEADER. `readLiveState` returns without an error when the projection
1144
+ // itself ran and every component inside it failed, so "checked against that wave's own pre-image"
1145
+ // is a heading that survives a verification which read nothing. Each finding says UNREADABLE on
1146
+ // its own line; the summary has to say it too, because the summary is what gets skimmed.
1147
+ const unreadable = report.findings.filter((finding) => finding.state === "UNREADABLE").length;
1148
+ const total = report.findings.length;
1149
+ const lines = [
1150
+ `VERIFICATION WAVE — the undelete of wave ${wave}, checked against that wave's own pre-image (${report.checkedAt})`,
1151
+ unreadable === 0
1152
+ ? `all ${total} carded component(s) were read and compared.`
1153
+ : unreadable === total
1154
+ ? `NOTHING WAS COMPARED — the org could not be read for any of the ${total} carded component(s), under either name. Nothing below is a claim about whether the undelete happened.`
1155
+ : `${total - unreadable} of ${total} carded component(s) were read and compared; ${unreadable} could not be read and carry no claim either way.`,
1156
+ ""
1157
+ ];
1158
+ for (const finding of report.findings) {
1159
+ lines.push(`${finding.state} ${finding.key}`);
1160
+ for (const sentence of wrap(finding.detail, 100))
1161
+ lines.push(` ${sentence}`);
1162
+ for (const change of finding.differences) {
1163
+ lines.push(` - ${change.path}: pre-image ${change.before} → now ${change.after}${change.orderOnly ? " (order only)" : ""}`);
1164
+ }
1165
+ lines.push("");
1166
+ }
1167
+ lines.push("This compares METADATA SHAPE against the pre-image. It does not and cannot prove the records' values came", "back — the field was unreadable while deleted, so nothing recorded what they were. Open a record and look.");
1168
+ return lines.join("\n");
1169
+ }
1170
+ // ---------------------------------------------------------------------------------------------
1171
+ // rendering
1172
+ // ---------------------------------------------------------------------------------------------
1173
+ const HEADINGS = {
1174
+ EXECUTE: "WILL EXECUTE",
1175
+ HELD_FOR_ACK: "HELD — needs a named ack",
1176
+ // "Settled in Setup" rather than "undelete in Setup": the same disposition also carries the field
1177
+ // whose window is out on a clock nothing checked, and there the operator's job is to find out
1178
+ // WHICH it is. Promising an undelete to someone who may find nothing to undelete is the same
1179
+ // overclaim as the header this module's last defect produced, one line further down.
1180
+ OPERATOR_ACTION: "OPERATOR ACTION — settled in Setup, never a deploy",
1181
+ REPORTED: "REPORTED — nothing for this tool to execute"
1182
+ };
1183
+ /** `n component(s)`, or `it`/`them` where the count is already on the line. */
1184
+ const plural = (n, singular, many) => (n === 1 ? singular : many);
1185
+ /**
1186
+ * The R16 preflight's summary — what it COMPARED, never what it was asked to compare.
1187
+ *
1188
+ * The count-zero case gets its own branch and its own verb. A read that answered for nothing did
1189
+ * not preflight anything, and it borrows the not-read branch's sentence verbatim, because that is
1190
+ * exactly what it is: no divergence below because none was looked for.
1191
+ */
1192
+ function liveStateLines(live) {
1193
+ const noneLookedFor = "No divergence is reported below because none was looked for; that is not the same as live state agreeing with what this wave recorded.";
1194
+ const notRead = (error) => [
1195
+ `live state: NOT READ (${error ?? "unavailable"}) — the R16 preflight DID NOT RUN. ${noneLookedFor}`
1196
+ ];
1197
+ if (!live)
1198
+ return notRead(undefined);
1199
+ const unreadable = live.scope - live.count;
1200
+ // `unknown` can be shorter than the count — a component the projection never mentioned is
1201
+ // unreadable with no reason to give — so the count comes from the scope and the list is detail.
1202
+ const detail = live.unknown.length > 0
1203
+ ? ` the org could not be read for ${unreadable}: ${live.unknown.join("; ")}. Those carry NO live claim either way.`
1204
+ : ` ${unreadable} component(s) in scope came back with no answer at all. Those carry NO live claim either way.`;
1205
+ switch (live.status) {
1206
+ case "NOT_ATTEMPTED":
1207
+ case "FAILED":
1208
+ return notRead(live.error);
1209
+ case "READ":
1210
+ return [
1211
+ `live state: READ — all ${live.count} component(s) in scope were read from the org, and the R16 preflight compared every one of them.`
1212
+ ];
1213
+ case "PARTIAL":
1214
+ return [
1215
+ `live state: PARTIAL — ${live.count} of ${live.scope} component(s) in scope were read and compared; ${unreadable} could not be read, so the R16 preflight did not run against ${plural(unreadable, "it", "them")}.`,
1216
+ detail
1217
+ ];
1218
+ case "NONE_READABLE":
1219
+ return [
1220
+ `live state: NOT COMPARED — the read was issued and answered for 0 of ${live.scope} component(s), so the R16 preflight compared NOTHING. ${noneLookedFor}`,
1221
+ detail
1222
+ ];
1223
+ case "NOTHING_IN_SCOPE":
1224
+ return [
1225
+ "live state: NOTHING IN SCOPE — no component was selected for the R16 preflight to read, so it compared nothing."
1226
+ ];
1227
+ }
1228
+ // A status this renderer has not been taught falls to the CONSERVATIVE line. Every switch above is
1229
+ // exhaustive today, so this is unreachable — and it is here because the alternative default (the
1230
+ // last case listed) would render an unknown status as "nothing to read", the single most
1231
+ // reassuring sentence in the set, in a module whose thesis is that a summary may not overclaim.
1232
+ return notRead(live.error);
1233
+ }
1234
+ /**
1235
+ * The deleted-field probe's summary — what ANSWERED, and what an unanswered probe costs.
1236
+ *
1237
+ * Every branch that is not MEASURED names the consequence in the same sentence as the status, in
1238
+ * the words `classifyDeletedField` uses per component: presence UNVERIFIED, not absent, and the
1239
+ * window is the clock's answer alone.
1240
+ */
1241
+ function probeLines(probe) {
1242
+ const notRun = [
1243
+ "deleted-field probe: NOT RUN — presence in Salesforce's Deleted Fields list is UNVERIFIED, which is not the same as absent."
1244
+ ];
1245
+ if (!probe)
1246
+ return notRun;
1247
+ // EACH COMPONENT CARRIES ITS OWN REASON. `failures` is keyed by object and `unverified` by
1248
+ // component; printing the object errors as one parenthetical and the component keys as another
1249
+ // would invite the reader to pair them up, and they do not always pair.
1250
+ const reasons = probe.unverified.map((entry) => ` ${entry.key} — ${entry.reason}`);
1251
+ switch (probe.status) {
1252
+ case "NOT_ATTEMPTED":
1253
+ return notRun;
1254
+ case "MEASURED":
1255
+ return [
1256
+ `deleted-field probe: MEASURED for ${probe.verified.length} component(s) — presence in Salesforce's Deleted Fields list was read from the org, not derived from the clock.`
1257
+ ];
1258
+ case "PARTIAL":
1259
+ return [
1260
+ `deleted-field probe: PARTIAL — measured for ${probe.verified.length} component(s), and ${probe.unverified.length} went unanswered. ` +
1261
+ `Presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent — for ${plural(probe.unverified.length, "that component, whose window is", "those components, whose windows are")} the clock's answer alone:`,
1262
+ ...reasons
1263
+ ];
1264
+ case "FAILED":
1265
+ return [
1266
+ `deleted-field probe: ATTEMPTED, FAILED — nothing answered. ` +
1267
+ `Presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent — for all ${probe.unverified.length} component(s) it was asked about, and every window below is the clock's answer alone:`,
1268
+ ...reasons
1269
+ ];
1270
+ case "NOT_NEEDED":
1271
+ return [
1272
+ "deleted-field probe: NOT NEEDED — no component in this plan is a deleted field, so there is no Deleted Fields list to read and none was."
1273
+ ];
1274
+ }
1275
+ return notRun; // Unknown status → the conservative line. See `liveStateLines`.
1276
+ }
1277
+ function renderPlan(input, result, gated) {
1278
+ const executed = result.applied !== undefined;
1279
+ const lines = [
1280
+ `${executed ? "ROLLBACK EXECUTED" : "ROLLBACK PLAN"} — wave ${input.wave} of session ${input.sessionId} (org ${input.orgId}), classified ${result.asOf}`,
1281
+ `${gated.length} component(s) classified${input.components ? " from the selection given" : " — the whole wave"}.`
1282
+ ];
1283
+ // R16 IS OPT-IN AND SILENT WHEN OMITTED, so its absence is stated rather than left to be inferred
1284
+ // from a card with no divergence on it. Absence of a divergence is not absence of divergence.
1285
+ //
1286
+ // AND NEITHER OF THESE TWO LINES MAY RESOLVE OFF "WAS ATTEMPTED". They are the first thing an
1287
+ // operator reads and everything below them is per-component; a summary that says MEASURED over
1288
+ // evidence that says UNVERIFIED is not a wording problem, it is the header contradicting the
1289
+ // finding. See `LiveStateReport` for the live case that caught this. Both statuses distinguish
1290
+ // not-attempted from attempted-and-failed from partial, and every branch states the consequence
1291
+ // in the same breath as the status.
1292
+ lines.push(...liveStateLines(result.liveState), ...probeLines(result.plan?.probe));
1293
+ if (input.writeBlocked)
1294
+ lines.push(`writes: ${input.writeBlocked}`);
1295
+ if (result.reclassified?.length) {
1296
+ lines.push("", "RECLASSIFIED AT EXECUTION TIME — the plan you read is no longer the plan:");
1297
+ for (const entry of result.reclassified) {
1298
+ lines.push(` ${entry.key}: ${entry.from} → ${entry.to}`);
1299
+ for (const sentence of wrap(entry.reason, 100))
1300
+ lines.push(` ${sentence}`);
1301
+ }
1302
+ }
1303
+ for (const disposition of ["EXECUTE", "HELD_FOR_ACK", "OPERATOR_ACTION", "REPORTED"]) {
1304
+ const entries = gated.filter((entry) => entry.disposition === disposition);
1305
+ if (entries.length === 0)
1306
+ continue;
1307
+ lines.push("", `${HEADINGS[disposition]} (${entries.length})`);
1308
+ for (const entry of entries) {
1309
+ // An OPERATOR_ACTION entry's detail is its CARD, printed in full below — repeating the action
1310
+ // line here would say less in more space. Everything that would be lost by skipping it has to
1311
+ // reach the card instead, which is why `renderUndeleteCard` carries the R16 divergence: this
1312
+ // `continue` used to drop that finding on the floor for the one class that cannot afford it.
1313
+ if (disposition === "OPERATOR_ACTION")
1314
+ continue;
1315
+ lines.push(` ${entry.component.key} — ${entry.component.class}`);
1316
+ for (const sentence of wrap(entry.component.action, 100))
1317
+ lines.push(` ${sentence}`);
1318
+ if (entry.component.limitation) {
1319
+ for (const sentence of wrap(`REFUSED: ${entry.component.limitation}`, 100))
1320
+ lines.push(` ${sentence}`);
1321
+ }
1322
+ // THE REASONS COME BEFORE THE RELEASE TOKEN, and the token says how many it covers. One key
1323
+ // releases every reason on the component (see `GatedComponent.ackKey`), so an operator who
1324
+ // read one reason and typed the key would be answering for all of them without being told.
1325
+ for (const reason of entry.ackReasons) {
1326
+ for (const sentence of wrap(`• ${reason}`, 100))
1327
+ lines.push(` ${sentence}`);
1328
+ }
1329
+ if (entry.ackKey && disposition === "HELD_FOR_ACK") {
1330
+ lines.push(entry.ackReasons.length > 1
1331
+ ? ` release it with: ackComponents: ["${entry.ackKey}"] <- this ONE key accepts ALL ${entry.ackReasons.length} reasons above`
1332
+ : ` release it with: ackComponents: ["${entry.ackKey}"]`);
1333
+ }
1334
+ }
1335
+ }
1336
+ for (const card of result.cards ?? [])
1337
+ lines.push("", card.text);
1338
+ if (result.schedule) {
1339
+ lines.push("", `A verification wave is scheduled in ${path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE)}.`);
1340
+ }
1341
+ if (result.applied) {
1342
+ const applied = result.applied;
1343
+ lines.push("", `deploy verdict: ${applied.verdict}`, `bundle: ${applied.bundleDir}`, ...(applied.mirror
1344
+ ? [
1345
+ applied.mirror.error
1346
+ ? `history: NOT RECORDED (${applied.mirror.error})`
1347
+ : applied.mirror.committed
1348
+ ? `history: recorded as ${applied.mirror.sha?.slice(0, 12)}, marked a revert of ${input.sessionId}/${input.wave} (the sha is a display alias, never store it)`
1349
+ : "history: nothing to record — this rollback left the org exactly as it was"
1350
+ ]
1351
+ : []), ...(applied.operatorSummary ? [`operator summary: ${applied.operatorSummary}`] : []), ...applied.notes);
1352
+ }
1353
+ else if (input.mode === "plan") {
1354
+ const held = gated.filter((entry) => entry.disposition === "HELD_FOR_ACK").map((entry) => entry.component.key);
1355
+ lines.push("", "NOTHING HAS BEEN CHANGED. This call classified and carded only.", ` execute it with: direct_rollback {sessionId: "${input.sessionId}", wave: "${input.wave}", mode: "execute"${held.length ? `, ackComponents: [${held.map((key) => `"${key}"`).join(", ")}]` : ""}}`);
1356
+ }
1357
+ for (const note of [...result.notes, ...(result.plan?.notes ?? [])])
1358
+ lines.push(`note: ${note}`);
1359
+ return lines.join("\n");
1360
+ }