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,1513 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { sortKeysDeep } from "../projection/diff.js";
4
+ import { incompleteFacetsOf } from "../orgread/layoutSnapshot.js";
5
+ import { assessComparability } from "./snapshotComparability.js";
6
+ import { deliveredComponents, mirrorPath } from "./orgRepo.js";
7
+ import { resolveComponentFromRelPath } from "./sourceTree.js";
8
+ /**
9
+ * ORIGIN R18, VERBATIM IN STRUCTURE — every class this module can return, and nothing else.
10
+ *
11
+ * Exported as data rather than left implicit in branches because R18 is a CONTRACT: the tool's
12
+ * card text, this module's classification and the plan document all have to say the same thing,
13
+ * and a table nobody can read is a table that drifts.
14
+ *
15
+ * ONE ROW IS AN AMENDMENT, not a transcription: `ALREADY_LIVE` has no counterpart in
16
+ * 2026-07-31-001 §2, which never asks what happens when the deleted component is back. It is
17
+ * recorded as an amendment at R18 in 2026-08-02-001 (2026-08-03) rather than smuggled in here.
18
+ */
19
+ export const REVERSIBILITY_TABLE = [
20
+ {
21
+ class: "CLEAN_DELETE",
22
+ change: "Component created",
23
+ rollbackPath: "Delete it",
24
+ risk: "Clean, if nothing came to depend on it"
25
+ },
26
+ {
27
+ class: "CLEAN_REDEPLOY",
28
+ change: "Component modified",
29
+ rollbackPath: "Deploy the bundle's pre/source/ image",
30
+ risk: "Clean while the pre-image is COMPLETE; an incomplete one drops content on a replace type, so an image whose projection declared an unread facet is gated for ack rather than called clean"
31
+ },
32
+ {
33
+ class: "UNDELETE_WINDOW",
34
+ change: "Field deleted, under 15 days ago",
35
+ rollbackPath: "Undelete in Setup — never a metadata re-create",
36
+ risk: "Data intact. Restoring by deploy instead would throw it away while appearing to succeed"
37
+ },
38
+ {
39
+ class: "ALREADY_LIVE",
40
+ change: "Field deleted, and the component is LIVE in the org again",
41
+ rollbackPath: "Nothing to roll back for the definition — verify the live state is what you want",
42
+ risk: "No loss to accept. Carding this as lossy would claim data loss that did not happen, and re-creating a component that already exists is a deploy against live state"
43
+ },
44
+ {
45
+ class: "LOSSY_RECREATE",
46
+ change: "Field deleted, window lapsed",
47
+ rollbackPath: "Re-create from the pre-image",
48
+ risk: "Metadata returns, DATA IS GONE FOREVER — an explicit operator decision, never a default"
49
+ },
50
+ {
51
+ class: "IRREVERSIBLE",
52
+ change: "Picklist value set moved, record type created, CMT access granted",
53
+ rollbackPath: "None — the Metadata API cannot perform the removal, or no deploy restores the data",
54
+ risk: "Refused, naming the limitation; the rest of a partial selection is unaffected"
55
+ }
56
+ ];
57
+ /**
58
+ * Mark a RESOLVED `EntityDefinition.DurableId` as addressable. The only way to make a
59
+ * `TableEnumOrId`, so every one of them is traceable to a resolution the caller actually performed —
60
+ * U10's probe does it in `rollbackRun.ts`.
61
+ */
62
+ export const tableEnumOrId = (durableId) => durableId;
63
+ /**
64
+ * The Tooling query, with the column that makes the difference correct.
65
+ *
66
+ * `NamespacePrefix` is not optional. Omitting it turns a 34-row deleted set into a 226-row one made
67
+ * almost entirely of managed-package fields whose `DeveloperName` does not carry their namespace
68
+ * (measured, T-Pinto, 2026-08-03). `FullName` is included because Tooling's own answer beats a
69
+ * reconstruction, and it is free HERE — the single-row restriction that makes `Metadata`/`FullName`
70
+ * expensive applies to the qualification, not the column list. If an org rejects it, drop the column
71
+ * and `toolingFullName` reconstructs.
72
+ *
73
+ * The parameter is a `TableEnumOrId` and not an object name — see that type.
74
+ */
75
+ export const TOOLING_CUSTOM_FIELD_SOQL = (table) => `SELECT Id, DeveloperName, NamespacePrefix FROM CustomField WHERE TableEnumOrId = '${table}'`;
76
+ /**
77
+ * The audit query. `Action` differs by object kind and `Display` cannot appear in a WHERE clause
78
+ * (probe §4, traps 1 and 2), so the object filter is `Section` server-side plus label matching
79
+ * client-side — which is what makes every window fact this contributes INFERRED.
80
+ */
81
+ export const SETUP_AUDIT_TRAIL_SOQL = (standardObject) => `SELECT CreatedDate, Action, Section, Display FROM SetupAuditTrail ` +
82
+ `WHERE Action = '${standardObject ? "deletedCF" : "deletedCFCustom"}' ORDER BY CreatedDate DESC`;
83
+ /** Salesforce's Deleted Fields retention. 15 days, then field and data are erased. */
84
+ export const UNDELETE_WINDOW_DAYS = 15;
85
+ const DAY_MS = 24 * 60 * 60 * 1000;
86
+ export class RollbackError extends Error {
87
+ code;
88
+ constructor(code, message) {
89
+ super(message);
90
+ this.code = code;
91
+ this.name = "RollbackError";
92
+ }
93
+ }
94
+ // ---------------------------------------------------------------------------------------------
95
+ // the deleted-set detector
96
+ // ---------------------------------------------------------------------------------------------
97
+ /**
98
+ * A Tooling row's qualified name, the way `listMetadata` would report it.
99
+ *
100
+ * Tooling's own `FullName` when the caller asked for it; otherwise reconstructed as
101
+ * `<object>.<ns>__<developerName>__c`. The namespace segment is the whole point — see the module
102
+ * header's refinement 1.
103
+ */
104
+ export function toolingFullName(objectName, row) {
105
+ if (row.fullName)
106
+ return row.fullName;
107
+ const ns = row.namespacePrefix ? `${row.namespacePrefix}__` : "";
108
+ return `${objectName}.${ns}${row.developerName}__c`;
109
+ }
110
+ /**
111
+ * `Tooling CustomField ∖ listMetadata CustomField` — the deleted-but-recoverable set (probe §3).
112
+ *
113
+ * Name-independent by construction, which is what makes it survive the re-delete collision that a
114
+ * `_del`-suffix scan cannot: the SET is decided by absence from `listMetadata`, and only the join
115
+ * back to a specific field (`matchDeletedField`) touches names at all.
116
+ *
117
+ * The reverse difference is expected to be empty and is returned rather than asserted: a non-empty
118
+ * `onlyLive` means Tooling did not see something `listMetadata` did, which is a fact about the read
119
+ * (paging, a scope mismatch) and a caller deciding how much to trust the answer wants to see it.
120
+ */
121
+ export function deletedFieldSet(args) {
122
+ const liveSet = new Set(args.live);
123
+ const toolingNames = args.tooling.map((row) => toolingFullName(args.objectName, row));
124
+ const toolingSet = new Set(toolingNames);
125
+ return {
126
+ deleted: toolingNames.filter((name) => !liveSet.has(name)).sort(),
127
+ onlyLive: args.live.filter((name) => !toolingSet.has(name)).sort()
128
+ };
129
+ }
130
+ // Salesforce's delete rename: `TEST_Region__c` -> `TEST_Region_del__c`, and a SECOND deleted
131
+ // generation of the same base name -> `TEST_Region_del1__c` (measured 2026-08-03; the probe had
132
+ // flagged this as unprobeable without a write). Anchored at both ends so `Foo_del` cannot match
133
+ // `Foo_delta`.
134
+ const deletedNameRe = (base) => new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_del\\d*__c$`);
135
+ /**
136
+ * The deleted-set members that are this field, post-rename.
137
+ *
138
+ * More than one is the re-delete collision and is RETURNED rather than resolved: two generations of
139
+ * one API name are two different fields with two different data sets, and picking one silently
140
+ * would card the operator with a Setup path that might restore the wrong one.
141
+ */
142
+ export function matchDeletedField(deleted, fullName) {
143
+ const dot = fullName.indexOf(".");
144
+ if (dot < 0)
145
+ return [];
146
+ const object = fullName.slice(0, dot);
147
+ const leaf = fullName.slice(dot + 1);
148
+ if (!leaf.endsWith("__c"))
149
+ return [];
150
+ const base = leaf.slice(0, -"__c".length);
151
+ const re = deletedNameRe(base);
152
+ return deleted.filter((name) => name.slice(0, dot) === object && re.test(name.slice(dot + 1))).sort();
153
+ }
154
+ // ---------------------------------------------------------------------------------------------
155
+ // the window
156
+ // ---------------------------------------------------------------------------------------------
157
+ const iso = (ms) => new Date(ms).toISOString();
158
+ const AUDIT_DISPLAY_PREFIX = "deleted custom field ";
159
+ const AUDIT_DISPLAY_OBJECT_SEP = " from ";
160
+ /**
161
+ * The LABEL an audit row names, lower-cased — or null when the row is not a field deletion.
162
+ *
163
+ * WHY NOT A SUBSTRING SEARCH. The join needle is a field label and `Display` is a whole sentence,
164
+ * so `display.includes(label)` matches any row whose label merely CONTAINS the needle: the label
165
+ * "Region" joins "Deleted custom field Sales Region Code" and dates one field's window from a
166
+ * different field's deletion. The rendering is fixed and documented (probe §4, trap 2), so the
167
+ * label is extracted from it and compared whole. Two shapes, because the action name differs by
168
+ * object kind: `deletedCF` ends at the label, `deletedCFCustom` appends ` from <Object Label>`.
169
+ * `lastIndexOf` on the separator, so a label that itself contains " from " keeps its tail.
170
+ *
171
+ * A row that does not carry the prefix contributes nothing rather than something plausible. The
172
+ * audit trail is corroboration only, and losing it costs an INFERRED term, not a class.
173
+ */
174
+ export function auditDisplayLabel(display) {
175
+ const text = display.trim().toLowerCase();
176
+ if (!text.startsWith(AUDIT_DISPLAY_PREFIX))
177
+ return null;
178
+ const rest = text.slice(AUDIT_DISPLAY_PREFIX.length).trim();
179
+ const sep = rest.lastIndexOf(AUDIT_DISPLAY_OBJECT_SEP);
180
+ const label = (sep < 0 ? rest : rest.slice(0, sep)).trim();
181
+ return label || null;
182
+ }
183
+ /**
184
+ * The audit row for this field, joined by LABEL and picked by NEARNESS to the wave's own clock.
185
+ *
186
+ * The probe had to take "the most recent row" because it had no bundle. A rollback usually does:
187
+ * the wave that issued the delete recorded when, so the right row is the one closest to that
188
+ * instant rather than the newest — which is what keeps an EARLIER deletion of a same-labelled field
189
+ * from claiming this field's window. Still inferred, because the join is still a label. When the
190
+ * bundle recorded NO instant (`bundleMs` NaN) there is nothing to be near, and this falls back to
191
+ * the probe's own heuristic: the first row, which the query orders newest-first.
192
+ */
193
+ function joinAuditRow(rows, label, bundleMs) {
194
+ if (!label)
195
+ return { matched: 0 };
196
+ const needle = label.trim().toLowerCase();
197
+ if (!needle)
198
+ return { matched: 0 };
199
+ const matches = rows.filter((row) => auditDisplayLabel(row.display) === needle);
200
+ if (matches.length === 0)
201
+ return { matched: 0 };
202
+ let best = matches[0];
203
+ let bestDistance = Number.POSITIVE_INFINITY;
204
+ for (const row of matches) {
205
+ const at = Date.parse(row.createdDate);
206
+ const distance = Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - bundleMs);
207
+ if (distance < bestDistance) {
208
+ best = row;
209
+ bestDistance = distance;
210
+ }
211
+ }
212
+ return { row: best, matched: matches.length };
213
+ }
214
+ /**
215
+ * The window for one deleted field: when it lapses, how much is left, and on whose word.
216
+ *
217
+ * The BUNDLE's mutation window is the primary term and the audit trail corroborates it, which
218
+ * inverts the probe's ranking for the one case the probe could not cover. When the two disagree by
219
+ * more than a day the audit row wins — Salesforce's own clock beats ours for a purge Salesforce
220
+ * performs — and the disagreement is reported so a reader can see which one moved the date.
221
+ *
222
+ * AND WITH NEITHER TERM THERE IS NO WINDOW. `basis: "UNVERIFIED"`, `lapsesAt` and `daysRemaining`
223
+ * withheld. The alternative — anchoring on `asOf` — manufactures a FULL 15-day window out of the
224
+ * moment this code happened to run and labels it `BUNDLE_WINDOW`, i.e. states as measured the one
225
+ * thing the bundle failed to record. That is the same error the probe's presence rule forbids on
226
+ * the other axis (a failed probe leaves presence UNVERIFIED, never absent), and it fails in the
227
+ * dangerous direction: a fabricated window is at its most generous exactly when the delete is
228
+ * oldest and least likely to still be recoverable.
229
+ */
230
+ export function undeleteWindowFor(args) {
231
+ const bundleMs = args.deletedAtBundle ? Date.parse(args.deletedAtBundle) : Number.NaN;
232
+ const auditMs = args.audit?.row ? Date.parse(args.audit.row.createdDate) : Number.NaN;
233
+ const usable = (value) => !Number.isNaN(value);
234
+ // The bundle's own clock unless the audit row disagrees materially. "Materially" is a day,
235
+ // because sub-day drift between an org's stamp and a laptop's is not a finding.
236
+ const preferAudit = usable(auditMs) && (!usable(bundleMs) || Math.abs(auditMs - bundleMs) > DAY_MS);
237
+ const anchor = preferAudit ? auditMs : bundleMs;
238
+ const base = {
239
+ ...(args.candidates[0] ? { toolingFullName: args.candidates[0] } : {}),
240
+ candidates: args.candidates,
241
+ ...(args.deletedAtBundle ? { deletedAtBundle: args.deletedAtBundle } : {}),
242
+ ...(args.audit?.row ? { deletedAtAudit: args.audit.row.createdDate } : {}),
243
+ auditRowsMatched: args.audit?.matched ?? 0,
244
+ presenceVerified: args.presenceVerified
245
+ };
246
+ if (!usable(anchor))
247
+ return { ...base, basis: "UNVERIFIED", daysRemaining: null };
248
+ const lapsesMs = anchor + UNDELETE_WINDOW_DAYS * DAY_MS;
249
+ return {
250
+ ...base,
251
+ lapsesAt: iso(lapsesMs),
252
+ basis: preferAudit ? "AUDIT_TRAIL_INFERRED" : "BUNDLE_WINDOW",
253
+ daysRemaining: Math.floor((lapsesMs - Date.parse(args.asOf)) / DAY_MS)
254
+ };
255
+ }
256
+ async function readJson(file) {
257
+ try {
258
+ return JSON.parse(await fs.readFile(file, "utf8"));
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
264
+ /**
265
+ * A relative path from a manifest, or null if it could escape the directory it is joined against.
266
+ *
267
+ * `orgRepo.ts`'s `safeRelPath` is the rule ("Manifest paths are org-derived — check them") and this
268
+ * is the same check: manifest `file` entries are written from ORG-SUPPLIED component names, so a
269
+ * bundle produced against a hostile or merely strange org must not be able to steer a read outside
270
+ * its own tree. Replicated rather than imported because `safeRelPath` is not exported and U9 does
271
+ * not edit `orgRepo.ts` — the same bounded duplication `canonicalizeSnapshot` documents.
272
+ *
273
+ * NULL RATHER THAN A THROW, because both callers here are reads whose failure already has a
274
+ * meaning: no snapshot, and no mirror match. Refusing a path degrades a comparison; it must not
275
+ * take down the classification of every other component in the wave.
276
+ */
277
+ function safeRelPath(rel) {
278
+ const normalized = path.normalize(rel).replace(/^[/\\]+/, "");
279
+ if (!normalized || normalized === "." || normalized.split(/[/\\]/).includes("..") || path.isAbsolute(normalized)) {
280
+ return null;
281
+ }
282
+ return normalized;
283
+ }
284
+ /**
285
+ * The bytes a component contributes to the mirror — orgRepo's `canonicalSnapshot`, mirrored here.
286
+ *
287
+ * NOT IMPORTED because it is not exported, and U9 does not edit `orgRepo.ts`. The duplication is
288
+ * deliberate and bounded by two things: `rollback.test.ts` commits a wave through the real
289
+ * `overlayAndCommit` and asserts this function reproduces the committed bytes, so a divergence
290
+ * fails a test rather than quietly mis-reporting drift; and every comparison below runs BOTH sides
291
+ * through this function, so agreement only requires the two to be semantically equal, not for this
292
+ * to match orgRepo byte for byte.
293
+ *
294
+ * `observedAt` goes because it is when we looked, not what we found; `errors` goes because it
295
+ * describes the read — except for an `INCOMPLETE_FACET` declaration, which is a fact about the
296
+ * DOCUMENT ("the facet named here is not in the state below") and must survive.
297
+ */
298
+ export function canonicalizeSnapshot(snapshot) {
299
+ const source = (snapshot ?? {});
300
+ const { observedAt: _observedAt, errors, ...rest } = source;
301
+ const declared = [...new Set(incompleteFacetsOf(Array.isArray(errors) ? errors : undefined))].sort();
302
+ const withMarker = declared.length > 0 ? { ...rest, incompleteFacets: declared } : rest;
303
+ return `${JSON.stringify(sortKeysDeep(withMarker), null, 2)}\n`;
304
+ }
305
+ /**
306
+ * The facets a snapshot's own `errors` array declares it could not read, deduped and sorted.
307
+ *
308
+ * The DOCUMENT's declaration, not a guess about it: a projection that hit a partial read says so
309
+ * (`layoutSnapshot.ts`'s `INCOMPLETE_FACET` prefix), and that declaration is what makes "the
310
+ * pre-image is complete" checkable instead of assumed. Silence is not a completeness claim — a
311
+ * facet nobody declared can still be missing — so an empty result means "nothing declared", which
312
+ * is why the gate it feeds is an ack rather than a promise.
313
+ */
314
+ function declaredIncompleteFacets(snapshot) {
315
+ const errors = snapshot?.errors;
316
+ if (!Array.isArray(errors))
317
+ return [];
318
+ return [...new Set(incompleteFacetsOf(errors))].sort();
319
+ }
320
+ const componentKey = (c) => `${c.typeName}:${c.fullName}`;
321
+ async function readSide(dir) {
322
+ const manifest = await readJson(path.join(dir, "projection.json"));
323
+ if (!manifest?.components)
324
+ return null;
325
+ const components = new Map();
326
+ for (const entry of manifest.components) {
327
+ if (typeof entry?.typeName !== "string" || typeof entry?.fullName !== "string")
328
+ continue;
329
+ const rel = typeof entry.file === "string" ? safeRelPath(entry.file) : null;
330
+ const snapshot = entry.found && rel ? await readJson(path.join(dir, rel)) : null;
331
+ components.set(componentKey(entry), { entry, snapshot });
332
+ }
333
+ return { ...(manifest.orgId ? { orgId: manifest.orgId } : {}), components };
334
+ }
335
+ /**
336
+ * Which components the bundle holds deployable bytes for, from `pre/source/`.
337
+ *
338
+ * Read by WALKING the tree and resolving each path back to its component
339
+ * (`resolveComponentFromRelPath`), never by predicting where the retrieve would have put a
340
+ * component. The retrieve is fail-open (U1) and can land partially, so the only honest answer to
341
+ * "is there an image" is "is there a file", and a forward prediction would answer a different
342
+ * question confidently.
343
+ *
344
+ * `preRoot` is the bundle dir for a single-attempt wave, or attempt 1's archive for a retried one —
345
+ * whichever `classifyRollback` resolved as the honest baseline. Never `bundleDir` unconditionally.
346
+ */
347
+ async function readPreImageIndex(preRoot) {
348
+ const root = path.join(preRoot, "pre", "source");
349
+ const out = new Map();
350
+ const walk = async (dir, prefix) => {
351
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
352
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
353
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
354
+ if (entry.isDirectory()) {
355
+ await walk(path.join(dir, entry.name), rel);
356
+ continue;
357
+ }
358
+ if (!entry.isFile())
359
+ continue;
360
+ const resolved = resolveComponentFromRelPath(rel);
361
+ if (!resolved)
362
+ continue;
363
+ const key = `${resolved.typeName}:${resolved.fullName}`;
364
+ const image = out.get(key) ?? { relPaths: [] };
365
+ // Meta first: a content-file type deploys its `-meta.xml` alongside its payload, and the
366
+ // meta is what names the component.
367
+ if (resolved.role === "meta")
368
+ image.relPaths.unshift(rel);
369
+ else
370
+ image.relPaths.push(rel);
371
+ out.set(key, image);
372
+ }
373
+ };
374
+ await walk(root, "");
375
+ return out;
376
+ }
377
+ // ---------------------------------------------------------------------------------------------
378
+ // irreversibility detection
379
+ // ---------------------------------------------------------------------------------------------
380
+ const metadataOf = (snapshot) => {
381
+ const state = snapshot?.state;
382
+ if (!state || typeof state !== "object")
383
+ return {};
384
+ const metadata = state.metadata;
385
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata)
386
+ ? metadata
387
+ : state;
388
+ };
389
+ const namesOf = (value, field = "fullName") => {
390
+ if (!Array.isArray(value))
391
+ return [];
392
+ return value
393
+ .map((entry) => entry?.[field])
394
+ .filter((name) => typeof name === "string");
395
+ };
396
+ /**
397
+ * The picklist value names a snapshot declares, across the three shapes that carry them.
398
+ *
399
+ * `CustomField` nests them under `valueSet.valueSetDefinition.value[]` (live shape, transcribed
400
+ * from `Account.PV_Risk_Tier__c`); `GlobalValueSet` uses `customValue[]`; `StandardValueSet` uses
401
+ * `standardValue[]`. Null — not an empty array — when the snapshot carries NO value set at all, so
402
+ * "this component is not a picklist" and "this picklist has no values" stay different answers.
403
+ */
404
+ export function picklistValues(snapshot) {
405
+ const metadata = metadataOf(snapshot);
406
+ const valueSet = metadata.valueSet;
407
+ if (valueSet && typeof valueSet === "object") {
408
+ const definition = valueSet.valueSetDefinition;
409
+ if (definition && typeof definition === "object")
410
+ return namesOf(definition.value).sort();
411
+ // A picklist inheriting a global value set names it rather than listing values; there is no
412
+ // per-field value set to compare and the global one is its own component.
413
+ if (typeof valueSet.valueSetName === "string")
414
+ return null;
415
+ if (Array.isArray(valueSet.values))
416
+ return [...valueSet.values].filter((v) => typeof v === "string").sort();
417
+ }
418
+ if (Array.isArray(metadata.customValue))
419
+ return namesOf(metadata.customValue).sort();
420
+ if (Array.isArray(metadata.standardValue))
421
+ return namesOf(metadata.standardValue).sort();
422
+ return null;
423
+ }
424
+ /** The custom-metadata-type accesses a Profile/PermissionSet snapshot grants. */
425
+ export function customMetadataAccesses(snapshot) {
426
+ const metadata = metadataOf(snapshot);
427
+ const raw = metadata.customMetadataTypeAccesses;
428
+ if (!Array.isArray(raw))
429
+ return null;
430
+ return raw
431
+ .map((entry) => {
432
+ const record = entry;
433
+ return typeof record?.name === "string" && record.enabled !== false ? record.name : null;
434
+ })
435
+ .filter((name) => typeof name === "string")
436
+ .sort();
437
+ }
438
+ const sameSet = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
439
+ /**
440
+ * Would undoing this modify require a removal the Metadata API cannot perform?
441
+ *
442
+ * See the module header for how R18's three irreversible rows are read. Only asked of a MODIFY: a
443
+ * create's rollback is a delete of the whole component (allowed for a picklist field, which is why
444
+ * a wave that creates a picklist is clean), and a delete's rollback adds values back inside a
445
+ * component that has to be re-created anyway.
446
+ */
447
+ export function irreversibleModify(pre, post) {
448
+ const preValues = picklistValues(pre);
449
+ const postValues = picklistValues(post);
450
+ if (preValues && postValues && !sameSet(preValues, postValues)) {
451
+ const removed = preValues.filter((v) => !postValues.includes(v));
452
+ const added = postValues.filter((v) => !preValues.includes(v));
453
+ if (added.length > 0 && removed.length > 0) {
454
+ return `this wave both added (${added.join(", ")}) and removed (${removed.join(", ")}) picklist values. Neither half is undoable: the Metadata API can only INACTIVATE a value, never remove one, and the records that carried a removed value lost it at removal — no deploy restores them.`;
455
+ }
456
+ if (added.length > 0) {
457
+ return `this wave added the picklist value(s) ${added.join(", ")}; undoing that means REMOVING them, and the Metadata API can only inactivate a picklist value, never remove it.`;
458
+ }
459
+ return `this wave removed the picklist value(s) ${removed.join(", ")}; re-adding the value is possible, but every record that carried it lost its value at removal and no deploy restores those.`;
460
+ }
461
+ const preAccess = customMetadataAccesses(pre);
462
+ const postAccess = customMetadataAccesses(post);
463
+ if (preAccess && postAccess) {
464
+ const granted = postAccess.filter((v) => !preAccess.includes(v));
465
+ if (granted.length > 0) {
466
+ return `this wave granted custom metadata type access (${granted.join(", ")}); undoing that means REMOVING the access, which the Metadata API cannot do — CMT access can be added to a profile, not removed.`;
467
+ }
468
+ }
469
+ return null;
470
+ }
471
+ /** Types whose rollback-of-a-create is a deletion the Metadata API refuses. */
472
+ const UNDELETABLE_TYPES = new Set(["RecordType"]);
473
+ /** The org-wide-default fields of a `CustomObject` — 2026-07-31-001 §2's last row. */
474
+ const SHARING_MODEL_KEYS = ["sharingModel", "externalSharingModel"];
475
+ /**
476
+ * Did this modify move an org-wide default, and what does undoing it set off?
477
+ *
478
+ * 2026-07-31-001 §2 CARRIES A ROW R18's four classes do not: *Org-wide defaults changed* → "Re-deploy
479
+ * prior sharing model" → "Slow recalculation; not atomic". Read only through the reversibility
480
+ * question that row is a plain CLEAN_REDEPLOY and disappears, which is the wrong answer to give an
481
+ * operator: the hazard is not that the rollback cannot be done, it is what the rollback DOES. Lowering
482
+ * an org-wide default makes Salesforce recalculate sharing for every affected record asynchronously,
483
+ * so the org sits partly on one model and partly on the other for as long as that takes — and the
484
+ * deploy reporting success is not the org having finished.
485
+ *
486
+ * A NOTE ON THE RESULT, NOT A CLASS OF ITS OWN — chosen deliberately. A class would say this change
487
+ * is a different KIND of reversible when it is not; what was missing is the ack, not the class.
488
+ */
489
+ export function sharingModelChange(pre, post) {
490
+ const before = metadataOf(pre);
491
+ const after = metadataOf(post);
492
+ const moved = SHARING_MODEL_KEYS.filter((key) => {
493
+ const from = before[key];
494
+ const to = after[key];
495
+ return typeof from === "string" && typeof to === "string" && from !== to;
496
+ });
497
+ if (moved.length === 0)
498
+ return null;
499
+ const detail = moved.map((key) => `${key} ${String(before[key])} → ${String(after[key])}`).join(", ");
500
+ return `this wave changed an org-wide default (${detail}). Rolling that back re-deploys the prior sharing model, and Salesforce then recalculates sharing for every affected record ASYNCHRONOUSLY: the change is slow and NOT ATOMIC, so the org sits partly on the old model and partly on the new one until the recalculation finishes — which the deploy's own success does not report.`;
501
+ }
502
+ // ---------------------------------------------------------------------------------------------
503
+ // the R16 preflight
504
+ // ---------------------------------------------------------------------------------------------
505
+ /**
506
+ * Does live state still match what this wave recorded, and if not, can this history explain it?
507
+ *
508
+ * ORDER MATTERS, and it is the order origin R16 states: the divergence is classified against MIRROR
509
+ * HISTORY FIRST. Live state matching this org's own later delivery is not an outside edit — it is
510
+ * the operator being about to revert past a wave, and the ack card has to name that wave rather
511
+ * than accuse a client admin of changing something.
512
+ *
513
+ * COMPARABILITY IS ASKED BEFORE EITHER (PV Arm B, `snapshotComparability.ts`). A months-old recorded
514
+ * post-image and a snapshot read today can be the work of two different readers, and their
515
+ * difference is then not evidence about the org in either direction. A rollback preflight is the
516
+ * worst possible place to learn that lesson twice: the finding there would be "someone changed this,
517
+ * ack before I overwrite it" about a component nobody touched.
518
+ *
519
+ * THE MIRROR IS CONSULTED AT ITS WORKING TREE, not commit by commit. The tree equals HEAD after
520
+ * every overlay, so `projection/<file>` IS the newest recorded state — one file read instead of a
521
+ * walk. The bound that buys, stated rather than hidden: live state matching an INTERMEDIATE recorded
522
+ * delivery (later than the target, older than HEAD) reads as an external change. That is the
523
+ * conservative direction — it asks for an ack instead of granting one — and it can only arise when
524
+ * something outside this history has already moved the component back. A second bound of the same
525
+ * kind and the same direction: naming the LATER waves reads only the newest
526
+ * `ATTRIBUTION_COMMIT_LIMIT` mirror commits, so in a history longer than that a divergence whose
527
+ * explanation lies further back is worded as an external change rather than attributed — and when
528
+ * the read reaches that bound, `classifyRollback` says so in the plan's notes.
529
+ */
530
+ export async function preflightDivergence(args) {
531
+ const key = componentKey(args.component);
532
+ const recorded = args.recordedPost === null ? null : canonicalizeSnapshot(args.recordedPost);
533
+ const live = args.live === null ? null : canonicalizeSnapshot(args.live);
534
+ if (recorded === live)
535
+ return null;
536
+ if (recorded !== null && live !== null) {
537
+ const verdict = assessComparability({ recorded, observed: live });
538
+ if (verdict.kind !== "COMPARABLE") {
539
+ return {
540
+ kind: "NOT_COMPARABLE",
541
+ detail: verdict.kind === "READER_CHANGED"
542
+ ? `${key}: the recorded post-image and today's read were produced by different readers, so their difference is not evidence about the org. Nothing here says whether ${key} changed.`
543
+ : `${key}: every difference falls inside a facet one of the two reads declared it could not read (${verdict.facets.join(", ")}). Not evidence about the org.`,
544
+ ...(verdict.kind === "READER_CHANGED"
545
+ ? { readers: { recorded: readerLabel(verdict.recorded), observed: readerLabel(verdict.observed) } }
546
+ : {}),
547
+ differingKeys: verdict.differingKeys
548
+ };
549
+ }
550
+ }
551
+ const later = await laterWavesFor(args.orgId, key, args.target, args.root, args.notes);
552
+ const explained = await matchesMirrorHead(args.orgId, args.component, live, args.root);
553
+ if (explained && later.length > 0) {
554
+ return {
555
+ kind: "REVERTING_PAST_WAVE",
556
+ detail: `${key} no longer matches what wave ${args.target.wave} left, and this org's own history accounts for it: live state is what ${later[0]} delivered. Rolling this component back reverts past ${later.join(", ")}.`,
557
+ laterWaves: later
558
+ };
559
+ }
560
+ const present = live === null ? "is absent from the org" : "does not match the recorded post-image";
561
+ return {
562
+ kind: "EXTERNAL_CHANGE",
563
+ detail: `${key} ${present}, and nothing in this org's history accounts for the difference — it changed outside this history since wave ${args.target.wave} touched it. Rolling it back would overwrite that change.`
564
+ };
565
+ }
566
+ const readerLabel = (id) => `${id.family}|${id.source}`;
567
+ /**
568
+ * How many mirror commits divergence attribution reads. A BOUND ON THE ANSWER, so it is named.
569
+ *
570
+ * `deliveredComponents` caps at `--max-count`, which counts COMMITS, not components — one busy
571
+ * wave can spend many rows of it. Past the cap the attribution simply does not see the delivery
572
+ * that explains a divergence, which is the conservative direction (an unnamed wave becomes an ack
573
+ * card, never a silent proceed) but is still an answer shaped by a limit rather than by the org.
574
+ */
575
+ const ATTRIBUTION_COMMIT_LIMIT = 500;
576
+ /**
577
+ * `<sessionId>/<wave>` for every commit that touched this component AFTER the target wave.
578
+ *
579
+ * `deliveredComponents` rather than `historyLog({component})`, and the difference is identity.
580
+ * `historyLog`'s component filter is a PATH GLOB, so it answers "which commits touched a file whose
581
+ * name looks like this" — on the live mirror it matches through the `source/` deploy log, which
582
+ * keeps a DELETED component's last bytes and would therefore report a delete as a later delivery.
583
+ * `deliveredComponents` reads each committed blob's own `typeName`/`fullName` and reports removals
584
+ * as removals, which is the question being asked here.
585
+ */
586
+ async function laterWavesFor(orgId, component, target, root, notes) {
587
+ let delivered;
588
+ try {
589
+ delivered = await deliveredComponents({ orgId, limit: ATTRIBUTION_COMMIT_LIMIT, ...(root ? { root } : {}) });
590
+ }
591
+ catch {
592
+ // No history, or git could not answer. An unproven attribution is not an attribution, and the
593
+ // caller's fallback ("external change") is the conservative one.
594
+ return [];
595
+ }
596
+ if (notes && new Set(delivered.map((entry) => entry.sha)).size >= ATTRIBUTION_COMMIT_LIMIT) {
597
+ const note = `divergence attribution read the newest ${ATTRIBUTION_COMMIT_LIMIT} mirror commits and that bound was reached, so a divergence explained by a delivery older than those is worded as an external change rather than named.`;
598
+ if (!notes.includes(note))
599
+ notes.push(note);
600
+ }
601
+ // Newest commit first, so everything before the target's own entry is later than it.
602
+ const mine = delivered.filter((entry) => entry.key === component);
603
+ const index = mine.findIndex((entry) => entry.sessionId === target.sessionId && entry.wave === target.wave);
604
+ const later = index < 0 ? [] : mine.slice(0, index);
605
+ const seen = new Set();
606
+ return later
607
+ .map((entry) => `${entry.sessionId ?? "?"}/${entry.wave ?? "?"}`)
608
+ .filter((label) => (seen.has(label) ? false : (seen.add(label), true)));
609
+ }
610
+ /** Does live state equal the mirror's newest recorded state for this component? */
611
+ async function matchesMirrorHead(orgId, component, live, root) {
612
+ if (!component.file)
613
+ return false;
614
+ const rel = safeRelPath(component.file);
615
+ // A manifest path that could escape the mirror is not read at all. `false` is the conservative
616
+ // answer: it costs an ack card, where a successful escape would read an arbitrary file.
617
+ if (!rel)
618
+ return false;
619
+ const file = path.join(mirrorPath(orgId, root), "projection", rel);
620
+ const recorded = await fs.readFile(file, "utf8").catch(() => null);
621
+ // A component the mirror removed has no file: its recorded state is ABSENCE, which live absence
622
+ // matches and live presence does not.
623
+ if (recorded === null)
624
+ return live === null;
625
+ if (live === null)
626
+ return false;
627
+ // Both sides re-canonicalized, so this survives a serialization change in the materializer.
628
+ try {
629
+ return canonicalizeSnapshot(JSON.parse(recorded)) === live;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ }
635
+ /**
636
+ * Classify every rollback candidate in one wave.
637
+ *
638
+ * The wave is addressed by its BUNDLE, which is the rebuild-stable identity (R17): session id and
639
+ * wave label come from the directory, never from a commit SHA, because any rebuild rewrites those.
640
+ */
641
+ export async function classifyRollback(input) {
642
+ const bundleDir = path.resolve(input.bundleDir);
643
+ // Only the pre-image moves. See the field doc on `ClassifyInput.preImageRoot` for why.
644
+ const preRoot = input.preImageRoot ? path.resolve(input.preImageRoot) : bundleDir;
645
+ const wave = path.basename(bundleDir);
646
+ const sessionId = path.basename(path.resolve(bundleDir, "..", ".."));
647
+ const asOf = input.asOf ?? new Date().toISOString();
648
+ const notes = [];
649
+ const verdictFile = await readJson(path.join(bundleDir, "verdict.json"));
650
+ const verdict = verdictFile?.verdict;
651
+ if (!verdict) {
652
+ throw new RollbackError("BUNDLE_UNREADABLE", `${bundleDir} has no readable verdict.json — nothing here says what this wave did, so nothing can be classified.`);
653
+ }
654
+ if (!STATE_CHANGING.has(verdict)) {
655
+ throw new RollbackError("NOT_STATE_CHANGING", `wave ${wave} ended ${verdict}, which changed nothing in the org. There is nothing to roll back, and rolling back a wave that did not land would be a change of its own.`);
656
+ }
657
+ if (verdict === "AMBIGUOUS_DELIVERY") {
658
+ notes.push(`wave ${wave} ended AMBIGUOUS_DELIVERY: its post-image covers only the components the run actually observed, so a component missing below is UNKNOWN rather than untouched.`);
659
+ }
660
+ const request = await readJson(path.join(bundleDir, "request.json"));
661
+ const pre = await readSide(path.join(preRoot, "pre"));
662
+ const post = await readSide(path.join(bundleDir, "post"));
663
+ if (!request || !pre) {
664
+ throw new RollbackError("BUNDLE_UNREADABLE", `${bundleDir} is missing request.json, or ${preRoot} is missing pre/projection.json. Both are written before the org is read at all, so their absence means a corrupt bundle and guessing at its scope would be guessing at what to change in a live org.`);
665
+ }
666
+ const window = await readJson(path.join(bundleDir, "mutation-window.json"));
667
+ const deletedAtBundle = window?.start;
668
+ if (!deletedAtBundle) {
669
+ notes.push(`wave ${wave} records no mutation window, so any undelete window below rests on the audit trail alone (inferred) or on nothing at all.`);
670
+ }
671
+ const images = await readPreImageIndex(preRoot);
672
+ let candidates = candidatesOf(request);
673
+ // A SHRINKING-SCOPE RETRY: attempt 1 delivered FieldA + FieldB, attempt 2 re-applied only FieldB.
674
+ // The rollback's pre-image comes from attempt 1 (preRoot !== bundleDir means the baseline was
675
+ // resolved to an archive), but candidates so far come from the LAST attempt's request.json alone
676
+ // — which no longer names FieldA. Left unfixed, FieldA is never classified and survives the
677
+ // rollback untouched, while the note this tool prints claims the org is restored to before the
678
+ // wave first changed it. So: union in every CONTRIBUTING archived attempt's own request.json
679
+ // too (see archivedContributingAttempts for which attempts qualify — one that landed nothing
680
+ // must not seed phantoms into an executable plan), and a component only an EARLIER attempt
681
+ // named is classified against the baseline pre-image exactly like any other candidate. The
682
+ // current request's declared operation wins on overlap (it is the most recent statement of
683
+ // intent for that component); earlier attempts only ever ADD candidates — and the plan says
684
+ // which ones they added, because a union row an operator cannot attribute is indistinguishable
685
+ // from this wave's own scope.
686
+ if (preRoot !== bundleDir) {
687
+ const earlier = [];
688
+ const sourceAttempt = new Map();
689
+ for (const { attempt, request: attemptRequest } of await archivedContributingAttempts(bundleDir)) {
690
+ for (const candidate of candidatesOf(attemptRequest)) {
691
+ earlier.push(candidate);
692
+ const key = `${candidate.typeName}:${candidate.fullName}`;
693
+ if (!sourceAttempt.has(key))
694
+ sourceAttempt.set(key, attempt);
695
+ }
696
+ }
697
+ const ownScope = new Set(candidates.map((c) => `${c.typeName}:${c.fullName}`));
698
+ candidates = unionCandidates(candidates, earlier);
699
+ const added = candidates.filter((c) => !ownScope.has(`${c.typeName}:${c.fullName}`));
700
+ if (added.length > 0) {
701
+ notes.push(`in scope from an EARLIER attempt's request, not this bundle's own: ${added
702
+ .map((c) => `${componentKey(c)} (attempt ${sourceAttempt.get(`${c.typeName}:${c.fullName}`)})`)
703
+ .sort()
704
+ .join(", ")}.`);
705
+ }
706
+ }
707
+ if (candidates.length === 0) {
708
+ throw new RollbackError("NO_CANDIDATES", `wave ${wave}'s request names no components to roll back.`);
709
+ }
710
+ const selected = input.components ? new Set(input.components) : null;
711
+ const skipped = selected ? [...selected].filter((key) => !candidates.some((c) => componentKey(c) === key)) : [];
712
+ if (skipped.length > 0) {
713
+ notes.push(`not part of wave ${wave} and therefore not classified: ${skipped.sort().join(", ")}.`);
714
+ }
715
+ const probeLedger = newProbeLedger();
716
+ const components = [];
717
+ for (const candidate of candidates) {
718
+ const key = componentKey(candidate);
719
+ if (selected && !selected.has(key))
720
+ continue;
721
+ components.push(await classifyOne({
722
+ candidate,
723
+ key,
724
+ wave,
725
+ sessionId,
726
+ orgId: input.orgId,
727
+ asOf,
728
+ pre,
729
+ post,
730
+ images,
731
+ ...(deletedAtBundle ? { deletedAtBundle } : {}),
732
+ ...(input.probe ? { probe: input.probe } : {}),
733
+ probeLedger,
734
+ notes,
735
+ ...(input.live ? { live: input.live } : {}),
736
+ ...(input.root ? { root: input.root } : {})
737
+ }));
738
+ }
739
+ components.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
740
+ // ROLLING BACK A ROLLBACK IS A NAMED SITUATION, NEVER ONE SILENT STEP (G12). A rollback bundle
741
+ // is an ordinary bundle, so it classifies like any wave — and until this, nothing read the
742
+ // `revertOf` its request carries, so undoing `revert-w1` looked identical to undoing a delivery.
743
+ // It is not: its "pre-image" is the org mid-rollback, and executing against it re-applies the
744
+ // very state the first rollback was approved to remove — the operator is un-approving a decision,
745
+ // and the plan must say which one. The class stays whatever the evidence says; the GATE degrades
746
+ // to ACK_REQUIRED per component, because "proceed silently" is the one wrong answer. Semantics
747
+ // beyond the ack (a true un-revert) are deliberately not built here.
748
+ if (request.revertOf) {
749
+ const target = `${request.revertOf.sessionId}/${request.revertOf.wave}`;
750
+ notes.push(`wave ${wave} was itself a ROLLBACK — it reverted ${target}. Undoing it re-applies what that rollback was approved to remove, and its pre-image is the org as it stood mid-rollback, not a delivery baseline. Every component below requires an explicit ack for that reason.`);
751
+ const reason = `${wave} was a rollback of ${target}; undoing it re-applies what that rollback removed. Acknowledge only if un-doing the rollback is the intent.`;
752
+ for (let i = 0; i < components.length; i++) {
753
+ const component = components[i];
754
+ // The three terminal gates hold their ground for the same reasons `withDivergence` gives:
755
+ // REFUSED has nothing an ack could release, NO_ACTION nothing to execute, OPERATOR_ACTION's
756
+ // deploy is the harm. Everything else — including PROCEEDS — now needs the operator to say
757
+ // "yes, undo the rollback" with the target named.
758
+ if (component.gate === "REFUSED" || component.gate === "NO_ACTION" || component.gate === "OPERATOR_ACTION")
759
+ continue;
760
+ components[i] = { ...component, gate: "ACK_REQUIRED", ackReasons: [...component.ackReasons, reason] };
761
+ }
762
+ }
763
+ return { sessionId, wave, orgId: input.orgId, asOf, components, notes, probe: probeOutcome(probeLedger, Boolean(input.probe)) };
764
+ }
765
+ /** Mirrors orgRepo's own set — the verdicts that changed the org and can therefore be undone. */
766
+ const STATE_CHANGING = new Set(["DIFF_VERIFIED", "DELIVERED_WITH_DRIFT", "AMBIGUOUS_DELIVERY"]);
767
+ /**
768
+ * What the wave named, from its own request.
769
+ *
770
+ * The request's TOP-LEVEL entries, deliberately un-expanded. `orgRepo`'s `touchedKeys` expands a
771
+ * container into the children a deploy produced, because the mirror records component STATE; a
772
+ * rollback redeploys FILES, and the file is the container. Expanding here would offer the operator
773
+ * a child that cannot be deployed on its own.
774
+ */
775
+ function candidatesOf(request) {
776
+ const out = [];
777
+ const seen = new Set();
778
+ const add = (typeName, fullName, deleted) => {
779
+ const key = `${typeName}:${fullName}`;
780
+ if (seen.has(key))
781
+ return;
782
+ seen.add(key);
783
+ out.push({ typeName, fullName, deleted });
784
+ };
785
+ for (const item of request.deletes ?? []) {
786
+ if (typeof item?.typeName === "string" && typeof item?.fullName === "string")
787
+ add(item.typeName, item.fullName, true);
788
+ }
789
+ for (const item of request.edits ?? []) {
790
+ if (typeof item?.typeName === "string" && typeof item?.fullName === "string")
791
+ add(item.typeName, item.fullName, false);
792
+ }
793
+ for (const facet of request.facetUpdates ?? []) {
794
+ if (typeof facet?.objectApiName === "string")
795
+ add("CustomObject", facet.objectApiName, false);
796
+ }
797
+ return out;
798
+ }
799
+ /**
800
+ * The archived attempts whose requests can still bind components into this wave's blast radius.
801
+ *
802
+ * An archived attempt speaks for org state only if it may have LANDED something. The canonical
803
+ * same-label retry is attempt 1 `REJECTED_BY_ORG` — an attempt whose request names every component
804
+ * it meant to change and whose deploy changed none of them. Unioning that request into a rollback
805
+ * plan resurrects phantoms: a delete it tried and failed comes back through the deleted-candidate
806
+ * path as a re-create, one ack away from redeploying a stale image over a live component. So:
807
+ *
808
+ * - an attempt whose archive manifest proves it never wrote pre/ never reached the deploy that
809
+ * follows the pre-write — a proven no-op, skipped;
810
+ * - an attempt with an explicit non-state-changing verdict landed nothing — skipped;
811
+ * - an attempt with a pre-image but NO verdict died somewhere between deploy and verdict-write —
812
+ * the same cannot-rule-it-out territory as AMBIGUOUS_DELIVERY, so it stays in.
813
+ *
814
+ * Ordered numerically, not lexicographically: attempts/10 sorts after attempts/2, and
815
+ * first-occurrence-wins downstream makes the order load-bearing.
816
+ *
817
+ * Shared with the batch report (`batchReport.ts`), which must answer "did any attempt of this wave
818
+ * touch existing state?" from the same set of requests this plan draws its scope from.
819
+ */
820
+ export async function archivedContributingAttempts(bundleDir) {
821
+ const attemptsDir = path.join(bundleDir, "attempts");
822
+ const entries = await fs.readdir(attemptsDir, { withFileTypes: true }).catch(() => []);
823
+ const out = [];
824
+ const numbered = entries
825
+ .filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
826
+ .sort((a, b) => Number(a.name) - Number(b.name));
827
+ for (const entry of numbered) {
828
+ const dir = path.join(attemptsDir, entry.name);
829
+ const archive = await readJson(path.join(dir, "archive.json"));
830
+ if (archive?.archived && archive.archived["pre"] === false)
831
+ continue;
832
+ const verdict = (await readJson(path.join(dir, "verdict.json")))?.verdict;
833
+ if (verdict && !STATE_CHANGING.has(verdict))
834
+ continue;
835
+ const request = await readJson(path.join(dir, "request.json"));
836
+ if (request)
837
+ out.push({ attempt: entry.name, request });
838
+ }
839
+ return out;
840
+ }
841
+ /**
842
+ * Merge candidate lists, first occurrence wins — the same identity `candidatesOf` dedupes on
843
+ * (`typeName:fullName`). Used to union a shrinking-scope retry's earlier-attempt candidates in
844
+ * behind the current request's: see the call site in `classifyRollback`.
845
+ */
846
+ function unionCandidates(...lists) {
847
+ const out = [];
848
+ const seen = new Set();
849
+ for (const list of lists) {
850
+ for (const candidate of list) {
851
+ const key = `${candidate.typeName}:${candidate.fullName}`;
852
+ if (seen.has(key))
853
+ continue;
854
+ seen.add(key);
855
+ out.push(candidate);
856
+ }
857
+ }
858
+ return out;
859
+ }
860
+ /**
861
+ * Did this component exist before the wave?
862
+ *
863
+ * Asked of the PRE-PROJECTION MANIFEST, not of the request's declared intent — the same measurement
864
+ * `preImageTargets` makes, and for the same reason `orgRepo`'s `touchedKeys` gives for ignoring
865
+ * `intent`: a mis-declared entry must not get to decide what happened, and the org's own answer is
866
+ * on disk.
867
+ *
868
+ * A component the manifest does not mention at all falls to MODIFY. The two wrong answers are not
869
+ * symmetric: a create mistaken for a modify offers a redeploy that is gated on an image and blocked
870
+ * without one, while a modify mistaken for a create offers to DELETE a component that predates the
871
+ * wave. Doubt fails towards the harmless one.
872
+ */
873
+ function operationOf(candidate, pre) {
874
+ if (candidate.deleted)
875
+ return { operation: "delete", measured: true };
876
+ const entry = pre.get(componentKey(candidate));
877
+ if (!entry)
878
+ return { operation: "modify", measured: false };
879
+ return { operation: entry.entry.found ? "modify" : "create", measured: true };
880
+ }
881
+ async function probeObject(probe, objectName) {
882
+ try {
883
+ const [tooling, live, audit] = await Promise.all([
884
+ probe.toolingCustomFields(objectName),
885
+ probe.listCustomFields(objectName),
886
+ probe.fieldDeleteAudit(objectName).catch(() => [])
887
+ ]);
888
+ return { ...deletedFieldSet({ objectName, tooling, live }), live, audit };
889
+ }
890
+ catch (error) {
891
+ // A probe that could not run leaves presence UNVERIFIED — never "absent". The difference is the
892
+ // whole point: absence means the field was purged and the data is gone, and asserting that on a
893
+ // network error would tell an operator to accept data loss they do not have.
894
+ return { deleted: [], live: [], onlyLive: [], audit: [], failed: error instanceof Error ? error.message : String(error) };
895
+ }
896
+ }
897
+ const newProbeLedger = () => ({
898
+ cache: new Map(),
899
+ asked: new Set(),
900
+ failures: new Map(),
901
+ presence: new Map()
902
+ });
903
+ /**
904
+ * A re-check's ledger, pre-loaded with what the classification already answered.
905
+ *
906
+ * SEEDED RATHER THAN MERGED AFTERWARDS. The re-check visits only `UNDELETE_WINDOW` components, so
907
+ * the rest keep the classification's answer and there is nothing to recompute for them — and the
908
+ * "later pass wins" rule then lives in exactly one place (the ledger's own writes) instead of being
909
+ * restated by a merge function that has to re-derive it for four fields.
910
+ *
911
+ * `cache` is deliberately left EMPTY: the whole point of the re-check is to ask the org again.
912
+ */
913
+ function seedProbeLedger(outcome) {
914
+ const ledger = newProbeLedger();
915
+ for (const objectName of outcome.objects)
916
+ ledger.asked.add(objectName);
917
+ for (const failure of outcome.failures)
918
+ ledger.failures.set(failure.objectName, failure.error);
919
+ for (const key of outcome.verified)
920
+ ledger.presence.set(key, null);
921
+ for (const entry of outcome.unverified)
922
+ ledger.presence.set(entry.key, entry.reason);
923
+ return ledger;
924
+ }
925
+ async function askProbe(ledger, probe, objectName) {
926
+ ledger.asked.add(objectName);
927
+ let pending = ledger.cache.get(objectName);
928
+ if (!pending) {
929
+ pending = probeObject(probe, objectName);
930
+ ledger.cache.set(objectName, pending);
931
+ }
932
+ const probed = await pending;
933
+ // LAST ANSWER WINS PER OBJECT, IN BOTH DIRECTIONS. The delete is what makes the seeded case sound:
934
+ // a failure this pass answered must not survive as a failure, or an execute whose token was
935
+ // refreshed since the plan would still report the plan's expired-token error.
936
+ if (probed.failed)
937
+ ledger.failures.set(objectName, probed.failed);
938
+ else
939
+ ledger.failures.delete(objectName);
940
+ return probed;
941
+ }
942
+ /** Why a component's presence went unanswered — the reason recorded against the component itself. */
943
+ const unansweredBecause = (probe, probed) => probed?.failed ??
944
+ (probe
945
+ ? "the probe was never asked about it — this component's name carries no object to look a Deleted Fields list up by"
946
+ : "no live org probe was supplied");
947
+ function probeOutcome(ledger, supplied) {
948
+ const verified = [];
949
+ const unverified = [];
950
+ for (const key of [...ledger.presence.keys()].sort()) {
951
+ const reason = ledger.presence.get(key);
952
+ if (reason === null || reason === undefined)
953
+ verified.push(key);
954
+ else
955
+ unverified.push({ key, reason });
956
+ }
957
+ const objects = [...ledger.asked].sort();
958
+ const failures = objects
959
+ .filter((objectName) => ledger.failures.has(objectName))
960
+ .map((objectName) => ({ objectName, error: ledger.failures.get(objectName) ?? "" }));
961
+ const status = !supplied
962
+ ? "NOT_ATTEMPTED"
963
+ : objects.length === 0
964
+ ? "NOT_NEEDED"
965
+ : verified.length === 0
966
+ ? "FAILED"
967
+ : unverified.length > 0 || failures.length > 0
968
+ ? "PARTIAL"
969
+ : "MEASURED";
970
+ return { status, objects, failures, verified, unverified };
971
+ }
972
+ async function classifyOne(args) {
973
+ const { candidate, key } = args;
974
+ const { operation, measured } = operationOf(candidate, args.pre.components);
975
+ const evidence = [
976
+ {
977
+ statement: measured
978
+ ? `wave ${args.wave} ${operation === "delete" ? "deleted" : operation === "create" ? "created" : "changed"} ${key}; the pre-projection ${operation === "create" ? "did not find it" : "found it"}.`
979
+ : `wave ${args.wave} edited ${key}, and its pre-projection does not mention the component. Treated as a MODIFY, which is the direction that cannot offer to delete something the wave did not create.`,
980
+ basis: measured ? "MEASURED" : "INFERRED"
981
+ }
982
+ ];
983
+ const image = args.images.get(key);
984
+ const preSnapshot = args.pre.components.get(key)?.snapshot ?? null;
985
+ const postSnapshot = args.post?.components.get(key)?.snapshot ?? null;
986
+ // R18's CLEAN_REDEPLOY row is conditioned on the pre-image being COMPLETE, and completeness is a
987
+ // SECOND question from existence. `readPreImageIndex` answers "is there a file", which the
988
+ // fail-open retrieve makes the only honest existence test — but a component whose projection
989
+ // declared `INCOMPLETE_FACET` was read partially, and on a REPLACE type deploying that image
990
+ // drops whatever the unread facet held while the deploy reports success. The class does not move
991
+ // (the CHANGE is still cleanly reversible); the GATE does, which is the distinction this module
992
+ // is built on.
993
+ const incompleteFacets = image ? declaredIncompleteFacets(preSnapshot) : [];
994
+ const incompleteAck = incompleteFacets.length > 0
995
+ ? `the pre-image for ${key} declares it could not read ${incompleteFacets.join(", ")}, so it is not a complete image of what was there. Redeploying it restores everything else and, on a replace type, DROPS what ${incompleteFacets.length > 1 ? "those facets" : "that facet"} held — while the deploy reports success.`
996
+ : null;
997
+ if (incompleteAck)
998
+ evidence.push({ statement: incompleteAck, basis: "MEASURED" });
999
+ const base = {
1000
+ key,
1001
+ typeName: candidate.typeName,
1002
+ fullName: candidate.fullName,
1003
+ operation,
1004
+ ...(image ? { image } : {}),
1005
+ ackReasons: [],
1006
+ evidence
1007
+ };
1008
+ const divergence = await divergenceFor(args, operation);
1009
+ if (divergence)
1010
+ evidence.push({ statement: divergence.detail, basis: "MEASURED" });
1011
+ const withDivergence = (component) => {
1012
+ if (!divergence)
1013
+ return component;
1014
+ if (divergence.kind === "NOT_COMPARABLE")
1015
+ return { ...component, divergence };
1016
+ // R16: divergence blocks PER COMPONENT pending explicit ack, whatever the class says. THREE
1017
+ // gates are already terminal and an ack cannot move them, so putting an approval in front of one
1018
+ // would be theatre — or worse:
1019
+ //
1020
+ // REFUSED an irreversible component has no rollback for an ack to release.
1021
+ // NO_ACTION a no-op has nothing to execute.
1022
+ // OPERATOR_ACTION an in-window deleted field leaves through SETUP, not through a deploy. No
1023
+ // ack can buy a deploy here, because the deploy is the harm (AE4).
1024
+ //
1025
+ // THE THIRD WAS MISSING, and its absence was not theatre — it broke AE4. Rewriting
1026
+ // OPERATOR_ACTION to ACK_REQUIRED left `class` at UNDELETE_WINDOW while handing the component an
1027
+ // ack token, and U10 dispatches on the GATE: once acked it reached the deploy and re-created the
1028
+ // field from the pre-image — every value every record held destroyed, while the recoverable
1029
+ // generation sat in Setup's Deleted Fields list and the deploy reported success.
1030
+ //
1031
+ // The trigger is ORDINARY, not exotic: the delete FREES the original API name (measured — see
1032
+ // U10's POST_UNDELETE_CHECKLIST item 1), so a later wave or an admin re-creating a field under
1033
+ // that name is live state disagreeing with a recorded post-image of ABSENCE, which is exactly
1034
+ // what this preflight is built to find. `liveAgain` does not absorb it either, since that needs
1035
+ // an EMPTY deleted set and this field is still in one.
1036
+ const gate = component.gate === "REFUSED" || component.gate === "NO_ACTION" || component.gate === "OPERATOR_ACTION"
1037
+ ? component.gate
1038
+ : "ACK_REQUIRED";
1039
+ return { ...component, divergence, gate, ackReasons: [...component.ackReasons, divergence.detail] };
1040
+ };
1041
+ if (operation === "create") {
1042
+ if (UNDELETABLE_TYPES.has(candidate.typeName)) {
1043
+ return withDivergence({
1044
+ ...base,
1045
+ class: "IRREVERSIBLE",
1046
+ gate: "REFUSED",
1047
+ action: `no rollback: undoing the creation of ${key} means deleting it.`,
1048
+ limitation: `the Metadata API cannot delete a ${candidate.typeName} — "Cannot delete record type through API". Deactivating it in Setup is the nearest available action and is the operator's to take.`
1049
+ });
1050
+ }
1051
+ return withDivergence({
1052
+ ...base,
1053
+ class: "CLEAN_DELETE",
1054
+ gate: "PROCEEDS",
1055
+ action: `delete ${key}, which wave ${args.wave} created.`
1056
+ });
1057
+ }
1058
+ if (operation === "modify") {
1059
+ const limitation = irreversibleModify(preSnapshot, postSnapshot);
1060
+ if (limitation) {
1061
+ return withDivergence({
1062
+ ...base,
1063
+ class: "IRREVERSIBLE",
1064
+ gate: "REFUSED",
1065
+ action: `no rollback for ${key}.`,
1066
+ limitation
1067
+ });
1068
+ }
1069
+ const sharing = sharingModelChange(preSnapshot, postSnapshot);
1070
+ if (sharing)
1071
+ evidence.push({ statement: sharing, basis: "MEASURED" });
1072
+ // AN UNRUN CHECK IS A STATED BOUND, NOT SILENCE. Both detectors above compare pre against
1073
+ // post, and `metadataOf(null)` is `{}` — so with no post snapshot every guard short-circuits
1074
+ // and returns null, which the code below used to read as "nothing irreversible happened".
1075
+ // That handed a picklist edit on an AMBIGUOUS_DELIVERY wave an unconditional PROCEEDS: the
1076
+ // detectors go quiet in exactly the runs where org state is least certain (gap G1, confirmed
1077
+ // by rollbackGaps.test.ts 2026-08-18). The class stays CLEAN_REDEPLOY — the pre-image and the
1078
+ // redeploy are fine — but PROCEEDS is a certification this plan cannot issue, so the gate
1079
+ // degrades to ACK_REQUIRED with the reason on the component, where its reader is. The
1080
+ // plan-level AMBIGUOUS_DELIVERY note cannot carry this: it says a MISSING component is
1081
+ // unknown, and this component is present and would otherwise read as measured-clean.
1082
+ const unknownPost = postSnapshot === null
1083
+ ? `no post-image exists for ${key}, so the irreversibility checks never ran: whether this edit added a picklist value (unremovable through the Metadata API), granted custom-metadata access, or moved an org-wide default was not established. Unknown is not clean — acknowledge to redeploy the pre-image anyway.`
1084
+ : null;
1085
+ const acks = [incompleteAck, sharing, unknownPost].filter((reason) => reason !== null);
1086
+ return withDivergence({
1087
+ ...base,
1088
+ class: "CLEAN_REDEPLOY",
1089
+ gate: image ? (acks.length > 0 ? "ACK_REQUIRED" : "PROCEEDS") : "BLOCKED_NO_IMAGE",
1090
+ ackReasons: acks,
1091
+ action: image
1092
+ ? `redeploy ${key} from the wave's pre-image (${image.relPaths.join(", ")}).${incompleteAck ? " The image is INCOMPLETE — see the ack." : ""}${sharing ? " Re-deploying an org-wide default is slow and not atomic — see the ack." : ""}${unknownPost ? " Reversibility is UNKNOWN — see the ack." : ""}`
1093
+ : `cannot redeploy ${key}: this bundle holds no pre/source image for it, so there are no bytes to restore. The change itself is cleanly reversible — the image is what is missing.`
1094
+ });
1095
+ }
1096
+ // A delete. The undelete window applies to CUSTOM FIELDS ONLY: Salesforce's Deleted Fields list
1097
+ // is a field feature, and no other type has one.
1098
+ if (candidate.typeName === "CustomField") {
1099
+ return withDivergence(await classifyDeletedField(args, base, image));
1100
+ }
1101
+ if (UNDELETABLE_TYPES.has(candidate.typeName)) {
1102
+ // Reachable only from outside this tool — the API that refuses to delete a record type is the
1103
+ // API a wave would have had to use. Classified rather than crashed, and lossy rather than clean,
1104
+ // because re-creating one does not restore the records that were assigned to it.
1105
+ evidence.push({
1106
+ statement: `${candidate.typeName} cannot be deleted through the Metadata API, so this deletion did not come from a wave's own deploy.`,
1107
+ basis: "MEASURED"
1108
+ });
1109
+ return withDivergence({
1110
+ ...base,
1111
+ class: "LOSSY_RECREATE",
1112
+ gate: image ? "ACK_REQUIRED" : "BLOCKED_NO_IMAGE",
1113
+ action: `re-create ${key} from the wave's pre-image.`,
1114
+ ackReasons: [
1115
+ `re-creating ${key} restores the definition only; every record assigned to it lost that assignment when it was deleted, and no deploy restores those.`
1116
+ ]
1117
+ });
1118
+ }
1119
+ return withDivergence({
1120
+ ...base,
1121
+ class: "CLEAN_REDEPLOY",
1122
+ gate: image ? (incompleteAck ? "ACK_REQUIRED" : "PROCEEDS") : "BLOCKED_NO_IMAGE",
1123
+ ackReasons: incompleteAck ? [incompleteAck] : [],
1124
+ action: image
1125
+ ? `re-create ${key} from the wave's pre-image (${image.relPaths.join(", ")}).${incompleteAck ? " The image is INCOMPLETE — see the ack." : ""}`
1126
+ : `cannot re-create ${key}: this bundle holds no pre/source image, and after the delete that image was the only surviving definition of the component on this machine.`
1127
+ });
1128
+ }
1129
+ /**
1130
+ * A deleted custom field: in the window, or past it.
1131
+ *
1132
+ * PRESENCE IS THE AUTHORITY, the clock is the estimate. The probe verified 28/28 (and this
1133
+ * implementation re-verified 34/34 on 2026-08-03) that the surviving Tooling set IS the in-window
1134
+ * set, so when the two disagree — the derived lapse date has passed but Salesforce still lists the
1135
+ * field — the derived date is what is wrong, and saying "lossy" over a field that is still sitting
1136
+ * in Setup's Deleted Fields list would throw away recoverable DATA. The disagreement is reported.
1137
+ */
1138
+ async function classifyDeletedField(args, base, image) {
1139
+ const { key, candidate } = args;
1140
+ const evidence = base.evidence;
1141
+ const objectName = candidate.fullName.split(".")[0] ?? "";
1142
+ const label = labelOf(args.pre.components.get(key)?.snapshot);
1143
+ let probed = null;
1144
+ if (args.probe && objectName)
1145
+ probed = await askProbe(args.probeLedger, args.probe, objectName);
1146
+ // The reverse difference `deletedFieldSet` returns "rather than asserts" so a caller can decide
1147
+ // how much to trust the read — which no caller could, because `ObjectProbe` dropped it (G14).
1148
+ // Non-empty means listMetadata saw fields Tooling did not: paging or scope trouble on the read,
1149
+ // so the deleted set for this OBJECT may be missing members. Plan-level and once per object —
1150
+ // it is a fact about the read, not about any one component.
1151
+ if (probed && !probed.failed && probed.onlyLive.length > 0) {
1152
+ const note = `the deleted-field probe's Tooling read for ${objectName} did not return ${probed.onlyLive.length} field(s) listMetadata sees (${probed.onlyLive.join(", ")}) — the read paged or scoped short, so the deleted set for this object may be incomplete and its answers deserve less trust.`;
1153
+ if (!args.notes.includes(note))
1154
+ args.notes.push(note);
1155
+ }
1156
+ const candidates = probed && !probed.failed ? matchDeletedField(probed.deleted, candidate.fullName) : [];
1157
+ const presenceVerified = Boolean(probed && !probed.failed);
1158
+ // The plan's own record of what the probe was worth for THIS component, so the summary header can
1159
+ // state attempted-and-failed rather than inferring "ran" from the probe's mere existence.
1160
+ args.probeLedger.presence.set(key, presenceVerified ? null : unansweredBecause(args.probe, probed));
1161
+ // NO SUBSTITUTE CLOCK. `?? args.asOf` used to stand here, which turned a bundle with no
1162
+ // `mutation-window.json` into a full 15-day window measured from the instant this ran, labelled
1163
+ // `BUNDLE_WINDOW` as if the bundle had said so. See `undeleteWindowFor`.
1164
+ const deletedAtBundle = args.deletedAtBundle;
1165
+ const audit = probed
1166
+ ? joinAuditRow(probed.audit, label, deletedAtBundle ? Date.parse(deletedAtBundle) : Number.NaN)
1167
+ : { matched: 0 };
1168
+ const window = undeleteWindowFor({
1169
+ ...(deletedAtBundle ? { deletedAtBundle } : {}),
1170
+ asOf: args.asOf,
1171
+ candidates,
1172
+ presenceVerified,
1173
+ audit
1174
+ });
1175
+ if (!presenceVerified) {
1176
+ evidence.push({
1177
+ statement: probed?.failed
1178
+ ? `the live deleted-field probe failed (${probed.failed}), so presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent. The window below is the clock's answer alone.`
1179
+ : `no live org probe was supplied, so presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent. The window below rests on the recorded timestamps alone.`,
1180
+ basis: "INFERRED"
1181
+ });
1182
+ }
1183
+ else if (candidates.length > 0) {
1184
+ evidence.push({
1185
+ statement: `${key} is present in Tooling CustomField and absent from listMetadata as ${candidates.join(", ")} — the exact deleted-but-recoverable set. Presence implies it is still restorable.`,
1186
+ basis: "MEASURED"
1187
+ });
1188
+ if (candidates.length > 1) {
1189
+ evidence.push({
1190
+ statement: `${candidates.length} deleted generations of this API name coexist (Salesforce suffixes a re-delete: _del, _del1, …). Setup's Deleted Fields list will show all of them and they hold different data; which one this wave deleted cannot be decided from the API.`,
1191
+ basis: "INFERRED"
1192
+ });
1193
+ }
1194
+ }
1195
+ // Measured, and only decidable when the probe ran: the deleted set is empty for this field
1196
+ // BECAUSE the field is back in `listMetadata`, not because Salesforce purged it.
1197
+ const liveAgain = presenceVerified && candidates.length === 0 && (probed?.live.includes(candidate.fullName) ?? false);
1198
+ if (presenceVerified && candidates.length === 0) {
1199
+ evidence.push({
1200
+ statement: liveAgain
1201
+ ? `${key} is LIVE in the org again — it is in listMetadata, so it is not in the deleted set. Something restored or re-created it after this wave.`
1202
+ : `${key} is in neither listMetadata nor the Tooling deleted set: the 15-day window has closed and Salesforce has erased the field and its data.`,
1203
+ basis: "MEASURED"
1204
+ });
1205
+ }
1206
+ if (window.deletedAtAudit) {
1207
+ evidence.push({
1208
+ statement: `SetupAuditTrail records a matching deletion at ${window.deletedAtAudit} (joined by field LABEL${window.auditRowsMatched > 1 ? `, ${window.auditRowsMatched} rows matched that label` : ""}) — inferred, not measured.`,
1209
+ basis: "INFERRED"
1210
+ });
1211
+ }
1212
+ if (window.basis === "UNVERIFIED") {
1213
+ evidence.push({
1214
+ statement: `wave ${args.wave} recorded no mutation window and no audit row joined this field's label, so WHEN it was deleted is not established from this bundle and no lapse date is derivable from it. A live probe of the deleted-field set is what resolves this.`,
1215
+ basis: "INFERRED"
1216
+ });
1217
+ }
1218
+ // A component that is back is not a loss and not a re-create. It is reported and left alone.
1219
+ if (liveAgain) {
1220
+ return {
1221
+ ...base,
1222
+ class: "ALREADY_LIVE",
1223
+ gate: "NO_ACTION",
1224
+ action: `nothing to roll back for the definition of ${key}: wave ${args.wave} deleted it, but it is live in the org again, so there is nothing for a rollback to re-create. Verify the live state matches what you want — the wave's pre-image is on this machine to compare against.`
1225
+ };
1226
+ }
1227
+ // PRESENCE OUTRANKS THE CLOCK. Without presence the clock decides — and a clock that could not be
1228
+ // read at all decides nothing, which leaves the non-lossy direction: "the data is permanently
1229
+ // gone" is the one answer this module may never give without evidence, and Setup's Deleted Fields
1230
+ // list is where an operator can see the truth for themselves.
1231
+ const inWindow = presenceVerified
1232
+ ? candidates.length > 0
1233
+ : window.daysRemaining === null || window.daysRemaining >= 0;
1234
+ if (inWindow) {
1235
+ if (presenceVerified && window.daysRemaining !== null && window.daysRemaining < 0) {
1236
+ evidence.push({
1237
+ statement: `the derived lapse date (${window.lapsesAt}) has passed, yet Salesforce still lists the field as deleted-and-recoverable. Presence is measured and the date is derived, so the date is what is wrong; treated as in-window.`,
1238
+ basis: "MEASURED"
1239
+ });
1240
+ }
1241
+ const remaining = window.daysRemaining === null
1242
+ ? ` This bundle establishes no delete timestamp, so how much of the 15-day window is left could not be computed here — a live probe of the deleted-field set, or Setup's own list, is what answers that.`
1243
+ : window.daysRemaining >= 0
1244
+ ? ` ${window.daysRemaining} day(s) left; lapses ${window.lapsesAt}.`
1245
+ : "";
1246
+ return {
1247
+ ...base,
1248
+ class: "UNDELETE_WINDOW",
1249
+ gate: "OPERATOR_ACTION",
1250
+ window,
1251
+ action: `undelete ${key} in Setup (Object Manager → ${objectName} → Fields & Relationships → Deleted Fields → Undelete). Never re-created as metadata: the undelete restores the DATA, a re-create does not.${remaining}`
1252
+ };
1253
+ }
1254
+ // A LAPSE NOTHING VERIFIED IS NOT A LAPSE AN ACK MAY SPEND. Presence outranks the clock above; it
1255
+ // outranks it here too, and its ABSENCE is the whole question. What is left when the probe did not
1256
+ // answer is `deletedAt + 15 days` — a derivation this module has already caught being wrong in the
1257
+ // dangerous direction (see the `Presence is measured and the date is derived` branch above, where
1258
+ // Salesforce still listed a field whose derived date had passed).
1259
+ //
1260
+ // The ONE remaining path where an unanswered check reached a destructive outcome. Everything else
1261
+ // in this module withholds when a check does not answer — presence stays UNVERIFIED rather than
1262
+ // absent, a missing timestamp leaves the window UNVERIFIED rather than borrowing `asOf`,
1263
+ // NOT_COMPARABLE is carded as a check that did not run. This branch alone converted silence into a
1264
+ // one-key release for a deploy that destroys data, which is that doctrine inverted.
1265
+ //
1266
+ // SO THE GATE MOVES AND THE CLASS DOES NOT. The belief is still lossy — that is what the clock
1267
+ // says, and saying otherwise would promise an undelete nothing established either. What changes is
1268
+ // what the tool may DO with the belief, which is exactly the split `RollbackGate` exists for: the
1269
+ // operator opens Setup and looks, and the answer they bring back is a measurement rather than an
1270
+ // inference. `isUnverifiedLapse` is how U10's chokepoint recognises this state.
1271
+ //
1272
+ // NO GRACE MARGIN, deliberately. "Far enough past the date that no probe is needed" was the
1273
+ // alternative, and it requires a constant nobody measured, guarding the exact claim this module
1274
+ // refuses to make on inference; at the margin's own boundary it would authorise a destructive
1275
+ // re-create on the strength of a number we invented. The remedy for a check that did not answer is
1276
+ // a check that answers — re-run with the org reachable — not a bigger clock.
1277
+ if (!presenceVerified) {
1278
+ const reason = unverifiedLapseReason({ key, window, probeReason: unansweredBecause(args.probe, probed) });
1279
+ evidence.push({ statement: reason, basis: "INFERRED" });
1280
+ return {
1281
+ ...base,
1282
+ class: "LOSSY_RECREATE",
1283
+ gate: "OPERATOR_ACTION",
1284
+ window,
1285
+ action: unverifiedLapseAction({ key, objectName, ...(image ? { image } : {}) }),
1286
+ ackReasons: [reason]
1287
+ };
1288
+ }
1289
+ // The ack says what is KNOWN, term by term — a lapse date that has not arrived is not a closure
1290
+ // date, and a window that was never established is not one either.
1291
+ const deletedPhrase = window.deletedAtBundle ? `was deleted ${window.deletedAtBundle}` : `was deleted by wave ${args.wave}`;
1292
+ const windowPhrase = window.lapsesAt === undefined
1293
+ ? `its undelete window could not be established from this bundle, and Salesforce no longer lists it as deleted-and-recoverable`
1294
+ : window.daysRemaining !== null && window.daysRemaining >= 0
1295
+ ? `Salesforce no longer lists it as deleted-and-recoverable, ahead of the derived lapse date ${window.lapsesAt}`
1296
+ : `its undelete window closed ${window.lapsesAt}`;
1297
+ return {
1298
+ ...base,
1299
+ class: "LOSSY_RECREATE",
1300
+ gate: image ? "ACK_REQUIRED" : "BLOCKED_NO_IMAGE",
1301
+ window,
1302
+ action: image
1303
+ ? `re-create ${key} from the wave's pre-image (${image.relPaths.join(", ")}). The definition returns; the data does not.`
1304
+ : `cannot re-create ${key}: the undelete window is gone AND this bundle holds no pre/source image, so neither the data nor the definition can be restored from this machine.`,
1305
+ ackReasons: [
1306
+ `${key} ${deletedPhrase} and ${windowPhrase}. Re-creating it returns the field DEFINITION only — every value every record held in it is permanently gone.`
1307
+ ]
1308
+ };
1309
+ }
1310
+ /**
1311
+ * A lossy re-create whose ONLY evidence of lapse is a clock nobody checked against the org.
1312
+ *
1313
+ * THE ONE PREDICATE, ASKED IN THREE PLACES. `classifyDeletedField` and `recheckUndeleteWindow` gate
1314
+ * on this state, and U10's chokepoint refuses to build a deploy containing one. Re-deriving the
1315
+ * question at the chokepoint is how a guard comes to disagree with the rule it guards — the same
1316
+ * shape as U9's divergence handling rewriting a gate the AE4 check was reading.
1317
+ *
1318
+ * EXACT BY CONSTRUCTION, not by luck: a deleted field whose window is `UNVERIFIED` has a null
1319
+ * `daysRemaining`, which keeps it in `UNDELETE_WINDOW` rather than letting it reach lossy at all, and
1320
+ * the only other producer of `LOSSY_RECREATE` (a delete of an undeletable type) carries no window. So
1321
+ * `LOSSY_RECREATE` + a window + unverified presence is precisely "the clock said so and nothing else
1322
+ * did".
1323
+ */
1324
+ export const isUnverifiedLapse = (component) => component.class === "LOSSY_RECREATE" && component.window !== undefined && !component.window.presenceVerified;
1325
+ /**
1326
+ * What a lapse nothing verified establishes — which is nothing about the org.
1327
+ *
1328
+ * WRITTEN ONCE because both ends of the wait say it: `classifyDeletedField` when the probe never
1329
+ * answered in the first place, and `recheckUndeleteWindow` when the token that worked on Monday has
1330
+ * expired by Thursday. Two texts drifting apart is how the header came to contradict the evidence.
1331
+ *
1332
+ * IT NAMES BOTH HALVES OF WHAT IS MISSING. The date can be inferred (an audit row joined by field
1333
+ * LABEL, and labels repeat — 3 of 34 in-window labels collided in the live probe), and the check that
1334
+ * would have settled it did not answer. An operator told only "the window closed" has been handed the
1335
+ * conclusion without either premise.
1336
+ */
1337
+ function unverifiedLapseReason(args) {
1338
+ const closed = args.window.lapsesAt
1339
+ ? `the ${UNDELETE_WINDOW_DAYS}-day undelete window derived for it ran out ${args.window.lapsesAt}`
1340
+ : "its undelete window could not be established from this bundle";
1341
+ const anchor = args.window.basis === "AUDIT_TRAIL_INFERRED"
1342
+ ? ", and that date is itself INFERRED — derived from a SetupAuditTrail row joined by field LABEL, which is not an identity"
1343
+ : "";
1344
+ return (`${args.key}: ${closed}${anchor}. NOTHING VERIFIED IT — ${args.probeReason} — so presence in Salesforce's ` +
1345
+ "Deleted Fields list is UNVERIFIED, not absent, and \"the data is gone\" is this clock's inference rather than the " +
1346
+ "org's answer. No acknowledgement releases a re-create on that: if the field is still listed, an undelete restores " +
1347
+ "it AND ITS DATA, and a re-create would destroy exactly what the undelete would have brought back.");
1348
+ }
1349
+ /** What the operator does about it — the only move that turns the inference into an answer. */
1350
+ function unverifiedLapseAction(args) {
1351
+ return (`settle ${args.key} in Setup before anything re-creates it (Object Manager → ${args.objectName} → Fields & ` +
1352
+ "Relationships → Deleted Fields). If it is listed, UNDELETE it — the data is intact and this plan's clock was " +
1353
+ "wrong. If it is not listed, the field and its data are gone; re-run this rollback with the org reachable so the " +
1354
+ "probe can measure that, and the re-create is offered for your acknowledgement then." +
1355
+ (args.image ? "" : " This bundle holds no pre/source image either, so no re-create is available from this machine in any case."));
1356
+ }
1357
+ /** A component's label, for the audit-trail join. Never used as an identity. */
1358
+ function labelOf(snapshot) {
1359
+ const label = metadataOf(snapshot).label;
1360
+ return typeof label === "string" ? label : undefined;
1361
+ }
1362
+ async function divergenceFor(args, operation) {
1363
+ if (!args.live || !args.live.has(args.key))
1364
+ return null;
1365
+ const entry = args.pre.components.get(args.key)?.entry ?? args.post?.components.get(args.key)?.entry;
1366
+ return preflightDivergence({
1367
+ orgId: args.orgId,
1368
+ component: {
1369
+ typeName: args.candidate.typeName,
1370
+ fullName: args.candidate.fullName,
1371
+ ...(entry?.file ? { file: entry.file } : {})
1372
+ },
1373
+ // A delete's recorded post-image is ABSENCE. Reading the post projection for it would find
1374
+ // either nothing or a snapshot the deploy had already invalidated.
1375
+ recordedPost: operation === "delete" ? null : (args.post?.components.get(args.key)?.snapshot ?? null),
1376
+ live: args.live.get(args.key) ?? null,
1377
+ target: { sessionId: args.sessionId, wave: args.wave },
1378
+ notes: args.notes,
1379
+ ...(args.root ? { root: args.root } : {})
1380
+ });
1381
+ }
1382
+ /**
1383
+ * Re-ask the undelete question at execution time, and downgrade whatever has lapsed.
1384
+ *
1385
+ * THE REASON THIS EXISTS AS ITS OWN STEP. A classification is a statement about an instant. An
1386
+ * operator reads "11 days left, undelete in Setup", takes it to a change board, and comes back on
1387
+ * day 16 — by which point Salesforce has erased the field and its data, and the plan in their hand
1388
+ * still promises a clean recovery. A rollback that acts on that plan reports success while the data
1389
+ * it claimed to protect is gone. The window is per FIELD, not per run (the probe measured two
1390
+ * delete dates 8 days apart inside one cleanup), so this asks per component.
1391
+ *
1392
+ * ONE-DIRECTIONAL BY DESIGN: this can only take a component OUT of `UNDELETE_WINDOW` — to
1393
+ * `LOSSY_RECREATE` when Salesforce purged it, or to `ALREADY_LIVE` when it left the deleted set by
1394
+ * coming back. A lossy component does not become recoverable because a probe now succeeds — if the
1395
+ * field reappeared in the deleted set, something deleted it again, and that is a different deletion
1396
+ * with a different window and different data behind it.
1397
+ *
1398
+ * AND THE WITHDRAWAL IS NOT ITSELF A PROMISE. The probe that answered on Monday can fail on
1399
+ * Thursday — a token expires exactly across that gap — and then the only thing saying the window ran
1400
+ * out is the clock the plan already carried. That withdraws the undelete, because a promise nothing
1401
+ * supports may not stand; it does not hand over a re-create, because nothing supports that either.
1402
+ * The class moves and the GATE goes to the operator. See `isUnverifiedLapse`.
1403
+ */
1404
+ export async function recheckUndeleteWindow(args) {
1405
+ const reclassified = [];
1406
+ // Seeded with the classification's answers, then overwritten by this pass for what it touches. The
1407
+ // re-check asks a fresher org, so its answer supersedes — and a plan that carried the first pass's
1408
+ // outcome unchanged would report a token that expired in between as a probe that measured.
1409
+ //
1410
+ // AND NOT SEEDED AT ALL WHEN THERE IS NO PROBE, which is the same rule read the other way. A pass
1411
+ // that cannot ask has not asked, and republishing the earlier pass's objects and failures under
1412
+ // this pass's outcome is how the header came to read `ATTEMPTED, FAILED — nothing answered` over
1413
+ // components whose own recorded reason was that no probe was supplied. Unreachable through
1414
+ // `runRollback` (one reader decides both passes), and this function is exported.
1415
+ const ledger = args.probe ? seedProbeLedger(args.plan.probe) : newProbeLedger();
1416
+ const components = [];
1417
+ for (const component of args.plan.components) {
1418
+ if (component.class !== "UNDELETE_WINDOW" || !component.window) {
1419
+ components.push(component);
1420
+ continue;
1421
+ }
1422
+ const objectName = component.fullName.split(".")[0] ?? "";
1423
+ let probed = null;
1424
+ if (args.probe && objectName)
1425
+ probed = await askProbe(ledger, args.probe, objectName);
1426
+ const presenceVerified = Boolean(probed && !probed.failed);
1427
+ ledger.presence.set(component.key, presenceVerified ? null : unansweredBecause(args.probe, probed));
1428
+ const candidates = presenceVerified ? matchDeletedField(probed?.deleted ?? [], component.fullName) : [];
1429
+ const window = {
1430
+ ...component.window,
1431
+ candidates: presenceVerified ? candidates : component.window.candidates,
1432
+ presenceVerified,
1433
+ daysRemaining: component.window.lapsesAt
1434
+ ? Math.floor((Date.parse(component.window.lapsesAt) - Date.parse(args.asOf)) / DAY_MS)
1435
+ : null
1436
+ };
1437
+ // Presence still outranks the clock (see `classifyDeletedField`). Without a probe the clock is
1438
+ // all there is, and a clock that says the window closed is enough to withdraw the promise — but
1439
+ // a window that was never established says nothing, and silence does not withdraw anything.
1440
+ const stillInWindow = presenceVerified
1441
+ ? candidates.length > 0
1442
+ : window.daysRemaining === null || window.daysRemaining >= 0;
1443
+ if (stillInWindow) {
1444
+ components.push({ ...component, window });
1445
+ continue;
1446
+ }
1447
+ // The same question `classifyDeletedField` asks, at the other end of the wait: an empty deleted
1448
+ // set means PURGED only if the component is not simply live again. Somebody restoring the field
1449
+ // between classification and execution must not be reported as the data being destroyed.
1450
+ if (presenceVerified && (probed?.live.includes(component.fullName) ?? false)) {
1451
+ const reason = `${component.key} has left Salesforce's deleted-field set by coming BACK: it is in listMetadata again, so something restored or re-created it since this plan was made. There is nothing to re-create and nothing was lost.`;
1452
+ reclassified.push({ key: component.key, from: "UNDELETE_WINDOW", to: "ALREADY_LIVE", reason });
1453
+ components.push({
1454
+ ...component,
1455
+ class: "ALREADY_LIVE",
1456
+ gate: "NO_ACTION",
1457
+ window,
1458
+ action: `nothing to roll back for the definition of ${component.key}: it is live in the org again. Verify the live state matches what you want.`,
1459
+ evidence: [...component.evidence, { statement: reason, basis: "MEASURED" }]
1460
+ });
1461
+ continue;
1462
+ }
1463
+ // WITHDRAWN IS NOT INVERTED. The promise of a clean undelete goes the moment the clock says the
1464
+ // window ran out — but when nothing checked the org, an ack that buys the re-create does not
1465
+ // arrive in its place. That is `classifyDeletedField`'s rule at the other end of the wait, and
1466
+ // it is worded there for both ends; see `unverifiedLapseReason` and `isUnverifiedLapse`.
1467
+ if (!presenceVerified) {
1468
+ const reason = unverifiedLapseReason({
1469
+ key: component.key,
1470
+ window,
1471
+ probeReason: unansweredBecause(args.probe, probed)
1472
+ });
1473
+ reclassified.push({ key: component.key, from: "UNDELETE_WINDOW", to: "LOSSY_RECREATE", reason });
1474
+ components.push({
1475
+ ...component,
1476
+ class: "LOSSY_RECREATE",
1477
+ gate: "OPERATOR_ACTION",
1478
+ window,
1479
+ action: unverifiedLapseAction({
1480
+ key: component.key,
1481
+ objectName,
1482
+ ...(component.image ? { image: component.image } : {})
1483
+ }),
1484
+ ackReasons: [...component.ackReasons, reason],
1485
+ evidence: [...component.evidence, { statement: reason, basis: "INFERRED" }]
1486
+ });
1487
+ continue;
1488
+ }
1489
+ const closedAt = window.lapsesAt ? ` at ${window.lapsesAt}` : "";
1490
+ const reason = `${component.key} is no longer in Salesforce's deleted-field set: the ${UNDELETE_WINDOW_DAYS}-day window closed${closedAt} and the field and its data have been erased. The undelete this plan offered is no longer available.`;
1491
+ reclassified.push({ key: component.key, from: "UNDELETE_WINDOW", to: "LOSSY_RECREATE", reason });
1492
+ components.push({
1493
+ ...component,
1494
+ class: "LOSSY_RECREATE",
1495
+ gate: component.image ? "ACK_REQUIRED" : "BLOCKED_NO_IMAGE",
1496
+ window,
1497
+ action: component.image
1498
+ ? `re-create ${component.key} from the wave's pre-image (${component.image.relPaths.join(", ")}). The definition returns; the data does not.`
1499
+ : `cannot re-create ${component.key}: the undelete window has closed AND this bundle holds no pre/source image.`,
1500
+ ackReasons: [...component.ackReasons, reason],
1501
+ evidence: [...component.evidence, { statement: reason, basis: "MEASURED" }]
1502
+ });
1503
+ }
1504
+ return {
1505
+ plan: {
1506
+ ...args.plan,
1507
+ asOf: args.asOf,
1508
+ components,
1509
+ probe: probeOutcome(ledger, Boolean(args.probe))
1510
+ },
1511
+ reclassified
1512
+ };
1513
+ }