in2-agent-server 0.20260821.185429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. package/viewer/server.mjs +433 -0
@@ -0,0 +1,1013 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { readPlanFile } from "../core/planFile.js";
5
+ import { expandComponent, expandObservedComponent } from "../direct/containers.js";
6
+ import { deriveConfigItem, isConfigItemError } from "./configItem.js";
7
+ async function readJsonIfPresent(file) {
8
+ try {
9
+ return JSON.parse(await fs.readFile(file, "utf8"));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ const CONFIG_IDENTITY_KEYS = ["fullName", "name", "label", "field", "relatedList", "apiName", "tab", "application"];
16
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
17
+ /**
18
+ * Keep only the facets the submitted configuration stated; take a facet's VALUE from the org's
19
+ * post-image when the org has one, and keep the SUBMITTED value when it does not. This is the
20
+ * mismatch path's load-bearing rule, in both directions: repeating the requested blue value after
21
+ * Salesforce actually stored grey would make the Configuration Item repeat the wish rather than
22
+ * record the fact — but nulling a facet the org merely OMITS repeats the opposite lie. The intent
23
+ * comparator deliberately reads `externalId=false` against an org that dropped the element as
24
+ * AGREEMENT (absent means false), and a facet the comparator did not flag was confirmed as
25
+ * submitted; a genuinely-flagged facet is already named, with the org's word, in the delivery's
26
+ * `differences` list, which stays the authoritative account per path.
27
+ */
28
+ function observedConfiguration(wanted, observed) {
29
+ if (Array.isArray(wanted)) {
30
+ const have = Array.isArray(observed) ? observed : observed === undefined || observed === null ? [] : [observed];
31
+ // Identity matching mirrors the comparator's (applyLoop's INTENT_IDENTITY_KEYS): an identity
32
+ // key only counts if its values are UNIQUE on the observed side — duplicate values would make
33
+ // the Map keep the last silently, pairing entries the comparator never paired.
34
+ const identity = CONFIG_IDENTITY_KEYS.find((key) => wanted.every((entry) => isRecord(entry) && typeof entry[key] === "string") &&
35
+ have.every((entry) => isRecord(entry) && typeof entry[key] === "string") &&
36
+ new Set(have.map((entry) => entry[key])).size === have.length);
37
+ if (identity) {
38
+ const byIdentity = new Map(have.map((entry) => [entry[identity], entry]));
39
+ return wanted.map((entry) => {
40
+ const id = entry[identity];
41
+ return observedConfiguration(entry, byIdentity.get(id));
42
+ });
43
+ }
44
+ // Positional pairing is only meaningful when the two sides are the same length — pairing a
45
+ // 3-entry submission against a 2-entry read matches unrelated entries. Different lengths keep
46
+ // the submitted entries; the differences list carries what actually changed.
47
+ if (have.length !== wanted.length)
48
+ return wanted;
49
+ return wanted.map((entry, index) => observedConfiguration(entry, have[index]));
50
+ }
51
+ if (isRecord(wanted)) {
52
+ // Metadata XML collapses a one-element collection to an object while the org reader commonly
53
+ // materializes it as an array. The intent comparator treats those as the same shape; the value
54
+ // we record in Campfire has to make the same comparison or a real mismatched Profile entry
55
+ // would be rendered as a set of nulls.
56
+ const singleton = Array.isArray(observed) && observed.length === 1 ? observed[0] : observed;
57
+ const have = isRecord(singleton) ? singleton : {};
58
+ return Object.fromEntries(Object.keys(wanted).map((key) => [key, observedConfiguration(wanted[key], have[key])]));
59
+ }
60
+ // A facet the org read does not carry keeps its submitted value — the comparator counted that
61
+ // absence as agreement (see the function doc). `undefined` would silently disappear from the
62
+ // Campfire body either way; `null` earned its place only when the WHOLE component was absent,
63
+ // and that case now grades unconfirmed long before this function runs.
64
+ return observed === undefined ? wanted : observed;
65
+ }
66
+ async function projectionMetadata(bundleDir, side, typeName, fullName) {
67
+ const manifest = await readJsonIfPresent(path.join(bundleDir, side, "projection.json"));
68
+ const entry = manifest?.components?.find((component) => component.typeName === typeName && component.fullName === fullName);
69
+ if (!entry?.found)
70
+ return null;
71
+ const snapshot = await readJsonIfPresent(path.join(bundleDir, side, entry.file));
72
+ return snapshot?.state?.metadata ?? null;
73
+ }
74
+ async function expandedAction(args) {
75
+ if (!args.metadataKey || args.metadataKey === args.sourceComponent)
76
+ return args.fallback;
77
+ const [pre, post] = await Promise.all([
78
+ projectionMetadata(args.bundleDir, "pre", args.typeName, args.fullName),
79
+ projectionMetadata(args.bundleDir, "post", args.typeName, args.fullName)
80
+ ]);
81
+ // A post image that does not carry the component AT ALL cannot testify to any action — "the
82
+ // child is gone" and "the read did not cover the container" would both render as `deleted`, and
83
+ // only one of them happened. The diff-based fallback answers from evidence that does exist.
84
+ if (post === null)
85
+ return args.fallback;
86
+ const find = (metadata) => expandObservedComponent(args.typeName, args.fullName, metadata).find((component) => `${component.typeName}:${component.fullName}` === args.metadataKey)?.configuration;
87
+ const before = find(pre);
88
+ const after = find(post);
89
+ if (!before && after)
90
+ return "created";
91
+ if (before && !after)
92
+ return "deleted";
93
+ if (before && after)
94
+ return isDeepStrictEqual(before, after) ? "no-net-change" : "updated";
95
+ return args.fallback;
96
+ }
97
+ function actionFor(diff, component, destructive) {
98
+ if (destructive)
99
+ return diff?.removed?.includes(component) ? "deleted" : "unknown";
100
+ if (diff?.added?.includes(component))
101
+ return "created";
102
+ if (diff?.changed?.some((entry) => entry.key === component))
103
+ return "updated";
104
+ if (diff)
105
+ return "no-net-change";
106
+ return "unknown";
107
+ }
108
+ async function deliveryObservation(args) {
109
+ const reportKey = args.facetReport ?? args.sourceComponent;
110
+ const report = args.verdict?.intent?.find((entry) => entry.component === reportKey);
111
+ // THE REPORT COVERS THE DEPLOYED COMPONENT; a container's children share it. Scope the
112
+ // mismatches to the child this observation is about before grading, or every innocent sibling
113
+ // inherits one child's drift — graded differed, its description carrying a difference in a
114
+ // component it does not contain, and its requirement disclosed for work that landed exactly.
115
+ // A child's mismatch paths carry its identity bracket (`metadata.alerts[fullName=A].template`);
116
+ // a path with no bracket speaks about the container as a whole and applies to every child.
117
+ const childLeaf = args.metadataKey && args.metadataKey !== args.sourceComponent
118
+ ? (args.metadataKey.split(":")[1] ?? "").split(".").pop()
119
+ : undefined;
120
+ const allMismatches = report?.mismatches ?? [];
121
+ const mismatches = childLeaf
122
+ ? allMismatches.filter((m) => !/\[[^\]]+\]/.test(m.path) || m.path.includes(`=${childLeaf}]`))
123
+ : allMismatches;
124
+ // "(absent)" is the intent check's word for "the post-image did not carry this component at
125
+ // all" — a read that DID NOT COVER the component, not an org that disagreed with it. Grading it
126
+ // "differed" was live-measured to be wrong in the worst direction: on pre-2026-07-29 evidence
127
+ // (before the post-image coverage fix) it stamped "the observed value below is authoritative"
128
+ // over a configuration projected from the same absent read — every value null. An uncovered
129
+ // read is UNCONFIRMED, same as a missing report or a report whose own `found` is false; only a
130
+ // mismatch with an actually-observed org value is a difference.
131
+ const observedMismatches = mismatches.filter((m) => m.observed !== "(absent)");
132
+ // "(still present)" is the comparator's word for a DELETE the org refused. That is a FAILED
133
+ // delivery, not a difference — recording it as differed wrote "DESTRUCTIVE — this component was
134
+ // deleted" over a component demonstrably still in the org. The action says what actually
135
+ // happened (nothing), the differences carry the org's word, and heldForDeliveryTruth holds the
136
+ // row until the operator retries the delete or drops it and re-runs.
137
+ const deleteStillPresent = args.destructive && mismatches.some((m) => m.observed === "(still present)");
138
+ let readBack = !report || report.found === false || args.verdict?.verdict === "AMBIGUOUS_DELIVERY" || (mismatches.length > 0 && observedMismatches.length === 0)
139
+ ? "unconfirmed"
140
+ : mismatches.length > 0
141
+ ? "differed"
142
+ : "matched";
143
+ let deliveredConfiguration = deleteStillPresent ? undefined : args.configuration;
144
+ if (!args.destructive && readBack === "differed" && args.configuration) {
145
+ const observed = await projectionMetadata(args.bundleDir, "post", args.typeName, args.fullName);
146
+ const observedPart = args.metadataKey
147
+ ? expandObservedComponent(args.typeName, args.fullName, observed).find((component) => `${component.typeName}:${component.fullName}` === args.metadataKey)?.configuration
148
+ : observed;
149
+ if (observedPart === undefined || observedPart === null) {
150
+ // The post image does not carry this component (or this child of it). Falling back to the
151
+ // PARENT body matched the child's keys against the container and nulled all of them under
152
+ // the authoritative banner — an uncovered read is uncovered, and the row holds.
153
+ readBack = "unconfirmed";
154
+ deliveredConfiguration = undefined;
155
+ }
156
+ else {
157
+ deliveredConfiguration = observedConfiguration(args.configuration, observedPart);
158
+ }
159
+ }
160
+ return {
161
+ bundle: args.bundle,
162
+ action: deleteStillPresent
163
+ ? "no-net-change"
164
+ : await expandedAction({
165
+ bundleDir: args.bundleDir,
166
+ sourceComponent: args.sourceComponent,
167
+ typeName: args.typeName,
168
+ fullName: args.fullName,
169
+ metadataKey: args.metadataKey,
170
+ fallback: actionFor(args.diff, args.sourceComponent, args.destructive)
171
+ }),
172
+ readBack,
173
+ ...(deliveredConfiguration && Object.keys(deliveredConfiguration).length > 0 ? { deliveredConfiguration } : {}),
174
+ ...(mismatches.length > 0 ? { differences: mismatches } : {})
175
+ };
176
+ }
177
+ // Derive every Configuration Item a set of direct_apply bundles produced. Pure computation over
178
+ // local evidence — no org contact, no model judgement.
179
+ /**
180
+ * What the org actually says about a deployed Flow, read from this bundle's post projection and
181
+ * returned as DATA for the description generator to project.
182
+ *
183
+ * The type table used to print "Flows deploy INACTIVE — raise a Deployment Task to activate" on
184
+ * every Flow row. Usually true, and on CF76 simply false: both flows landed Active, and the manifest
185
+ * asserted otherwise. A Configuration Item is a record of what happened, so a claim on it has to be
186
+ * observed rather than assumed — the evidence was sitting in the same bundle the row came from.
187
+ *
188
+ * WHY DATA AND NOT PROSE. This was once a sentence on `row.note`. But `note` is a SHARED channel —
189
+ * `applyAttribution` appends the amendment sentence to whatever is already there — so the status and
190
+ * an amendment ended up in one string, and the description generator reading the status out of the
191
+ * observed body would print it twice. As observed facets the status travels in the body the
192
+ * generator already projects, and `note` carries amendments alone.
193
+ *
194
+ * WHY IT CANNOT COME FROM THE SUBMITTED BODY. On a MATCHED read `deliveredConfiguration` is the
195
+ * submitted metadata. A flow submitted `<status>Draft</status>` that the org activated would then
196
+ * describe itself as Draft — the request reprinted as though the org had confirmed it, which is CF76
197
+ * through a new door. These facets are read from the post image and win over the submitted values.
198
+ *
199
+ * NARROW ON PURPOSE: only the facets the generator consumes — `label` for the identity line,
200
+ * `status` and `start.object` for the projection. Merging the whole Flow body would pour a metadata
201
+ * blob into a bounded Campfire field and balloon every preview row.
202
+ *
203
+ * SILENCE IS NOT THE SAME AS DECLINING TO OVERWRITE. When the snapshot is unreadable this returns
204
+ * nothing, and the CALLER must then strip these keys from the submitted body — see
205
+ * FLOW_OBSERVED_FACETS. Merely skipping the merge leaves the submitted status standing and the
206
+ * description presents the request as an observation, which is the failure this path exists to
207
+ * prevent.
208
+ */
209
+ const FLOW_OBSERVED_FACETS = ["label", "status", "start"];
210
+ async function observedFlowFacets(bundleDir, fullName) {
211
+ const metadata = await projectionMetadata(bundleDir, "post", "Flow", fullName);
212
+ if (!metadata)
213
+ return undefined;
214
+ const facets = {};
215
+ for (const key of ["label", "status"]) {
216
+ const value = metadata[key];
217
+ if (typeof value === "string" && value.trim() !== "")
218
+ facets[key] = value;
219
+ }
220
+ // `start.object` is where a record-triggered flow names its object. Narrowed to that one key so
221
+ // an observed start block cannot smuggle the rest of a flow's trigger definition into the field.
222
+ const start = metadata.start;
223
+ if (isRecord(start) && typeof start.object === "string" && start.object.trim() !== "") {
224
+ facets.start = { object: start.object };
225
+ }
226
+ return Object.keys(facets).length > 0 ? facets : undefined;
227
+ }
228
+ /**
229
+ * Replace every post-owned Flow facet with the org's word, or with nothing at all.
230
+ *
231
+ * The post projection is AUTHORITATIVE for these keys, which cuts both ways: an observed value wins
232
+ * over the submitted one, and an unreadable read means the org said nothing — so the submitted value
233
+ * must go too rather than sitting there looking observed. Keys outside the owned set are untouched;
234
+ * they are the submitted body a matched read genuinely proved.
235
+ *
236
+ * Returns `undefined` when nothing survives, so the delivery carries no body rather than an empty
237
+ * object (the derivation already omits empty configurations).
238
+ */
239
+ function withObservedFlowFacets(submitted, observed) {
240
+ const merged = {};
241
+ for (const [key, value] of Object.entries(submitted ?? {})) {
242
+ if (!FLOW_OBSERVED_FACETS.includes(key))
243
+ merged[key] = value;
244
+ }
245
+ Object.assign(merged, observed ?? {});
246
+ return Object.keys(merged).length > 0 ? merged : undefined;
247
+ }
248
+ export async function configItemsFromBundles(bundleDirs) {
249
+ const byKey = new Map();
250
+ const unmapped = [];
251
+ const notes = [];
252
+ // Delivery ORDER is evidence: a later wave's action/destructive overwrite an earlier one's on
253
+ // the merged row. The session-discovery path sorts; explicit bundleDirs must sort identically,
254
+ // or the plan preview (which accepts bundleDirs) and the write (which discovers) can disagree
255
+ // about the same wave — live shape: destructive true forward, false reversed.
256
+ for (const dir of [...bundleDirs].sort((a, b) => path.basename(a).localeCompare(path.basename(b)))) {
257
+ const bundle = path.basename(dir);
258
+ const request = await readJsonIfPresent(path.join(dir, "request.json"));
259
+ if (!request) {
260
+ unmapped.push({ component: "(bundle)", bundle, reason: `No request.json under ${dir} — not a direct_apply evidence bundle.` });
261
+ continue;
262
+ }
263
+ if (request.dryRunOnly) {
264
+ notes.push(`${bundle}: dryRunOnly — nothing was deployed, so it contributes no Configuration Items.`);
265
+ continue;
266
+ }
267
+ const verdict = await readJsonIfPresent(path.join(dir, "verdict.json"));
268
+ const diff = await readJsonIfPresent(path.join(dir, "diff.json"));
269
+ // A REJECTED_BY_ORG or LINT_BLOCKED bundle deployed NOTHING — the org rolls the whole deploy
270
+ // back, so it is exactly as empty as a dry run and must contribute no Configuration Items.
271
+ //
272
+ // Live-caught: a wave failed on one bad component, was fixed and re-deployed as "<label>-v2",
273
+ // and BOTH bundles were on disk. The failed one sorts first, so its rows shadowed the
274
+ // successful ones and four genuinely-verified components were written back as
275
+ // `verified: false`. That understates delivery on a paid accuracy metric, and it is the worst
276
+ // kind of wrong: quiet, plausible, and pointing at real components.
277
+ const deployedNothing = verdict?.verdict === "REJECTED_BY_ORG" || verdict?.verdict === "LINT_BLOCKED";
278
+ if (deployedNothing) {
279
+ notes.push(`${bundle}: verdict ${verdict?.verdict} — the org rolled the whole deploy back, so it contributes no Configuration Items. If a later bundle re-deployed these components, that one is authoritative.`);
280
+ continue;
281
+ }
282
+ if (verdict && verdict.verdict !== "DIFF_VERIFIED" && verdict.verdict !== "DELIVERED_WITH_DRIFT") {
283
+ notes.push(`${bundle}: verdict ${verdict.verdict ?? "(none)"} — components below are NOT confirmed delivered; confirm before writing back.`);
284
+ }
285
+ const submitted = [
286
+ ...(request.edits ?? []).map((e) => ({ ...e, destructive: false })),
287
+ ...(request.deletes ?? []).map((d) => ({ ...d, xml: undefined, destructive: true }))
288
+ ];
289
+ for (const item of submitted) {
290
+ const expanded = item.destructive
291
+ ? expandObservedComponent(item.typeName, item.fullName, await projectionMetadata(dir, "pre", item.typeName, item.fullName))
292
+ : expandComponent(item.typeName, item.fullName, item.xml);
293
+ for (const comp of expanded) {
294
+ const derived = deriveConfigItem(comp.typeName, comp.fullName);
295
+ const source = `${item.typeName}:${item.fullName}`;
296
+ const metadataKey = `${comp.typeName}:${comp.fullName}`;
297
+ if (isConfigItemError(derived)) {
298
+ unmapped.push({ component: metadataKey, bundle, reason: derived.error });
299
+ continue;
300
+ }
301
+ const delivery = await deliveryObservation({
302
+ bundleDir: dir,
303
+ bundle,
304
+ sourceComponent: source,
305
+ typeName: item.typeName,
306
+ fullName: item.fullName,
307
+ metadataKey,
308
+ configuration: comp.configuration,
309
+ destructive: item.destructive,
310
+ verdict,
311
+ diff
312
+ });
313
+ // The org's own word about a Flow REPLACES the submitted one for the facets the post
314
+ // projection owns. On a matched read the delivered body is the SUBMITTED metadata, so a flow
315
+ // submitted Draft that the org activated would otherwise describe itself as Draft — and an
316
+ // unreadable read would leave that submitted Draft standing, which is the same lie with no
317
+ // evidence behind it at all. Both cases go through `withObservedFlowFacets`, which strips
318
+ // the owned keys before adding back whatever the org actually said.
319
+ //
320
+ // Destructive rows are skipped: their body is the pre-image by definition — the last state
321
+ // the org showed before the removal — and the post projection describes the org AFTER the
322
+ // delete, so merging it would overwrite that with what a refused or partial delete left.
323
+ if (comp.typeName === "Flow" && !item.destructive) {
324
+ delivery.deliveredConfiguration = withObservedFlowFacets(delivery.deliveredConfiguration, await observedFlowFacets(dir, comp.fullName));
325
+ }
326
+ const key = `${derived.type}|${derived.apiName}|${derived.objectApiName ?? ""}|${derived.folderApiName ?? ""}`;
327
+ const already = byKey.get(key);
328
+ if (already) {
329
+ // One CI, every revision. Keeping only the first wave discarded the exact update that
330
+ // finished a create-then-update component; keeping only the deletion discarded the build
331
+ // that preceded it. Delivery order is evidence and survives intact here.
332
+ already.deliveries = [...(already.deliveries ?? []), delivery];
333
+ already.sourceBundle = bundle;
334
+ already.destructive = item.destructive;
335
+ already.verified = (already.deliveries ?? []).every((entry) => entry.readBack === "matched");
336
+ continue;
337
+ }
338
+ byKey.set(key, {
339
+ ...derived,
340
+ sourceComponent: source,
341
+ metadataKey,
342
+ sourceBundle: bundle,
343
+ destructive: item.destructive,
344
+ origin: "deployed",
345
+ // Expanded children inherit the container's read-back — the intent check runs per
346
+ // DEPLOYED component, so a child has no verdict row of its own.
347
+ verified: delivery.readBack === "matched",
348
+ deliveries: [delivery],
349
+ ...(comp.expandedFrom ? { expandedFrom: comp.expandedFrom } : {})
350
+ });
351
+ }
352
+ }
353
+ for (const facet of request.facetUpdates ?? []) {
354
+ const source = `CustomObject:${facet.objectApiName}`;
355
+ const derived = deriveConfigItem("CustomObject", facet.objectApiName);
356
+ if (isConfigItemError(derived)) {
357
+ unmapped.push({ component: source, bundle, reason: derived.error });
358
+ continue;
359
+ }
360
+ const delivery = await deliveryObservation({
361
+ bundleDir: dir,
362
+ bundle,
363
+ sourceComponent: source,
364
+ typeName: "CustomObject",
365
+ fullName: facet.objectApiName,
366
+ configuration: facet.facets,
367
+ destructive: false,
368
+ verdict,
369
+ diff,
370
+ facetReport: `${source} (facets)`
371
+ });
372
+ const key = `${derived.type}|${derived.apiName}|${derived.objectApiName ?? ""}|${derived.folderApiName ?? ""}`;
373
+ const already = byKey.get(key);
374
+ if (already) {
375
+ already.deliveries = [...(already.deliveries ?? []), delivery];
376
+ already.sourceBundle = bundle;
377
+ already.verified = (already.deliveries ?? []).every((entry) => entry.readBack === "matched");
378
+ }
379
+ else {
380
+ byKey.set(key, {
381
+ ...derived,
382
+ sourceComponent: source,
383
+ metadataKey: source,
384
+ sourceBundle: bundle,
385
+ destructive: false,
386
+ verified: delivery.readBack === "matched",
387
+ origin: "deployed",
388
+ deliveries: [delivery]
389
+ });
390
+ }
391
+ }
392
+ }
393
+ return { rows: [...byKey.values()].sort((a, b) => `${a.type}${a.apiName}`.localeCompare(`${b.type}${b.apiName}`)), unmapped, notes };
394
+ }
395
+ /**
396
+ * A claim whose ONLY declared revisions of the component are deletions.
397
+ *
398
+ * It is not a claim to OWN the Configuration Item — the requirement built nothing — and it is
399
+ * deliberately kept out of every attribution decision below: it does not amend the row, it does not
400
+ * contest one, and it never synthesizes one. What it CAN still do is name the requirement a landed
401
+ * destructive row belongs to, which is the one case where a deletion has a Campfire footprint.
402
+ *
403
+ * The `!undeclared` half matters: a requirement with one delete entry and one entry declaring
404
+ * nothing has not established that removal is all it does, so it stays an ordinary claimant and
405
+ * contests exactly as it would have before deletions were expressible.
406
+ */
407
+ const isRemover = (c) => !c.undeclared && c.intents.has("delete") && !c.intents.has("create") && !c.intents.has("update");
408
+ /**
409
+ * Settle one component's claims, or say why they cannot be settled (R11).
410
+ *
411
+ * THE RULE: a Configuration Item is the ITEM, not the change. A dual claim where one requirement
412
+ * declares `intent: "create"` and the others declare `"update"` resolves to the CREATOR — one row,
413
+ * filed against the requirement that brought the component into existence, with the updates recorded
414
+ * as amendments on it. That is the class 004 §5 deliberately left UNATTRIBUTED, now well-defined.
415
+ *
416
+ * Everything else contests exactly as it did before the revision model: two creators is a genuine
417
+ * disagreement about who owns the component, all-updates leaves no creator to file it against, and
418
+ * an undeclared intent is an absence of evidence (see `PlanClaim.undeclared`).
419
+ *
420
+ * A DELETE CLAIM IS NOT A CLAIM ON THE ROW. Removers are set aside before any of that runs
421
+ * (`isRemover`), so a requirement retiring what another built neither contests the creator's
422
+ * ownership nor annotates it. That is the writeback caveat the live workaround had to state in
423
+ * prose and could not enforce: with `intent: "update"` as the only legal way to say "delete", the
424
+ * deleting requirement registered as an `amendedBy` annotation on the creating requirement's
425
+ * Configuration Item — an amendment for a change that had not landed and might never land. Nothing
426
+ * is recorded here until the deletion actually lands, at which point the destructive bundle
427
+ * produces the row and `remover` is who it is filed against.
428
+ */
429
+ function resolveClaims(claims) {
430
+ const removers = claims.filter(isRemover);
431
+ const builders = claims.filter((c) => !isRemover(c));
432
+ const creators = builders.filter((c) => c.intents.has("create"));
433
+ const attribution = {
434
+ ...(creators.length === 1 ? { createdBy: creators[0].extId } : {}),
435
+ // An amender declares an update and no create of its own. A requirement declaring both halves
436
+ // authored the chain itself: it is the owner, not an annotation on someone else's row.
437
+ amendedBy: builders.filter((c) => !c.undeclared && c.intents.has("update") && !c.intents.has("create")).map((c) => c.extId)
438
+ };
439
+ if (builders.length === 0) {
440
+ // Nothing in this batch builds it — every claimant only wants it gone. One remover is who a
441
+ // landed destructive row is filed against; two is a genuine disagreement about who owns the
442
+ // removal, and read order is not entitled to settle that either.
443
+ const remover = removers.length === 1 ? removers[0] : undefined;
444
+ if (remover) {
445
+ return {
446
+ attribution,
447
+ settled: {
448
+ extId: remover.extId,
449
+ deployable: remover.deployable,
450
+ approvedDetails: remover.approvedDetails,
451
+ deleteOnly: true
452
+ }
453
+ };
454
+ }
455
+ return { attribution, contest: "no-creator" };
456
+ }
457
+ const only = builders.length === 1 ? builders[0] : undefined;
458
+ if (only) {
459
+ return {
460
+ attribution,
461
+ settled: { extId: only.extId, deployable: only.deployable, approvedDetails: only.approvedDetails }
462
+ };
463
+ }
464
+ if (builders.some((c) => c.undeclared))
465
+ return { attribution, contest: "intent-unknown" };
466
+ if (creators.length === 0)
467
+ return { attribution, contest: "no-creator" };
468
+ if (creators.length > 1)
469
+ return { attribution, contest: "double-create" };
470
+ return {
471
+ attribution,
472
+ settled: {
473
+ extId: creators[0].extId,
474
+ deployable: creators[0].deployable,
475
+ approvedDetails: creators[0].approvedDetails
476
+ }
477
+ };
478
+ }
479
+ /**
480
+ * The amendment sentence for one row, or undefined when there is nothing to record.
481
+ *
482
+ * WHERE IT LANDS, AND WHY THERE: `note`, which `campfire/configItemDescription.ts`'s `describeConfigItem`
483
+ * composes into `Description__c`. That is the whole of a Configuration Item's free text in Campfire, so it is
484
+ * the only surface an amendment can reach WITHOUT an org schema change. It is recomposed from the
485
+ * derivation on every pass, so a re-writeback upserts an identical body under the same external id:
486
+ * idempotent by construction, and still one row per component.
487
+ *
488
+ * `note` CARRIES AMENDMENTS, NOT OBSERVED FACTS. A Flow's status was once seeded here too, and
489
+ * because this function APPENDS, the two shared one string — the generator also reads the status
490
+ * out of the observed body, so the row stated it twice. Observed facts travel as data on the
491
+ * delivery instead; see observedFlowFacets.
492
+ *
493
+ * A deployment task was the alternative (006 §8). Rejected: a task is a TODO for a human, and an
494
+ * amendment that already landed is neither outstanding nor manual — filing one would inflate
495
+ * `Number_of_Deployment_Tasks__c` with work that is done.
496
+ */
497
+ function amendmentNote(owner, { createdBy, amendedBy }) {
498
+ const others = amendedBy.filter((extId) => extId !== owner);
499
+ if (others.length > 0) {
500
+ return `Amended by ${others.join(", ")}${createdBy ? ` after ${createdBy} created it` : ""} — a Configuration Item is the component, not the change, so the later revision is recorded here rather than as a second row.`;
501
+ }
502
+ if (!amendedBy.includes(owner))
503
+ return undefined;
504
+ return createdBy && createdBy !== owner
505
+ ? `Filed against ${owner}, whose revision UPDATES this component; ${createdBy} declared the create revision.`
506
+ : `${owner}'s revision UPDATES this component; nothing in this batch created it, so the Configuration Item is filed against the requirement that changed it.`;
507
+ }
508
+ /** Stamp the revision facts onto a row that has just been attributed to `owner`. */
509
+ function applyAttribution(row, owner, attribution) {
510
+ if (attribution.amendedBy.length === 0)
511
+ return;
512
+ row.amendedBy = [...attribution.amendedBy];
513
+ const sentence = amendmentNote(owner, attribution);
514
+ if (sentence)
515
+ row.note = row.note ? `${row.note} ${sentence}` : sentence;
516
+ }
517
+ /** A `waves` label that matches no evidence bundle. A caller mistake, not an internal fault. */
518
+ export class UnknownWaveError extends Error {
519
+ code = "UNKNOWN_WAVE";
520
+ constructor(message) {
521
+ super(message);
522
+ this.name = "UnknownWaveError";
523
+ }
524
+ }
525
+ export async function configItemsForSession(opts) {
526
+ // Bundles now live under the session, so they are DISCOVERABLE rather than dictated. Passing
527
+ // absolute paths by hand was only ever necessary because evidence was filed by org alias
528
+ // somewhere else entirely — one more thing the model had to carry correctly between calls.
529
+ let bundleDirs = opts.bundleDirs ?? [];
530
+ if (bundleDirs.length === 0 && opts.sessionDir) {
531
+ const root = path.join(opts.sessionDir, "direct");
532
+ if (opts.waves?.length) {
533
+ // Named waves resolve to bundle dirs by label. A label that does not exist is an ERROR
534
+ // rather than an empty contribution: silently deriving nothing from a misspelled wave would
535
+ // publish a manifest that is missing components and looks complete.
536
+ const missing = [];
537
+ for (const label of opts.waves) {
538
+ const dir = path.join(root, label);
539
+ try {
540
+ if ((await fs.stat(dir)).isDirectory())
541
+ bundleDirs.push(dir);
542
+ else
543
+ missing.push(label);
544
+ }
545
+ catch {
546
+ missing.push(label);
547
+ }
548
+ }
549
+ if (missing.length > 0) {
550
+ throw new UnknownWaveError(`no evidence bundle for wave(s): ${missing.join(", ")}. Bundle labels are the \`label\` passed to direct_apply; omit \`waves\` to derive from every bundle in the session.`);
551
+ }
552
+ bundleDirs.sort();
553
+ }
554
+ else {
555
+ try {
556
+ const entries = (await fs.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory());
557
+ // Sorted by name: the default label is an ISO timestamp, so this is delivery order.
558
+ bundleDirs = entries.map((e) => path.join(root, e.name)).sort();
559
+ }
560
+ catch {
561
+ // No bundles yet is a normal state (nothing deployed), not an error.
562
+ }
563
+ }
564
+ }
565
+ const base = await configItemsFromBundles(bundleDirs);
566
+ const rows = [...base.rows];
567
+ const unmapped = [...base.unmapped];
568
+ const notes = [...base.notes];
569
+ const ownershipGaps = [];
570
+ const ownershipConflicts = [];
571
+ const owner = new Map();
572
+ // Every plan.json claim per component, not just the winning one. `owner` can only hold one
573
+ // requirement per key, so it cannot tell "one claimant" from "the last of three" — and that
574
+ // difference is exactly what has to be reported or resolved rather than settled by read order.
575
+ const planClaims = new Map();
576
+ let plansFound = 0;
577
+ const reqDir = opts.sessionDir ? path.join(opts.sessionDir, "requirements") : undefined;
578
+ if (!opts.sessionDir) {
579
+ notes.push("No sessionDir given — rows cover only what these bundles deployed. A requirement that OWNS an already-existing component will be UNDER-COUNTED. Pass sessionDir so the plan.json ownership join runs.");
580
+ }
581
+ else if (reqDir) {
582
+ let extIds = [];
583
+ try {
584
+ extIds = (await fs.readdir(reqDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
585
+ }
586
+ catch {
587
+ notes.push(`No requirements/ under ${opts.sessionDir} — ownership join skipped.`);
588
+ }
589
+ for (const extId of extIds) {
590
+ // Through `core/planFile.ts` rather than a local shape: `intent` is what decides a dual claim
591
+ // now, and four readers each defaulting their own subset of this file is how they came to
592
+ // disagree about what one entry means.
593
+ const parsed = await readPlanFile(path.join(reqDir, extId, "plan.json"), extId);
594
+ if (!parsed.ok) {
595
+ // A missing plan.json is the normal "analyzer has not run" state, reported in aggregate
596
+ // below. A plan that EXISTS and cannot be read is a different fact: it drops a requirement's
597
+ // every ownership claim, which reads downstream as components nobody owns.
598
+ if (parsed.error.code !== "PLAN_NOT_FOUND")
599
+ notes.push(parsed.error.message);
600
+ continue;
601
+ }
602
+ plansFound += 1;
603
+ // The DOCUMENT's extId still wins over the directory name — two dirs carrying one
604
+ // requirement's plan are one claimant, not a dual claim.
605
+ const owning = parsed.plan.declaredExtId ?? extId;
606
+ for (const refusal of parsed.plan.refusals) {
607
+ // Only the intent refusal changes what this join can settle: an entry whose `intent` was
608
+ // rejected reads as UNDECLARED, so a dual claim it belongs to contests. Say so, or the
609
+ // contest looks like plans that simply never declared anything.
610
+ if (refusal.code === "INVALID_INTENT")
611
+ notes.push(refusal.message);
612
+ }
613
+ for (const entry of parsed.plan.entries) {
614
+ let claims = planClaims.get(entry.key);
615
+ if (!claims) {
616
+ claims = [];
617
+ planClaims.set(entry.key, claims);
618
+ }
619
+ let claim = claims.find((c) => c.extId === owning);
620
+ if (!claim) {
621
+ claim = { extId: owning, intents: new Set(), undeclared: false, deployable: true, approvedDetails: [] };
622
+ claims.push(claim);
623
+ }
624
+ if (entry.intent)
625
+ claim.intents.add(entry.intent);
626
+ else
627
+ claim.undeclared = true;
628
+ claim.deployable = entry.deployable;
629
+ const details = Array.isArray(entry.raw.operatorDecidedFacets)
630
+ ? entry.raw.operatorDecidedFacets.filter((value) => typeof value === "string" && value.trim() !== "")
631
+ : [];
632
+ claim.approvedDetails = [...new Set([...claim.approvedDetails, ...details])];
633
+ }
634
+ }
635
+ if (plansFound === 0 && !(opts.ownership?.length)) {
636
+ notes.push(`No plan.json found under ${reqDir} (${extIds.length} requirement dir(s)) and no ownership override supplied — the analyzers did not run, so no row can be mapped to a requirement.`);
637
+ }
638
+ }
639
+ // Settle every claimed key BEFORE the override runs, so `owner` holds the revision model's answer
640
+ // rather than whichever entry `readdir` yielded last. A key that cannot be settled goes to
641
+ // `planContests` and stays OUT of `owner` — an unattributed row is what makes the tools refuse.
642
+ const planContests = new Map();
643
+ const attributions = new Map();
644
+ for (const [key, claims] of planClaims) {
645
+ const { attribution, settled, contest } = resolveClaims(claims);
646
+ attributions.set(key, attribution);
647
+ if (settled) {
648
+ owner.set(key, {
649
+ extId: settled.extId,
650
+ deployable: settled.deployable,
651
+ source: "plan",
652
+ attribution,
653
+ approvedDetails: settled.approvedDetails,
654
+ ...(settled.deleteOnly ? { deleteOnly: true } : {})
655
+ });
656
+ }
657
+ else if (contest)
658
+ planContests.set(key, contest);
659
+ }
660
+ // The override goes on TOP of plan.json, and it is the one place a contested component CAN be
661
+ // settled — the supervisor is consolidating the batch and is entitled to make the call, on the
662
+ // record. So an override that names one of the claiming requirements RESOLVES the contest
663
+ // (noted as asserted); an override that names a requirement no claiming plan mentions is a
664
+ // disagreement with all of them, and is reported rather than obeyed.
665
+ const overridden = new Map();
666
+ const overrideConflicts = new Map();
667
+ for (const claim of opts.ownership ?? []) {
668
+ if (!claim.typeName || !claim.fullName || !claim.extId)
669
+ continue;
670
+ const key = `${claim.typeName}:${claim.fullName}`;
671
+ const claimants = (planClaims.get(key) ?? []).map((c) => c.extId).sort();
672
+ const priorOverride = overridden.get(key);
673
+ if (priorOverride && priorOverride !== claim.extId) {
674
+ // The override contradicting ITSELF. Same defect as the one this function exists to close —
675
+ // array order is not an opinion about who owns a component either.
676
+ overrideConflicts.set(key, {
677
+ claimants: [...new Set([...claimants, priorOverride, claim.extId])],
678
+ override: claim.extId
679
+ });
680
+ }
681
+ else if (claimants.length > 0 && !claimants.includes(claim.extId)) {
682
+ overrideConflicts.set(key, {
683
+ claimants: [...claimants, claim.extId],
684
+ plan: claimants.length === 1 ? claimants[0] : undefined,
685
+ override: claim.extId
686
+ });
687
+ }
688
+ else if (claimants.length > 1) {
689
+ // A key the revision model already settled is not a disagreement — saying "the plans still
690
+ // disagree" about a declared create-then-update pair would report the model's own answer as a
691
+ // defect, and send the operator to fix plans that are correct.
692
+ const settled = owner.get(key);
693
+ notes.push(settled?.source === "plan"
694
+ ? `${key}: the revision model files this under ${settled.extId}, the requirement that CREATED it; your ownership override files it under ${claim.extId} instead. ASSERTED, not computed.`
695
+ : `${key}: claimed by ${claimants.join(" and ")}; your ownership override files it under ${claim.extId}. ASSERTED, not computed — the plans still disagree.`);
696
+ }
697
+ overridden.set(key, claim.extId);
698
+ owner.set(key, {
699
+ extId: claim.extId,
700
+ deployable: true,
701
+ source: "supervisor",
702
+ attribution: attributions.get(key) ?? { amendedBy: [] },
703
+ approvedDetails: (planClaims.get(key) ?? []).find((candidate) => candidate.extId === claim.extId)?.approvedDetails ?? []
704
+ });
705
+ }
706
+ // Contested components stay OUT of `owner`: an unattributed row is what makes the tools refuse,
707
+ // and refusing is the whole point. Sorted so the report does not depend on readdir order either.
708
+ for (const [key, reason] of planContests) {
709
+ if (overridden.has(key))
710
+ continue;
711
+ ownershipConflicts.push({
712
+ component: key,
713
+ kind: "plan",
714
+ claimants: (planClaims.get(key) ?? []).map((c) => c.extId).sort(),
715
+ reason
716
+ });
717
+ }
718
+ for (const [key, c] of overrideConflicts) {
719
+ ownershipConflicts.push({ component: key, kind: "override", claimants: c.claimants, plan: c.plan, override: c.override });
720
+ }
721
+ ownershipConflicts.sort((a, b) => a.component.localeCompare(b.component));
722
+ const contested = new Set(ownershipConflicts.map((c) => c.component));
723
+ for (const key of contested)
724
+ owner.delete(key);
725
+ if (owner.size === 0) {
726
+ return {
727
+ rows,
728
+ unmapped,
729
+ notes,
730
+ ownershipGaps,
731
+ ownershipSource: "none",
732
+ ownershipConflicts,
733
+ bundlesRead: bundleDirs,
734
+ requirementDisclosures: []
735
+ };
736
+ }
737
+ // 1. Attach the owning requirement to each deployed row; flag deployed-but-unowned.
738
+ const seen = new Set();
739
+ for (const row of rows) {
740
+ // metadataKey first — it is the post-expansion component in the SAME vocabulary plan.json
741
+ // uses, so a container child (WorkflowAlert:Obj.Alert) matches even though the row's
742
+ // Campfire `type` is WorkflowEmailAlert.
743
+ const resolved = owner.get(row.metadataKey) ??
744
+ owner.get(row.sourceComponent) ??
745
+ (row.expandedFrom ? owner.get(row.expandedFrom) : undefined);
746
+ if (resolved) {
747
+ row.requirement = resolved.extId;
748
+ if (resolved.approvedDetails.length > 0)
749
+ row.approvedDetails = [...resolved.approvedDetails];
750
+ applyAttribution(row, resolved.extId, resolved.attribution);
751
+ }
752
+ // A contested row is NOT a gap. "Nobody claims it" and "two requirements claim it" have
753
+ // different fixes, and reporting the second as the first sends the operator to write an
754
+ // ownership override for a component that already has too many owners.
755
+ else if (!contested.has(row.metadataKey) && !contested.has(row.sourceComponent) && !(row.expandedFrom && contested.has(row.expandedFrom))) {
756
+ ownershipGaps.push({
757
+ component: row.metadataKey,
758
+ reason: "Deployed but no plan.json claims ownership — this Configuration Item cannot be mapped to a requirement."
759
+ });
760
+ }
761
+ seen.add(row.metadataKey);
762
+ seen.add(row.sourceComponent);
763
+ if (row.expandedFrom)
764
+ seen.add(row.expandedFrom);
765
+ }
766
+ // Index deployed rows by the Campfire identity (Type + API Name). A plan can name a component
767
+ // differently from how it was finally delivered — live-caught: EXT-654E planned an
768
+ // object-specific QuickAction and the operator approved shipping it GLOBAL, so the plan key and
769
+ // the deployed key differ while both mean the SAME Configuration Item. Adding the plan's
770
+ // version would double-count, which is precisely the accuracy failure this tool exists to stop.
771
+ const deployedByIdentity = new Map();
772
+ for (const r of rows)
773
+ deployedByIdentity.set(`${r.type}|${r.apiName}`, r);
774
+ // 2. Add OWNED components no bundle touched — the under-count this join exists to fix.
775
+ //
776
+ // SKIPPED when `waves` scoped the derivation. Step 1 above still ATTACHES the owning requirement
777
+ // to every delivered row (that is what makes a row writable at all), but adding the
778
+ // owned-but-not-delivered components back would defeat the scoping entirely: the caller asked
779
+ // for one wave and would get the whole requirement's manifest again, merely relabelled
780
+ // PRE-EXISTING. Live-caught while wiring this — scoping to a single wave still produced all 19
781
+ // rows, so per-wave writeback would have re-saved every row and inflated exactly the counters
782
+ // the scoping exists to protect.
783
+ const scopedToWaves = Boolean(opts.waves?.length);
784
+ if (scopedToWaves) {
785
+ notes.push(`scoped to wave(s) ${opts.waves?.join(", ")}: rows are what these bundles DELIVERED. Owned-but-already-existing components are omitted — derive without \`waves\` at end of run for each requirement's complete manifest.`);
786
+ }
787
+ for (const [compKey, { extId, deployable, attribution, deleteOnly }] of scopedToWaves ? [] : owner) {
788
+ if (seen.has(compKey))
789
+ continue;
790
+ if (deleteOnly) {
791
+ // A DELETION THAT HAS NOT LANDED RECORDS NOTHING. `seen` covers every component this run's
792
+ // bundles touched, so reaching here means no destructive package carried this member — the
793
+ // removal was planned, ordered, and not executed. Synthesizing a `pre-existing` row for it
794
+ // would put a component on the manifest that the requirement's whole work is to remove, and
795
+ // mark it delivered. Nothing until landed is the honest record; once it lands, the destructive
796
+ // bundle produces the row above and step 1 files it against this same requirement.
797
+ notes.push(`${extId}: plans the DELETION of ${compKey}, and no bundle in this derivation carried it — so it contributes no Configuration Item. Nothing is recorded for a deletion until it lands.`);
798
+ continue;
799
+ }
800
+ const sep = compKey.indexOf(":");
801
+ const typeName = compKey.slice(0, sep);
802
+ const fullName = compKey.slice(sep + 1);
803
+ const derived = deriveConfigItem(typeName, fullName);
804
+ if (isConfigItemError(derived)) {
805
+ unmapped.push({ component: compKey, bundle: `plan:${extId}`, reason: derived.error });
806
+ continue;
807
+ }
808
+ const identity = `${derived.type}|${derived.apiName}`;
809
+ const already = deployedByIdentity.get(identity);
810
+ if (already) {
811
+ // Same Configuration Item, named differently by plan vs delivery. Keep the DELIVERED row
812
+ // (it reflects the org) and attach ownership rather than emitting a second row.
813
+ if (!already.requirement) {
814
+ already.requirement = extId;
815
+ applyAttribution(already, extId, attribution);
816
+ }
817
+ const gapIdx = ownershipGaps.findIndex((g) => g.component === already.metadataKey);
818
+ if (gapIdx >= 0)
819
+ ownershipGaps.splice(gapIdx, 1);
820
+ if (already.metadataKey !== compKey) {
821
+ const disclosure = `The plan owns ${compKey}, but it was delivered as ${already.metadataKey}; ` +
822
+ `the delivered Configuration Item shape is authoritative.`;
823
+ notes.push(`${extId}: ${disclosure}`);
824
+ already.disclosureNotes = [...(already.disclosureNotes ?? []), disclosure];
825
+ }
826
+ continue;
827
+ }
828
+ const row = {
829
+ ...derived,
830
+ sourceComponent: compKey,
831
+ metadataKey: compKey,
832
+ destructive: false,
833
+ // Not in this run's evidence, so this run did not verify it. Its presence in the org is
834
+ // what the analyzer asserted from the projection, not something we re-proved here.
835
+ verified: false,
836
+ requirement: extId,
837
+ origin: deployable ? "pre-existing" : "routed",
838
+ ...(owner.get(compKey)?.approvedDetails.length ? { approvedDetails: owner.get(compKey).approvedDetails } : {})
839
+ };
840
+ applyAttribution(row, extId, attribution);
841
+ rows.push(row);
842
+ }
843
+ rows.sort((a, b) => `${a.requirement ?? "zz"}${a.type}${a.apiName}`.localeCompare(`${b.requirement ?? "zz"}${b.type}${b.apiName}`));
844
+ // Report where the attributions actually came from, so a reviewer can tell a computed manifest
845
+ // from an asserted one at a glance — they carry different confidence.
846
+ const sources = new Set(rows.filter((r) => r.requirement).map((r) => owner.get(r.metadataKey)?.source ?? "plan"));
847
+ const ownershipSource = rows.every((r) => !r.requirement) ? "none" : sources.size > 1 ? "mixed" : sources.has("supervisor") ? "supervisor" : "plan";
848
+ const disclosuresByRequirement = new Map();
849
+ const disclose = (requirement, component, note) => {
850
+ const current = disclosuresByRequirement.get(requirement) ?? { components: new Set(), notes: [] };
851
+ current.components.add(component);
852
+ if (!current.notes.includes(note))
853
+ current.notes.push(note);
854
+ disclosuresByRequirement.set(requirement, current);
855
+ };
856
+ // A held row writes NOTHING — its disclosure would tell the operator to paste "The
857
+ // Configuration Item records the observed org value" about a Campfire record that does not
858
+ // exist. The row discloses when it writes, after the operator reconciles it.
859
+ const heldKeys = new Set(heldForDeliveryTruth(rows).map(({ row }) => row.metadataKey));
860
+ for (const row of rows) {
861
+ if (!row.requirement || heldKeys.has(row.metadataKey))
862
+ continue;
863
+ for (const note of row.disclosureNotes ?? [])
864
+ disclose(row.requirement, row.metadataKey, note);
865
+ // Scoped to the FINAL delivery only, mirroring `deviations()` in configItemDescription.ts —
866
+ // this sentence asserts what the Configuration Item row records, and that row describes the
867
+ // final delivery (see finalConfiguration/deviations there). A component that differed in an
868
+ // earlier wave and matched in a later, corrective wave has no deviation text on its CI row;
869
+ // disclosing from the earlier delivery would tell the operator the record says something it
870
+ // does not. Keep this rule identical to deviations()'s so the two cannot drift apart.
871
+ const finalDelivery = (row.deliveries ?? []).at(-1);
872
+ if (finalDelivery && finalDelivery.readBack === "differed") {
873
+ const differences = (finalDelivery.differences ?? [])
874
+ .map((difference) => `${difference.path}: org stored ${difference.observed} (approved ${difference.intended})`)
875
+ .join("; ");
876
+ disclose(row.requirement, row.metadataKey, `${row.metadataKey} differed from the approved configuration after delivery${differences ? ` — ${differences}` : ""}. ` +
877
+ `The Configuration Item records the observed org value.`);
878
+ }
879
+ }
880
+ const requirementDisclosures = [...disclosuresByRequirement]
881
+ .sort(([a], [b]) => a.localeCompare(b))
882
+ .map(([requirement, disclosure]) => ({
883
+ requirement,
884
+ components: [...disclosure.components].sort(),
885
+ text: disclosure.notes.join(" ")
886
+ }));
887
+ return {
888
+ rows,
889
+ unmapped,
890
+ notes,
891
+ ownershipGaps,
892
+ ownershipSource,
893
+ ownershipConflicts,
894
+ bundlesRead: bundleDirs,
895
+ requirementDisclosures
896
+ };
897
+ }
898
+ /** Why a dual claim survived the revision model, in the operator's terms. */
899
+ function contestReason(reason) {
900
+ switch (reason) {
901
+ case "double-create":
902
+ return 'both declare `intent: "create"`, so they genuinely disagree about who creates it';
903
+ case "no-creator":
904
+ return 'no claim is an `intent: "create"` — nothing in this batch created it, so there is no creator to file it against (two requirements each declaring only its DELETION reaches here too: they disagree about who owns the removal)';
905
+ case "intent-unknown":
906
+ return "at least one of them declares no `intent`, so create-vs-update cannot be told apart (this derivation reads no org projection and will not guess)";
907
+ default:
908
+ return "two or more plan.json";
909
+ }
910
+ }
911
+ /**
912
+ * The refusal a derivation demands before ANY of it is written back, or null when it is writable.
913
+ *
914
+ * Shared by the preview (`campfire_config_item_plan`) and the write (`campfire_write_config_items`)
915
+ * so the two cannot disagree about what is writable — a preview that passes while the write refuses
916
+ * sends the operator to Campfire with a manifest that will not land.
917
+ *
918
+ * CONTESTED IS CHECKED BEFORE UNOWNED. A contested component is deliberately left unattributed, so
919
+ * checking "unowned" first would report OWNERSHIP_UNRESOLVED — "no requirement claims this" — for a
920
+ * component two requirements are fighting over, and point the fix at the wrong end.
921
+ */
922
+ export function ownershipRefusal(d) {
923
+ if (d.ownershipConflicts.length > 0) {
924
+ return {
925
+ code: "OWNERSHIP_CONFLICT",
926
+ message: [
927
+ `${d.ownershipConflicts.length} component(s) are claimed by more than one requirement. Which requirement a`,
928
+ "Configuration Item is filed against is not something read order can decide, so NOTHING was written:",
929
+ ...d.ownershipConflicts.map((c) => c.kind === "override"
930
+ ? c.plan
931
+ ? ` - ${c.component}: plan.json says ${c.plan}, your ownership override says ${c.override}`
932
+ : ` - ${c.component}: named for ${c.claimants.join(", ")} — your ownership override did not settle it`
933
+ : ` - ${c.component}: claimed by ${c.claimants.join(" and ")} — ${contestReason(c.reason)}`),
934
+ "",
935
+ "Resolve it, do not let an ordering resolve it:",
936
+ " 1. Fix plan.json — exactly one requirement OWNS a component; the others list it under dependsOn.",
937
+ " 2. Or pass `ownership: [{typeName, fullName, extId}, ...]` naming one of the claiming requirements.",
938
+ " That is recorded as ASSERTED by you rather than computed, and it resolves the contest.",
939
+ "",
940
+ "A create-then-update pair does NOT reach this refusal. When one claimant's plan.json declares",
941
+ '`intent: "create"` and the others declare `"update"`, the Configuration Item is filed against the',
942
+ "CREATOR automatically — a CI is the component, not the change — and each update is recorded as an",
943
+ "amendment on that one row, so the component still counts once. The claims above could not be read",
944
+ "that way; declaring the missing intents is the fix that makes them computable."
945
+ ].join("\n")
946
+ };
947
+ }
948
+ const unowned = d.rows.filter((r) => !r.requirement);
949
+ if (unowned.length === 0)
950
+ return null;
951
+ return {
952
+ code: "OWNERSHIP_UNRESOLVED",
953
+ message: [
954
+ `${unowned.length} of ${d.rows.length} Configuration Item(s) map to no requirement, so NOTHING was written:`,
955
+ ...unowned.map((r) => ` - ${r.metadataKey}`),
956
+ "",
957
+ "Two ways to resolve, both legitimate:",
958
+ " 1. Run the requirement analyzers so each writes its plan.json (the normal path — ownership is then COMPUTED).",
959
+ " 2. Pass `ownership: [{typeName, fullName, extId}, ...]` with the mapping you already worked out reading the batch (ASSERTED — use when a small batch did not warrant analyzers).",
960
+ ...(d.ownershipSource === "none" ? [] : [`Current attribution source: ${d.ownershipSource}.`])
961
+ ].join("\n")
962
+ };
963
+ }
964
+ /**
965
+ * How to make a held Configuration Item writable. Single-sourced so the plan preview, the write
966
+ * report and the playbook cannot drift apart in what they tell the operator to do — a hold
967
+ * without a printed remedy is a dead end for a non-technical operator.
968
+ */
969
+ export const DELIVERY_TRUTH_REMEDY = "To make a held row writable: re-read the component from the target org so an authoritative post-delivery image exists " +
970
+ "(re-project it — for an AMBIGUOUS_DELIVERY wave, reconcile the wave first so the verdict covers it), " +
971
+ "then re-run campfire_config_item_plan and campfire_write_config_items. The row writes as soon as its delivery truth is observed.";
972
+ /**
973
+ * The deployed rows whose post-deploy org read never established what happened. A deploy response
974
+ * is a claim; the independent post-image is the evidence — a row on this list must not be
975
+ * published as delivered, because the Campfire record would repeat the request as fact.
976
+ *
977
+ * PER ROW, deliberately: the write holds exactly these and writes the rest, mirroring the write
978
+ * loop's own doctrine that one bad row must not strand the manifest. A batch-wide refusal was the
979
+ * first shape here and it inverted that doctrine — one ambiguous wave blocked every confirmed row
980
+ * in the session, with nothing the operator could do but fix ALL of it before writing ANY of it.
981
+ */
982
+ export function heldForDeliveryTruth(rows) {
983
+ const held = [];
984
+ for (const row of rows) {
985
+ if (row.origin !== "deployed")
986
+ continue;
987
+ if (!row.deliveries?.length) {
988
+ held.push({ row, reason: "no delivery observation — nothing on disk says what this deploy did to the org" });
989
+ continue;
990
+ }
991
+ // A delete the org refused: the row's request says destroy, the org still holds the component.
992
+ // Writing it would record a deletion that did not happen; the fix is the operator's call
993
+ // (retry the delete, or drop it from the request), not a description's.
994
+ const refusedDelete = row.destructive
995
+ ? row.deliveries.filter((delivery) => delivery.differences?.some((d) => d.observed === "(still present)"))
996
+ : [];
997
+ if (refusedDelete.length > 0) {
998
+ held.push({
999
+ row,
1000
+ reason: `the delete did not land — the org still holds this component (${refusedDelete.map((d) => `[${d.bundle}]`).join(", ")}); retry the delete or drop it from the request, then re-run`
1001
+ });
1002
+ continue;
1003
+ }
1004
+ const unconfirmed = row.deliveries.filter((delivery) => delivery.readBack === "unconfirmed");
1005
+ if (unconfirmed.length > 0) {
1006
+ held.push({
1007
+ row,
1008
+ reason: `no authoritative org read-back for ${unconfirmed.map((d) => `[${d.bundle}]`).join(", ")} — the post-deploy read did not cover this component`
1009
+ });
1010
+ }
1011
+ }
1012
+ return held;
1013
+ }