bmad-method-test-architecture-enterprise 3.0.2 → 3.2.0

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.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Offline review and promotion of an exploratory candidate.
3
+ *
4
+ * A candidate that ran leaves two observations (the baseline arm and the candidate arm), each pointing at the retained
5
+ * evidence it summarizes. This module reads that evidence and, when an independent reference supplies the expected
6
+ * behavior, classifies what the candidate did:
7
+ *
8
+ * - `target-error`: an arm recorded errors and not one completed trial.
9
+ * - `undetermined`: an arm is short of its planned trials, an evidence file is absent or no longer digests to what the
10
+ * record names, the baseline varies between trials, the arms ran different inputs, the outputs differ with no
11
+ * independent label to say which is right, or the baseline itself departs from the reference.
12
+ * - `observationally-unchanged`: every sampled input produced the same output on both arms. That says nothing about an
13
+ * input nobody sampled, so it is never read as equivalence.
14
+ * - `confirmed-undesirable-caught` and `confirmed-undesirable-missed`: the candidate arm departs from an independent
15
+ * reference where the baseline matches it; the evaluator passed at least one departing trial (missed) or failed every one (caught).
16
+ *
17
+ * The classification is a suggestion that decides nothing. A review records a person's decision, bound to the
18
+ * candidate's identity, its determination and the observations it rested on, and a confirmed or unchanged conclusion is
19
+ * accepted only where the evidence classifies the same way. A reviewer can always decline to conclude. A promotion turns
20
+ * a reviewed, confirmed candidate into the draft native `replace-exact` mutation and records no qualification: the draft
21
+ * is checked, compiled, sealed, qualified and scored by the native path like any authored mutation, and the record
22
+ * carries no receipt until that path produces one.
23
+ *
24
+ * Nothing here calls a model, a process or the network, and nothing writes outside `discovery/` and the draft mutation.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+
32
+ const {
33
+ CANDIDATE_PATH,
34
+ REVIEW_DISPOSITIONS,
35
+ bytesDigest,
36
+ candidatePath,
37
+ canonicalJson,
38
+ decisionScope,
39
+ draftMutation,
40
+ readCandidate,
41
+ readHeldFile,
42
+ repeatedKeys,
43
+ validateCandidate,
44
+ } = require('./candidate');
45
+
46
+ /** A review or promotion that cannot proceed; `problems` lists every reason. */
47
+ class ReviewError extends Error {
48
+ constructor(problems) {
49
+ super(`cannot review or promote the candidate: ${problems.join('; ')}`);
50
+ this.name = 'ReviewError';
51
+ this.problems = problems;
52
+ }
53
+ }
54
+
55
+ const ARMS = Object.freeze(['baseline', 'candidate']);
56
+ const MUTATION_FILE = /^M-([0-9]{3,})\.mutation\.json$/;
57
+ const EVALUATOR_VERDICTS = new Set(['pass', 'fail']);
58
+ /** A path inside the evaluation folder: words joined by `.`, `_` and `-`, with no empty, `.` or `..` segment and no leading separator. */
59
+ const FOLDER_PATH = /^[A-Za-z0-9][\w.-]*(?:\/[A-Za-z0-9][\w.-]*)*$/;
60
+
61
+ /** Refuses a path that is not inside the evaluation folder, before anything is read or written through it. */
62
+ function inFolder(relative, label) {
63
+ if (typeof relative !== 'string' || !FOLDER_PATH.test(relative)) {
64
+ throw new ReviewError([`${label} ${JSON.stringify(relative)} is not a path inside the evaluation folder`]);
65
+ }
66
+ }
67
+
68
+ /** A plain object, not an array or null. */
69
+ function isObject(value) {
70
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
71
+ }
72
+
73
+ /** The JSON of strict UTF-8 `bytes`, or the reason it cannot be read: not UTF-8, not JSON, or a key stated twice in one object. */
74
+ function parseStrict(bytes) {
75
+ let text;
76
+ try {
77
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
78
+ } catch {
79
+ return { problem: 'is not UTF-8 text' };
80
+ }
81
+ let value;
82
+ try {
83
+ value = JSON.parse(text);
84
+ } catch {
85
+ return { problem: 'is not JSON' };
86
+ }
87
+ const repeated = repeatedKeys(text);
88
+ if (repeated.length > 0) return { problem: `states the key ${JSON.stringify(repeated[0])} twice in one object` };
89
+ return { value };
90
+ }
91
+
92
+ /**
93
+ * The trials one observation's evidence file holds, or the reasons it cannot be used. The evidence is the JSON object
94
+ * `{arm, trials: [{trial, input, status, output?, evaluator?, error?}]}`; a completed trial of the candidate arm also
95
+ * carries the evaluator's verdict on it, `pass` or `fail`.
96
+ */
97
+ function readEvidence(observation, bytes) {
98
+ const label = `${observation.observationId} (${observation.arm})`;
99
+ if (bytes === undefined) return { problems: [`${label} evidence is not held, so the original observation cannot be read`] };
100
+ const actual = bytesDigest(bytes);
101
+ if (actual !== observation.evidence) {
102
+ return { problems: [`${label} evidence digests to ${actual}; the record retained ${observation.evidence}`] };
103
+ }
104
+ const parsed = parseStrict(bytes);
105
+ if (parsed.problem) return { problems: [`${label} evidence ${parsed.problem}`] };
106
+ const { value } = parsed;
107
+ if (!isObject(value) || value.arm !== observation.arm || !Array.isArray(value.trials)) {
108
+ return { problems: [`${label} evidence is not {arm: ${observation.arm}, trials: [...]}`] };
109
+ }
110
+ const problems = [];
111
+ const trials = [];
112
+ for (const [index, trial] of value.trials.entries()) {
113
+ const at = `${label} trials[${index}]`;
114
+ if (!isObject(trial) || typeof trial.input !== 'string' || trial.input === '') {
115
+ problems.push(`${at} names no input`);
116
+ } else if (trial.status === 'completed') {
117
+ if (typeof trial.output !== 'string') problems.push(`${at} is completed with no output`);
118
+ else if (observation.arm === 'candidate' && !EVALUATOR_VERDICTS.has(trial.evaluator)) {
119
+ problems.push(`${at} is completed with no evaluator verdict, so a miss cannot be told from a catch`);
120
+ } else trials.push({ input: trial.input, output: trial.output, evaluator: trial.evaluator });
121
+ } else if (trial.status === 'error') {
122
+ if (typeof trial.error !== 'string' || trial.error === '') problems.push(`${at} is an error with no message`);
123
+ else trials.push({ input: trial.input, error: trial.error });
124
+ } else {
125
+ problems.push(
126
+ `${at} has status ${JSON.stringify(trial.status)}; a trial is completed or an error, and an absent trial is counted as missing`,
127
+ );
128
+ }
129
+ }
130
+ const completed = trials.filter((trial) => trial.error === undefined).length;
131
+ const errors = trials.length - completed;
132
+ if (completed !== observation.trials.completed || errors !== observation.trials.errors) {
133
+ problems.push(
134
+ `${label} evidence holds ${completed} completed and ${errors} errored trials; the record counts ${observation.trials.completed} and ${observation.trials.errors}`,
135
+ );
136
+ }
137
+ return problems.length > 0 ? { problems } : { problems, trials };
138
+ }
139
+
140
+ /**
141
+ * The reference file's labels, or the reason it cannot be used. It is `{kind, reference, cases: {input: expected output}}`,
142
+ * kept at `referencePath` in the campaign's evidence directory, which is how the record cites it as independent evidence.
143
+ */
144
+ function readReference(bytes, referencePath, record) {
145
+ const directory = `discovery/${record.campaignId}/evidence/`;
146
+ if (
147
+ typeof referencePath !== 'string' ||
148
+ !referencePath.startsWith(directory) ||
149
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(referencePath.slice(directory.length)) ||
150
+ referencePath.endsWith('.candidate.json')
151
+ ) {
152
+ return { problem: `the independent reference must be kept at ${directory}<file>, so the record cites the snapshot a reviewer saw` };
153
+ }
154
+ const parsed = parseStrict(bytes);
155
+ if (parsed.problem) return { problem: `the independent reference ${parsed.problem}` };
156
+ const { value } = parsed;
157
+ const valid =
158
+ isObject(value) &&
159
+ typeof value.kind === 'string' &&
160
+ typeof value.reference === 'string' &&
161
+ value.reference !== '' &&
162
+ isObject(value.cases) &&
163
+ Object.values(value.cases).every((expected) => typeof expected === 'string');
164
+ if (!valid) return { problem: 'the independent reference is not {kind, reference, cases: {input: expected output}}' };
165
+ return {
166
+ cases: new Map(Object.entries(value.cases)),
167
+ evidence: { kind: value.kind, reference: referencePath, digest: bytesDigest(bytes) },
168
+ };
169
+ }
170
+
171
+ /** The outputs per input of an arm's completed trials, or the inputs whose repeated trials disagree. */
172
+ function outputsByInput(trials) {
173
+ const outputs = new Map();
174
+ const varying = new Set();
175
+ for (const trial of trials.filter((entry) => entry.error === undefined)) {
176
+ if (outputs.has(trial.input) && outputs.get(trial.input).output !== trial.output) varying.add(trial.input);
177
+ if (!outputs.has(trial.input)) outputs.set(trial.input, { output: trial.output, evaluators: [] });
178
+ outputs.get(trial.input).evaluators.push(trial.evaluator);
179
+ }
180
+ return { outputs, varying: [...varying] };
181
+ }
182
+
183
+ /**
184
+ * Classifies what a candidate did from the evidence the record retains.
185
+ *
186
+ * @param {object} record a candidate with observations
187
+ * @param {{evidence?: Record<string, Uint8Array>, reference?: Uint8Array, referencePath?: string}} [inputs] the bytes of each
188
+ * observation's evidence file by observation ID, the bytes of the independent reference and the path in the folder it is kept at
189
+ * @returns {{disposition: string, reasons: string[], arms: object, compared: number, deviations: object[], independentEvidence: object|null}}
190
+ * `disposition` is a candidate disposition, never a native outcome; `reasons` says why, every missing, errored or
191
+ * unreadable piece of evidence included
192
+ */
193
+ function classifyCandidate(record, { evidence = {}, reference, referencePath } = {}) {
194
+ const reasons = [];
195
+ const arms = {};
196
+ const trialsByArm = { baseline: [], candidate: [] };
197
+ for (const arm of ARMS) arms[arm] = { planned: 0, completed: 0, errors: 0, missing: 0 };
198
+ for (const observation of record.observations) {
199
+ for (const key of Object.keys(arms[observation.arm])) arms[observation.arm][key] += observation.trials[key];
200
+ const read = readEvidence(observation, evidence[observation.observationId]);
201
+ reasons.push(...read.problems);
202
+ if (read.trials) trialsByArm[observation.arm].push(...read.trials);
203
+ }
204
+ const result = (disposition, extra = {}) => ({
205
+ disposition,
206
+ reasons,
207
+ arms,
208
+ compared: 0,
209
+ deviations: [],
210
+ independentEvidence: null,
211
+ ...extra,
212
+ });
213
+
214
+ for (const arm of ARMS) {
215
+ const { planned, completed, errors, missing } = arms[arm];
216
+ if (record.observations.every((observation) => observation.arm !== arm)) {
217
+ reasons.push(`the ${arm} arm has no observation`);
218
+ } else if (completed === 0 && errors > 0) {
219
+ reasons.push(`the ${arm} arm recorded ${errors} errored trials and no completed one`);
220
+ return result('target-error');
221
+ } else if (completed === 0 || completed < planned) {
222
+ reasons.push(`the ${arm} arm completed ${completed} of ${planned} planned trials (${errors} errored, ${missing} missing)`);
223
+ }
224
+ }
225
+ if (reasons.length > 0) return result('undetermined');
226
+
227
+ const baseline = outputsByInput(trialsByArm.baseline);
228
+ const candidate = outputsByInput(trialsByArm.candidate);
229
+ for (const [arm, { varying }] of [
230
+ ['baseline', baseline],
231
+ ['candidate', candidate],
232
+ ]) {
233
+ for (const input of varying) reasons.push(`the ${arm} arm produced different outputs for input ${JSON.stringify(input)} across trials`);
234
+ }
235
+ const inputs = new Set([...baseline.outputs.keys(), ...candidate.outputs.keys()]);
236
+ for (const input of inputs) {
237
+ if (!baseline.outputs.has(input) || !candidate.outputs.has(input)) {
238
+ reasons.push(`input ${JSON.stringify(input)} ran on only one arm, so the arms are not comparable there`);
239
+ }
240
+ }
241
+ if (reasons.length > 0) return result('undetermined', { compared: inputs.size });
242
+
243
+ const differing = [...inputs].filter((input) => baseline.outputs.get(input).output !== candidate.outputs.get(input).output);
244
+ if (differing.length === 0) {
245
+ reasons.push(
246
+ `all ${inputs.size} sampled inputs produced the same output on both arms; an input nobody sampled may still differ, so this is not equivalence`,
247
+ );
248
+ return result('observationally-unchanged', { compared: inputs.size });
249
+ }
250
+
251
+ if (reference === undefined) {
252
+ reasons.push(
253
+ `the outputs differ on ${differing.length} of ${inputs.size} inputs and no independent reference says which output is right`,
254
+ );
255
+ return result('undetermined', { compared: inputs.size });
256
+ }
257
+ const labels = readReference(reference, referencePath, record);
258
+ if (labels.problem) {
259
+ reasons.push(labels.problem);
260
+ return result('undetermined', { compared: inputs.size });
261
+ }
262
+ const deviations = [];
263
+ for (const input of differing) {
264
+ const expected = labels.cases.get(input);
265
+ const seen = { input, expected, baseline: baseline.outputs.get(input).output, candidate: candidate.outputs.get(input).output };
266
+ if (expected === undefined) {
267
+ reasons.push(`input ${JSON.stringify(input)} differs between the arms and the independent reference does not label it`);
268
+ } else if (seen.baseline === expected) {
269
+ deviations.push({ ...seen, evaluators: candidate.outputs.get(input).evaluators });
270
+ } else {
271
+ reasons.push(
272
+ `the baseline departs from the independent reference on input ${JSON.stringify(input)}, so the difference is not attributable to the edit`,
273
+ );
274
+ }
275
+ }
276
+ if (reasons.length > 0 || deviations.length === 0) {
277
+ return result('undetermined', { compared: inputs.size, deviations });
278
+ }
279
+ const missed = deviations.some((deviation) => deviation.evaluators.includes('pass'));
280
+ reasons.push(
281
+ `${deviations.length} of ${inputs.size} inputs depart from the independent reference only on the candidate arm; the evaluator ${missed ? 'passed at least one departing trial' : 'failed every departing trial'}`,
282
+ );
283
+ return result(missed ? 'confirmed-undesirable-missed' : 'confirmed-undesirable-caught', {
284
+ compared: inputs.size,
285
+ deviations,
286
+ independentEvidence: labels.evidence,
287
+ });
288
+ }
289
+
290
+ /**
291
+ * The candidate with the stop the evidence settles on its own: `target-error` when an arm only errored, `undetermined`
292
+ * when the evidence is short, absent or unreadable. The record stays in the `observed` state, so the stop stays visible.
293
+ * Settling again once the evidence no longer supports a stop this function set clears it. Any other classification needs
294
+ * a person, and a disposition this function did not set is left as it is.
295
+ *
296
+ * @param {object} record an observed candidate
297
+ * @param {object} [inputs] as for {@link classifyCandidate}
298
+ * @returns {object}
299
+ */
300
+ function settleCandidate(record, inputs) {
301
+ if (record.state !== 'observed')
302
+ throw new ReviewError([`candidate ${record.candidateId} is ${record.state}; only an observed candidate settles`]);
303
+ const { disposition } = classifyCandidate(record, inputs);
304
+ const stops = disposition === 'target-error' || disposition === 'undetermined';
305
+ const settledBefore = record.disposition === 'target-error' || record.disposition === 'undetermined';
306
+ if (!stops && !settledBefore) return record;
307
+ return validateCandidate({ ...structuredClone(record), disposition: stops ? disposition : null });
308
+ }
309
+
310
+ /**
311
+ * Records a person's review of an observed candidate. The decision is bound to the candidate's identity, its
312
+ * determination and the observations it rests on, so a later change to the edit, the requirement, the conclusion or an
313
+ * observation makes the reader refuse it.
314
+ *
315
+ * @param {object} record an observed candidate
316
+ * @param {object} inputs as for {@link classifyCandidate}
317
+ * @param {{reviewer: string, decidedAt: string, rationale: string, disposition: string}} decision
318
+ * @returns {object} the reviewed candidate
319
+ * @throws {ReviewError} when the candidate is not observed, the disposition is not a review conclusion, an arm only
320
+ * errored, or the evidence does not support a confirmed or unchanged conclusion
321
+ */
322
+ function reviewCandidate(record, inputs, decision) {
323
+ const problems = [];
324
+ if (record.state !== 'observed')
325
+ problems.push(`candidate ${record.candidateId} is ${record.state}; only an observed candidate is reviewed`);
326
+ if (!REVIEW_DISPOSITIONS.includes(decision.disposition)) {
327
+ problems.push(
328
+ `${JSON.stringify(decision.disposition)} is not a review conclusion; a review concludes ${REVIEW_DISPOSITIONS.join(', ')}`,
329
+ );
330
+ }
331
+ if (problems.length > 0) throw new ReviewError(problems);
332
+ const classification = classifyCandidate(record, inputs);
333
+ if (classification.disposition === 'target-error') {
334
+ throw new ReviewError([`the evidence classifies as target-error, which no review concludes: ${classification.reasons.join('; ')}`]);
335
+ }
336
+ const declines = decision.disposition === 'undetermined' || decision.disposition === 'inapplicable';
337
+ if (!declines && decision.disposition !== classification.disposition) {
338
+ throw new ReviewError([
339
+ `the evidence classifies as ${classification.disposition}, which does not support ${decision.disposition}: ${classification.reasons.join('; ')}`,
340
+ ]);
341
+ }
342
+
343
+ const next = structuredClone(record);
344
+ next.state = 'reviewed';
345
+ next.disposition = decision.disposition;
346
+ next.independentEvidence = decision.disposition.startsWith('confirmed-undesirable') ? [classification.independentEvidence] : [];
347
+ next.review = { reviewer: decision.reviewer, decidedAt: decision.decidedAt, rationale: decision.rationale, scope: null };
348
+ next.review.scope = decisionScope(next);
349
+ return validateCandidate(next);
350
+ }
351
+
352
+ /** The ID after the highest `M-NNN` mutation the folder holds. */
353
+ function nextMutationId(folder) {
354
+ let taken = 0;
355
+ let entries = [];
356
+ try {
357
+ entries = fs.readdirSync(path.join(folder, 'mutations'));
358
+ } catch (error) {
359
+ if (error.code !== 'ENOENT') throw error;
360
+ }
361
+ for (const name of entries) {
362
+ const match = MUTATION_FILE.exec(name);
363
+ if (match) taken = Math.max(taken, Number(match[1]));
364
+ }
365
+ return `M-${String(taken + 1).padStart(3, '0')}`;
366
+ }
367
+
368
+ /**
369
+ * Promotes a reviewed, confirmed candidate to a draft mutation, as data. The result names the record as it will stand
370
+ * and the files to write; {@link applyPromotion} writes them. The draft is the native `replace-exact` mutation and the
371
+ * record's promotion holds no qualification receipt: only a native qualification produces one.
372
+ *
373
+ * The retained evidence and the reference are read again, so evidence lost or altered since the review ends the
374
+ * promotion: the stored conclusion has to be the one the evidence classifies as now.
375
+ *
376
+ * @param {string} folder the evaluation folder
377
+ * @param {string} relative the candidate's path under `discovery/`
378
+ * @param {string} referencePath the independent reference the review rested on, relative to the folder
379
+ * @returns {{record: object, draft: object, previous: string, files: Array<{path: string, content: string}>}}
380
+ * @throws {import('./candidate').CandidateError} when the stored candidate or the files it names are invalid or stale
381
+ * @throws {ReviewError} when the candidate is not reviewed with a confirmed conclusion the evidence still supports
382
+ */
383
+ /**
384
+ * Refuses a reviewed record whose retained evidence no longer classifies as the stored conclusion: the observations and the
385
+ * reference are read again from the folder, and the conclusion and the reference digest have to be the ones the record states.
386
+ */
387
+ function assertEvidenceSupports(folder, record) {
388
+ const classification = classifyCandidate(record, loadReviewInputs(folder, record, record.independentEvidence[0]?.reference));
389
+ if (
390
+ classification.disposition !== record.disposition ||
391
+ classification.independentEvidence?.digest !== record.independentEvidence[0]?.digest
392
+ ) {
393
+ throw new ReviewError([
394
+ `the evidence now classifies as ${classification.disposition}, which does not support the stored ${record.disposition}: ${classification.reasons.join('; ')}`,
395
+ ]);
396
+ }
397
+ }
398
+
399
+ function promoteCandidate(folder, relative, referencePath) {
400
+ if (typeof relative !== 'string' || !CANDIDATE_PATH.test(relative)) {
401
+ throw new ReviewError([`${JSON.stringify(relative)} is not discovery/<campaignId>/C-NNN.candidate.json`]);
402
+ }
403
+ inFolder(referencePath, 'the independent reference');
404
+ const previous = readHeldFile(folder, relative, 'candidate').toString('utf8');
405
+ const record = readCandidate(folder, relative);
406
+ if (record.state !== 'reviewed') {
407
+ throw new ReviewError([`candidate ${record.candidateId} is ${record.state}; only a reviewed candidate is promoted`]);
408
+ }
409
+ if (!record.disposition.startsWith('confirmed-undesirable')) {
410
+ throw new ReviewError([
411
+ `candidate ${record.candidateId} concluded ${record.disposition}; only a confirmed undesirable candidate is promoted`,
412
+ ]);
413
+ }
414
+ if (referencePath !== record.independentEvidence[0]?.reference) {
415
+ throw new ReviewError([
416
+ `the record cites ${record.independentEvidence[0]?.reference} as its independent evidence, not ${referencePath}`,
417
+ ]);
418
+ }
419
+ assertEvidenceSupports(folder, record);
420
+ const next = structuredClone(record);
421
+ next.state = 'promoted';
422
+ next.promotion = { draftMutationId: nextMutationId(folder), qualificationReceipt: null };
423
+ validateCandidate(next);
424
+ const draft = draftMutation(next);
425
+ if (readHeldFile(folder, relative, 'candidate').toString('utf8') !== previous) {
426
+ throw new ReviewError([`${relative} changed while the promotion was computed; promote again`]);
427
+ }
428
+ return {
429
+ record: next,
430
+ draft,
431
+ previous,
432
+ files: [
433
+ { path: `mutations/${draft.mutationId}.mutation.json`, content: `${JSON.stringify(draft, null, 2)}\n` },
434
+ { path: relative, content: canonicalJson(next) },
435
+ ],
436
+ };
437
+ }
438
+
439
+ /** Refuses a directory on the way down `segments` from `folder` that is a link or not a directory; an absent one ends the walk. */
440
+ function writableDirectory(folder, segments) {
441
+ let at = folder;
442
+ for (const segment of segments) {
443
+ at = path.join(at, segment);
444
+ let stats;
445
+ try {
446
+ stats = fs.lstatSync(at);
447
+ } catch (error) {
448
+ if (error.code === 'ENOENT') return;
449
+ throw error;
450
+ }
451
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new ReviewError([`${segments.join('/')} is a link or not a directory`]);
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Writes a promotion: the record is staged, the draft mutation is created (refusing a name the folder already holds),
457
+ * and the staged record replaces the stored one. A failure at any step leaves the folder as it was. The stored record has
458
+ * to be the one the promotion was computed from, so a decision made in between is never overwritten.
459
+ *
460
+ * @param {string} folder
461
+ * @param {ReturnType<typeof promoteCandidate>} promotion
462
+ * @throws {ReviewError} when a path on the way is a link, the stored record changed, or a file the promotion creates exists
463
+ */
464
+ function applyPromotion(folder, promotion) {
465
+ // The files are the ones the promoted record states, never caller-chosen paths or contents.
466
+ const record = validateCandidate(structuredClone(promotion?.record ?? null));
467
+ if (record.state !== 'promoted' || record.promotion === null) {
468
+ throw new ReviewError([`candidate ${record.candidateId} is ${record.state}; only a promoted record is applied`]);
469
+ }
470
+ const relative = candidatePath(record.campaignId, record.candidateId);
471
+ const files = [
472
+ { path: `mutations/${record.promotion.draftMutationId}.mutation.json`, content: `${JSON.stringify(draftMutation(record), null, 2)}\n` },
473
+ { path: relative, content: canonicalJson(record) },
474
+ ];
475
+ if (
476
+ JSON.stringify(promotion.files) !== JSON.stringify(files) ||
477
+ JSON.stringify(promotion.draft) !== JSON.stringify(draftMutation(record))
478
+ ) {
479
+ throw new ReviewError(['the promotion files are not the ones its record states']);
480
+ }
481
+ const [mutation, candidate] = files;
482
+ for (const file of files) writableDirectory(folder, path.posix.dirname(file.path).split('/'));
483
+ if (readHeldFile(folder, candidate.path, 'candidate').toString('utf8') !== promotion.previous) {
484
+ throw new ReviewError([`${candidate.path} changed after the promotion was computed; promote again`]);
485
+ }
486
+ // The stored record reads clean, its retained evidence still supports the conclusion it stores, and the promoted record is that
487
+ // record with the promotion added and nothing else changed.
488
+ const stored = readCandidate(folder, candidate.path);
489
+ assertEvidenceSupports(folder, stored);
490
+ const expected = {
491
+ ...structuredClone(stored),
492
+ state: 'promoted',
493
+ promotion: { draftMutationId: record.promotion.draftMutationId, qualificationReceipt: null },
494
+ };
495
+ if (stored.state !== 'reviewed' || canonicalJson(record) !== canonicalJson(expected)) {
496
+ throw new ReviewError(['the promoted record is not the stored reviewed record with a promotion added']);
497
+ }
498
+ if (record.promotion.draftMutationId !== nextMutationId(folder)) {
499
+ throw new ReviewError([`${record.promotion.draftMutationId} is not the next free mutation ID; promote again`]);
500
+ }
501
+ const mutationFile = path.join(folder, ...mutation.path.split('/'));
502
+ const candidateFile = path.join(folder, ...candidate.path.split('/'));
503
+ const staging = `${candidateFile}.tmp`;
504
+ const created = [];
505
+ const create = (file, content, label) => {
506
+ try {
507
+ fs.writeFileSync(file, content, { flag: 'wx' });
508
+ } catch (error) {
509
+ if (error.code === 'EEXIST') throw new ReviewError([`${label} already exists; a promotion never replaces a file`]);
510
+ created.push(file);
511
+ throw error;
512
+ }
513
+ created.push(file);
514
+ };
515
+ let madeDirectory;
516
+ try {
517
+ create(staging, candidate.content, path.posix.basename(staging));
518
+ madeDirectory = fs.mkdirSync(path.dirname(mutationFile), { recursive: true });
519
+ create(mutationFile, mutation.content, mutation.path);
520
+ // A decision written while the files were staged is never overwritten: the stored record is read again just before the swap.
521
+ if (readHeldFile(folder, candidate.path, 'candidate').toString('utf8') !== promotion.previous) {
522
+ throw new ReviewError([`${candidate.path} changed while the promotion was written; promote again`]);
523
+ }
524
+ assertEvidenceSupports(folder, readCandidate(folder, candidate.path));
525
+ fs.renameSync(staging, candidateFile);
526
+ } catch (error) {
527
+ for (const file of created) fs.rmSync(file, { force: true });
528
+ if (madeDirectory !== undefined) fs.rmSync(madeDirectory, { recursive: true, force: true });
529
+ throw error;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Loads what {@link classifyCandidate} reads for a candidate the folder holds: the retained evidence of each
535
+ * observation at `discovery/<campaignId>/evidence/<candidateId>.<observationId>.json`, and the reference at
536
+ * `referencePath`. A file the folder does not hold is left out, so the classification reports it as missing.
537
+ *
538
+ * @param {string} folder
539
+ * @param {object} record
540
+ * @param {string} [referencePath] the independent reference, relative to the folder
541
+ * @returns {{evidence: Record<string, Buffer>, reference?: Buffer}}
542
+ */
543
+ function loadReviewInputs(folder, record, referencePath) {
544
+ if (referencePath !== undefined) inFolder(referencePath, 'the independent reference');
545
+ const held = (relative) => {
546
+ try {
547
+ return readHeldFile(folder, relative, 'review input');
548
+ } catch {
549
+ return null;
550
+ }
551
+ };
552
+ const evidence = {};
553
+ for (const observation of record.observations) {
554
+ const bytes = held(`discovery/${record.campaignId}/evidence/${record.candidateId}.${observation.observationId}.json`);
555
+ if (bytes !== null) evidence[observation.observationId] = bytes;
556
+ }
557
+ const reference = referencePath === undefined ? null : held(referencePath);
558
+ return reference === null ? { evidence } : { evidence, reference, referencePath };
559
+ }
560
+
561
+ module.exports = {
562
+ ReviewError,
563
+ applyPromotion,
564
+ classifyCandidate,
565
+ loadReviewInputs,
566
+ promoteCandidate,
567
+ reviewCandidate,
568
+ settleCandidate,
569
+ };