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.
- package/README.md +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- 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
|
+
}
|