@ontrails/trails 1.0.0-beta.45 → 1.0.0-beta.47

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.
@@ -7,103 +7,300 @@
7
7
 
8
8
  import { InternalError, Result, ValidationError } from '@ontrails/core';
9
9
  import type { Result as TrailsResult } from '@ontrails/core';
10
- import { regradeReportOutput } from '@ontrails/regrade';
11
- import type { RegradeReport } from '@ontrails/regrade';
10
+ import { resolveRegradeHistoryReceipt } from '@ontrails/regrade';
11
+ import type {
12
+ RegradeFormJudgment,
13
+ RegradeReport,
14
+ ResolvedRegradeHistoryReceipt,
15
+ } from '@ontrails/regrade';
12
16
  import {
13
17
  getGovernedVocabularyTransition,
14
- governedVocabularyHistoryProvenanceSchema,
15
18
  listGovernedVocabularyTransitions,
16
19
  } from '@ontrails/warden';
17
20
  import type { GovernedVocabularyHistoryProvenance } from '@ontrails/warden';
18
- import { createHash } from 'node:crypto';
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { createHash, randomUUID } from 'node:crypto';
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from 'node:fs';
20
31
  import { dirname, isAbsolute, join } from 'node:path';
21
- import { z } from 'zod';
22
32
 
23
33
  import {
24
- regradePlanArtifactSchema,
25
34
  regradePlanContentHash,
26
35
  regradePlanDirectory,
27
36
  regradePlanSlugForBody,
28
- legacyRegradeSourceHash,
29
37
  regradeSourceHash,
30
- regradeSourceHashMatches,
31
- regradeSourceHashes,
32
38
  rootRelativePath,
33
39
  } from './plan-artifact.js';
34
40
  import type { RegradePlanArtifact, RegradePlanBody } from './plan-artifact.js';
41
+ import {
42
+ buildRegradeHistoryReceipt,
43
+ resolveRegradeSourceRevision,
44
+ serializeRegradeHistoryReceipt,
45
+ } from './receipt-history.js';
46
+ import type { RegradeChangedFileEvidence } from './receipt-history.js';
35
47
 
36
- /**
37
- * Consolidated history schema version. Version 1 was the retired
38
- * one-file-per-run shape whose filename carried the lock hash.
39
- */
40
- export const REGRADE_HISTORY_SCHEMA_VERSION = 2;
41
-
42
- const rawCompletionReportHash = Symbol('rawCompletionReportHash');
43
- const rawReportHash = Symbol('rawReportHash');
44
- const rawCompletionReport = Symbol('rawCompletionReport');
45
- const rawReport = Symbol('rawReport');
46
-
47
- const regradeHistoryRunSchema = z
48
- .object({
49
- completionReport: regradeReportOutput
50
- .optional()
51
- .describe(
52
- 'Post-apply source evidence used to recognize a completed-state replay'
53
- ),
54
- completionReportHash: z
55
- .string()
56
- .regex(/^[0-9a-f]{64}$/)
57
- .optional()
58
- .describe('Canonical hash of the post-apply completion report'),
59
- lockHashAtRun: z
60
- .string()
61
- .min(1)
62
- .describe('Regrade source hash observed when this run applied'),
63
- plan: regradePlanArtifactSchema.describe(
64
- 'Plan artifact consumed by this run'
65
- ),
66
- planContentHash: z
67
- .string()
68
- .min(1)
69
- .describe('Canonical content hash of the resolved plan body'),
70
- provenance: governedVocabularyHistoryProvenanceSchema.optional(),
71
- report: regradeReportOutput.describe('Applied report recorded by the run'),
72
- })
73
- .strict();
48
+ const resolvedReceipt = Symbol('resolvedReceipt');
74
49
 
75
- const regradeHistoryArtifactSchema = z
76
- .object({
77
- id: z.string().min(1).describe('Stable transition identity'),
78
- kind: z.literal('regrade-history'),
79
- path: z.string().describe('Root-relative consolidated history path'),
80
- runs: z.array(regradeHistoryRunSchema).min(1),
81
- schemaVersion: z.literal(REGRADE_HISTORY_SCHEMA_VERSION),
82
- })
83
- .strict();
50
+ export const writeRegradeHistoryFileAtomically = (params: {
51
+ readonly absolutePath: string;
52
+ readonly content: string;
53
+ readonly diagnosticPath: string;
54
+ readonly replace?: typeof renameSync | undefined;
55
+ }): TrailsResult<void, InternalError> => {
56
+ const temporaryPath = join(
57
+ dirname(params.absolutePath),
58
+ `.${randomUUID()}.regrade-history.tmp`
59
+ );
60
+ try {
61
+ mkdirSync(dirname(params.absolutePath), { recursive: true });
62
+ writeFileSync(temporaryPath, params.content);
63
+ (params.replace ?? renameSync)(temporaryPath, params.absolutePath);
64
+ return Result.ok();
65
+ } catch (error) {
66
+ try {
67
+ rmSync(temporaryPath, { force: true });
68
+ } catch {
69
+ // Preserve the primary persistence failure.
70
+ }
71
+ return Result.err(
72
+ new InternalError('Failed to atomically write Regrade history.', {
73
+ ...(error instanceof Error ? { cause: error } : {}),
74
+ context: { path: params.diagnosticPath },
75
+ })
76
+ );
77
+ }
78
+ };
84
79
 
80
+ export const consumeActiveRegradePlanAfterHistoryWrite = (params: {
81
+ readonly absoluteHistoryPath: string;
82
+ readonly absolutePlanPath: string;
83
+ readonly historyPath: string;
84
+ readonly planPath: string;
85
+ readonly priorHistoryBytes?: string | undefined;
86
+ readonly remove?: ((path: string) => void) | undefined;
87
+ readonly replace?: typeof renameSync | undefined;
88
+ }): TrailsResult<void, InternalError> => {
89
+ const remove =
90
+ params.remove ?? ((path: string) => rmSync(path, { force: true }));
91
+ const removeNewHistory = (): Error | undefined => {
92
+ try {
93
+ remove(params.absoluteHistoryPath);
94
+ return undefined;
95
+ } catch (error) {
96
+ return error instanceof Error ? error : new Error(String(error));
97
+ }
98
+ };
99
+ try {
100
+ remove(params.absolutePlanPath);
101
+ return Result.ok();
102
+ } catch (error) {
103
+ let rollbackError: Error | undefined;
104
+ if (params.priorHistoryBytes === undefined) {
105
+ rollbackError = removeNewHistory();
106
+ } else {
107
+ const restored = writeRegradeHistoryFileAtomically({
108
+ absolutePath: params.absoluteHistoryPath,
109
+ content: params.priorHistoryBytes,
110
+ diagnosticPath: params.historyPath,
111
+ replace: params.replace,
112
+ });
113
+ if (restored.isErr()) {
114
+ rollbackError = restored.error;
115
+ }
116
+ }
117
+ return Result.err(
118
+ new InternalError('Failed to remove active Regrade plan.', {
119
+ ...(error instanceof Error ? { cause: error } : {}),
120
+ context: {
121
+ history: params.historyPath,
122
+ plan: params.planPath,
123
+ ...(rollbackError === undefined
124
+ ? {}
125
+ : { historyRollback: rollbackError.message }),
126
+ },
127
+ })
128
+ );
129
+ }
130
+ };
85
131
  interface RegradeHistoryRun {
86
- readonly [rawCompletionReportHash]?: string;
87
- readonly [rawCompletionReport]?: RegradeReport;
88
- readonly [rawReportHash]?: string;
89
- readonly [rawReport]?: RegradeReport;
90
132
  readonly completionReport: RegradeReport;
91
133
  readonly completionReportHash: string;
92
134
  readonly lockHashAtRun: string;
93
135
  readonly plan: RegradePlanArtifact;
94
136
  readonly planContentHash: string;
95
- readonly provenance?: GovernedVocabularyHistoryProvenance;
96
137
  readonly report: RegradeReport;
97
138
  }
98
139
 
99
140
  export interface RegradeHistoryArtifact {
141
+ readonly [resolvedReceipt]?: ResolvedRegradeHistoryReceipt;
100
142
  readonly id: string;
101
143
  readonly kind: 'regrade-history';
102
144
  readonly path: string;
103
145
  readonly runs: readonly RegradeHistoryRun[];
104
- readonly schemaVersion: typeof REGRADE_HISTORY_SCHEMA_VERSION;
146
+ readonly schemaVersion: number;
105
147
  }
106
148
 
149
+ const receiptDisposition = (
150
+ disposition: RegradeFormJudgment['disposition']
151
+ ) => {
152
+ switch (disposition) {
153
+ case 'mapped': {
154
+ return {
155
+ disposition: 'in-family-modified' as const,
156
+ verdict: 'applied' as const,
157
+ };
158
+ }
159
+ case 'out-of-family': {
160
+ return {
161
+ disposition: 'out-of-family' as const,
162
+ verdict: 'skipped' as const,
163
+ };
164
+ }
165
+ case 'preserved': {
166
+ return {
167
+ disposition: 'explicit-preserve' as const,
168
+ verdict: 'skipped' as const,
169
+ };
170
+ }
171
+ case 'unresolved': {
172
+ return {
173
+ disposition: 'in-family-unresolved' as const,
174
+ verdict: 'deferred' as const,
175
+ };
176
+ }
177
+ default: {
178
+ const exhaustive: never = disposition;
179
+ return exhaustive;
180
+ }
181
+ }
182
+ };
183
+
184
+ const reportForReceiptRun = (
185
+ run: ResolvedRegradeHistoryReceipt['runs'][number]
186
+ ): RegradeReport => {
187
+ const plan = run.plan as RegradePlanBody;
188
+ const { completion } = run.receipt;
189
+ const entries = run.receipt.evidence.changedFiles.map((file) => ({
190
+ outcome: 'rewrite' as const,
191
+ path: file.afterPath,
192
+ }));
193
+ const skipped = Object.values(completion.counts.skippedByReason).reduce(
194
+ (sum, count) => sum + count,
195
+ 0
196
+ );
197
+ const base: RegradeReport = {
198
+ apply: {
199
+ applied: completion.counts.rewritten,
200
+ filesChanged: completion.metrics.filesChanged,
201
+ review: completion.counts.review,
202
+ skipped,
203
+ unknown: completion.counts.unknown,
204
+ },
205
+ entries,
206
+ matched: completion.counts.matched,
207
+ review: completion.counts.review,
208
+ rewritten: completion.counts.rewritten,
209
+ root: '.',
210
+ scan: {
211
+ byDirectory: [],
212
+ byExtension: [],
213
+ files: {
214
+ matched: completion.counts.matched,
215
+ scanned: 0,
216
+ skipped,
217
+ },
218
+ skippedByReason: completion.counts.skippedByReason,
219
+ },
220
+ scanned: 0,
221
+ selectedClassIds:
222
+ plan.kind === 'class'
223
+ ? plan.classIds
224
+ : [plan.id ?? `vocabulary:${plan.from}->${plan.to}`],
225
+ skipped,
226
+ skipsByReason: completion.counts.skippedByReason,
227
+ unknownClassIds: [],
228
+ };
229
+ if (plan.kind !== 'vocabulary') {
230
+ return base;
231
+ }
232
+ const occurrences = run.classifiedState.forms.map((form, index) => {
233
+ const mapped = receiptDisposition(form.disposition);
234
+ return {
235
+ column: 1,
236
+ context: '',
237
+ ...mapped,
238
+ end: 0,
239
+ form: form.form,
240
+ line: form.representative?.line ?? 1,
241
+ path: form.representative?.path ?? run.receipt.transitionId,
242
+ reason: form.reason ?? form.disposition,
243
+ ...(form.target === undefined ? {} : { replacement: form.target }),
244
+ scopeTier: 'in-scope' as const,
245
+ start: index,
246
+ };
247
+ });
248
+ return {
249
+ ...base,
250
+ run: {
251
+ ledger: {
252
+ cycle: 1,
253
+ forms: Object.fromEntries(
254
+ occurrences.map((occurrence) => [occurrence.form, occurrence.verdict])
255
+ ),
256
+ occurrences,
257
+ },
258
+ plan,
259
+ report: {
260
+ applied: completion.counts.rewritten,
261
+ deferred: completion.counts.review,
262
+ dispositions: completion.counts.dispositions,
263
+ filesChanged: completion.metrics.filesChanged,
264
+ gate: {
265
+ ...completion.gate,
266
+ remainingByDisposition: {},
267
+ },
268
+ modified: completion.counts.rewritten,
269
+ open: completion.gate.remaining,
270
+ scopeTiers: { 'in-scope': occurrences.length, 'policy-classified': 0 },
271
+ skipped,
272
+ teachingSurfaces: { expected: [], missing: [], touched: [] },
273
+ },
274
+ },
275
+ };
276
+ };
277
+
278
+ const projectReceiptHistory = (
279
+ receipt: ResolvedRegradeHistoryReceipt
280
+ ): RegradeHistoryArtifact => ({
281
+ [resolvedReceipt]: receipt,
282
+ id: receipt.artifact.id,
283
+ kind: 'regrade-history',
284
+ path: receipt.artifact.path,
285
+ runs: receipt.runs.map((run) => ({
286
+ completionReport: reportForReceiptRun(run),
287
+ completionReportHash: run.receipt.evidence.sourceStateHash,
288
+ lockHashAtRun: run.receipt.evidence.lockStateHash,
289
+ plan: {
290
+ kind: 'regrade-plan',
291
+ path: `.trails/regrade/plans/${receipt.artifact.path.split('/').at(-1) ?? 'receipt'}`,
292
+ plan: run.plan as RegradePlanBody,
293
+ provenance: run.provenance,
294
+ schemaVersion: 1,
295
+ sourceHash: run.receipt.evidence.sourceStateHash,
296
+ transitionId: receipt.artifact.id,
297
+ },
298
+ planContentHash: run.receipt.intent.planContentHash,
299
+ report: reportForReceiptRun(run),
300
+ })),
301
+ schemaVersion: receipt.artifact.schemaVersion,
302
+ });
303
+
107
304
  export interface RegradeHistorySummary {
108
305
  readonly id: string;
109
306
  readonly path: string;
@@ -141,54 +338,26 @@ export const readRegradeHistoryArtifact = (
141
338
  })
142
339
  );
143
340
  }
144
- const parsed = regradeHistoryArtifactSchema.safeParse(parsedJson);
145
- if (!parsed.success) {
341
+ const receipt = resolveRegradeHistoryReceipt(parsedJson);
342
+ if (receipt.isErr()) {
343
+ return receipt;
344
+ }
345
+ const observedPath = path.replaceAll('\\', '/');
346
+ const embeddedPath = receipt.value.artifact.path;
347
+ if (
348
+ observedPath !== embeddedPath &&
349
+ !observedPath.endsWith(`/${embeddedPath}`)
350
+ ) {
146
351
  return Result.err(
147
- new ValidationError('Invalid Regrade history artifact.', {
148
- context: { issues: parsed.error.issues, path },
149
- })
352
+ new ValidationError(
353
+ 'Regrade history path does not match its observed file.',
354
+ {
355
+ context: { embeddedPath, observedPath },
356
+ }
357
+ )
150
358
  );
151
359
  }
152
- const rawRuns = (
153
- parsedJson as {
154
- readonly runs: readonly {
155
- readonly completionReport?: RegradeReport;
156
- readonly report: RegradeReport;
157
- }[];
158
- }
159
- ).runs;
160
- return Result.ok({
161
- ...parsed.data,
162
- runs: parsed.data.runs.map((run, index) => {
163
- // Early schema-v2 histories recorded only the post-apply report. Treat
164
- // that report and its lock hash as completion evidence when reading
165
- // those artifacts.
166
- const completionReport = run.completionReport ?? run.report;
167
- const normalized = {
168
- ...run,
169
- completionReport,
170
- completionReportHash: run.completionReportHash ?? run.lockHashAtRun,
171
- } as RegradeHistoryRun;
172
- const rawRun = rawRuns[index];
173
- if (rawRun !== undefined) {
174
- Object.defineProperties(normalized, {
175
- [rawCompletionReport]: {
176
- value: rawRun.completionReport ?? rawRun.report,
177
- },
178
- [rawCompletionReportHash]: {
179
- value: legacyRegradeSourceHash(
180
- rawRun.completionReport ?? rawRun.report
181
- ),
182
- },
183
- [rawReportHash]: {
184
- value: legacyRegradeSourceHash(rawRun.report),
185
- },
186
- [rawReport]: { value: rawRun.report },
187
- });
188
- }
189
- return normalized;
190
- }),
191
- } as RegradeHistoryArtifact);
360
+ return Result.ok(projectReceiptHistory(receipt.value));
192
361
  };
193
362
 
194
363
  /**
@@ -258,237 +427,120 @@ export const validateGovernedRegradePlan = (
258
427
  );
259
428
  };
260
429
 
261
- const governedProvenanceForRun = (params: {
430
+ /** Receipts are validated and hash-resolved during read. */
431
+ export const verifyRegradeHistoryRuns = (
432
+ artifact: RegradeHistoryArtifact
433
+ ): TrailsResult<{ readonly runs: number }, ValidationError> =>
434
+ Result.ok({ runs: artifact.runs.length });
435
+
436
+ const appendReceiptHistoryRun = (params: {
437
+ readonly absolutePath: string;
262
438
  readonly artifact: RegradePlanArtifact;
263
- readonly completionReport: RegradeReport;
439
+ readonly changedFiles: readonly RegradeChangedFileEvidence[];
440
+ readonly completedReport: RegradeReport;
441
+ readonly current: RegradeHistoryArtifact | undefined;
442
+ readonly lockHashAtRun: string;
264
443
  readonly planContentHash: string;
444
+ readonly relativePath: string;
265
445
  readonly report: RegradeReport;
266
- }): TrailsResult<
267
- GovernedVocabularyHistoryProvenance | undefined,
268
- ValidationError
269
- > => {
270
- const { plan } = params.artifact;
271
- const validation = validateGovernedRegradePlan(params.artifact);
272
- if (validation.isErr()) {
273
- return validation;
274
- }
275
- if (plan.kind !== 'vocabulary') {
276
- return Result.ok();
277
- }
278
- const transition = governedTransitionForPlan(plan);
279
- if (transition === undefined) {
280
- return Result.ok();
281
- }
282
-
283
- const reviewPending = params.report.review;
284
- return Result.ok({
285
- disposition: reviewPending > 0 ? 'review-follow-up' : 'applied-clean',
286
- kind: 'governed-vocabulary',
287
- planContentHash: params.planContentHash,
288
- reviewPending,
289
- safeApplied: params.report.apply?.applied ?? params.report.rewritten,
290
- sourceHashAfter: regradeSourceHash(params.completionReport),
291
- sourceHashBefore: regradeSourceHash(params.report),
292
- transitionId: transition.id,
293
- });
294
- };
295
-
296
- const governedProvenanceMatchesRun = (params: {
297
- readonly expected: GovernedVocabularyHistoryProvenance | undefined;
298
- readonly run: RegradeHistoryRun;
299
- }): boolean => {
300
- const { expected, run } = params;
301
- const actual = run.provenance;
302
- if (actual === undefined || expected === undefined) {
303
- return actual === expected;
304
- }
305
- return (
306
- actual.disposition === expected.disposition &&
307
- actual.kind === expected.kind &&
308
- actual.planContentHash === expected.planContentHash &&
309
- actual.reviewPending === expected.reviewPending &&
310
- actual.safeApplied === expected.safeApplied &&
311
- (regradeSourceHashMatches(actual.sourceHashBefore, run.report) ||
312
- run[rawReportHash] === actual.sourceHashBefore) &&
313
- (regradeSourceHashMatches(actual.sourceHashAfter, run.completionReport) ||
314
- run[rawCompletionReportHash] === actual.sourceHashAfter) &&
315
- actual.transitionId === expected.transitionId
316
- );
317
- };
318
-
319
- /**
320
- * Verify every recorded run at its own stamped lock: recompute the plan
321
- * content hash and lock hash from the recorded plan and report, then compare
322
- * with the stamped values.
323
- */
324
- export const verifyRegradeHistoryRuns = (
325
- artifact: RegradeHistoryArtifact
326
- ): TrailsResult<{ readonly runs: number }, ValidationError> => {
327
- for (const [index, run] of artifact.runs.entries()) {
328
- if (regradePlanContentHash(run.plan.plan) !== run.planContentHash) {
329
- return Result.err(
330
- new ValidationError('Regrade history run stamp mismatch.', {
446
+ readonly rootDir: string;
447
+ readonly sourceRevision: string;
448
+ }): TrailsResult<RegradeHistorySummary, Error> => {
449
+ if (
450
+ params.current !== undefined &&
451
+ params.artifact.transitionId !== undefined &&
452
+ params.artifact.transitionId !== params.current.id
453
+ ) {
454
+ return Result.err(
455
+ new ValidationError(
456
+ 'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
457
+ {
331
458
  context: {
332
- field: 'planContentHash',
333
- path: artifact.path,
334
- run: index,
459
+ history: params.current.id,
460
+ path: params.relativePath,
461
+ plan: params.artifact.transitionId,
335
462
  },
336
- })
337
- );
338
- }
339
- if (
340
- !regradeSourceHashMatches(run.lockHashAtRun, run.report) &&
341
- run[rawReportHash] !== run.lockHashAtRun
342
- ) {
343
- return Result.err(
344
- new ValidationError('Regrade history run stamp mismatch.', {
345
- context: { field: 'lockHashAtRun', path: artifact.path, run: index },
346
- })
347
- );
348
- }
349
- if (
350
- !regradeSourceHashMatches(
351
- run.completionReportHash,
352
- run.completionReport
353
- ) &&
354
- run[rawCompletionReportHash] !== run.completionReportHash
355
- ) {
356
- return Result.err(
357
- new ValidationError('Regrade history run stamp mismatch.', {
463
+ }
464
+ )
465
+ );
466
+ }
467
+ const currentLastRun = params.current?.runs.at(-1);
468
+ if (
469
+ params.current !== undefined &&
470
+ params.artifact.transitionId === undefined &&
471
+ currentLastRun !== undefined &&
472
+ currentLastRun.plan.plan.id !== params.artifact.plan.id
473
+ ) {
474
+ return Result.err(
475
+ new ValidationError(
476
+ 'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
477
+ {
358
478
  context: {
359
- field: 'completionReportHash',
360
- path: artifact.path,
361
- run: index,
479
+ history: currentLastRun.plan.plan.id,
480
+ path: params.relativePath,
481
+ plan: params.artifact.plan.id,
362
482
  },
363
- })
364
- );
365
- }
366
- const expectedProvenance = governedProvenanceForRun({
367
- artifact: run.plan,
368
- completionReport: run.completionReport,
369
- planContentHash: run.planContentHash,
370
- report: run.report,
371
- });
372
- if (expectedProvenance.isErr()) {
373
- return expectedProvenance;
374
- }
375
- const transition =
376
- run.plan.plan.kind === 'vocabulary'
377
- ? governedTransitionForPlan(run.plan.plan)
378
- : undefined;
379
- if (
380
- transition?.provenance.mode === 'regrade-history' &&
381
- run.provenance === undefined
382
- ) {
383
- return Result.err(
384
- new ValidationError('Regrade history run lacks governed provenance.', {
385
- context: { path: artifact.path, run: index },
386
- })
387
- );
388
- }
389
- if (
390
- run.provenance !== undefined &&
391
- !governedProvenanceMatchesRun({
392
- expected: expectedProvenance.value,
393
- run,
394
- })
395
- ) {
396
- return Result.err(
397
- new ValidationError('Regrade history run provenance mismatch.', {
398
- context: { path: artifact.path, run: index },
399
- })
400
- );
401
- }
483
+ }
484
+ )
485
+ );
402
486
  }
403
- return Result.ok({ runs: artifact.runs.length });
404
- };
405
-
406
- const historyEntryFor = (params: {
407
- readonly artifact: RegradePlanArtifact;
408
- readonly completionReport: RegradeReport;
409
- readonly lockHashAtRun: string;
410
- readonly planContentHash: string;
411
- readonly report: RegradeReport;
412
- }): TrailsResult<RegradeHistoryRun, ValidationError> => {
413
- const provenance = governedProvenanceForRun(params);
414
- if (provenance.isErr()) {
415
- return provenance;
487
+ const transitionId =
488
+ params.current?.id ??
489
+ params.artifact.transitionId ??
490
+ mintTransitionId(
491
+ regradePlanSlugForBody(params.artifact.plan),
492
+ params.planContentHash,
493
+ params.lockHashAtRun
494
+ );
495
+ const receipt = buildRegradeHistoryReceipt({
496
+ artifact: params.artifact,
497
+ changedFiles: params.changedFiles,
498
+ completedReport: params.completedReport,
499
+ historyPath: params.relativePath,
500
+ ...(params.current?.[resolvedReceipt] === undefined
501
+ ? {}
502
+ : { prior: params.current[resolvedReceipt] }),
503
+ report: params.report,
504
+ rootDir: params.rootDir,
505
+ sourceRevision: params.sourceRevision,
506
+ transitionId,
507
+ });
508
+ if (receipt.isErr()) {
509
+ return receipt;
510
+ }
511
+ const serialized = serializeRegradeHistoryReceipt(receipt.value);
512
+ if (serialized.isErr()) {
513
+ return serialized;
416
514
  }
515
+ const written = writeRegradeHistoryFileAtomically({
516
+ absolutePath: params.absolutePath,
517
+ content: serialized.value,
518
+ diagnosticPath: params.relativePath,
519
+ });
520
+ if (written.isErr()) {
521
+ return written;
522
+ }
523
+ const lastRun = receipt.value.runs.at(-1);
417
524
  return Result.ok({
418
- completionReport: params.completionReport,
419
- completionReportHash: regradeSourceHash(params.completionReport),
420
- lockHashAtRun: params.lockHashAtRun,
421
- plan: params.artifact,
422
- planContentHash: params.planContentHash,
423
- ...(provenance.value === undefined ? {} : { provenance: provenance.value }),
424
- report: params.report,
525
+ id: receipt.value.id,
526
+ path: receipt.value.path,
527
+ schemaVersion: receipt.value.schemaVersion,
528
+ status: lastRun?.runKind === 'proof' ? 'replay' : 'applied',
425
529
  });
426
530
  };
427
531
 
428
- const historySummaryFor = (
429
- artifact: Pick<RegradeHistoryArtifact, 'id' | 'path' | 'schemaVersion'>,
430
- status: RegradeHistorySummary['status'],
431
- provenance?: GovernedVocabularyHistoryProvenance
432
- ): RegradeHistorySummary => ({
433
- id: artifact.id,
434
- path: artifact.path,
435
- ...(provenance === undefined ? {} : { provenance }),
436
- schemaVersion: artifact.schemaVersion,
437
- status,
438
- });
439
-
440
- const writableHistoryArtifact = (artifact: RegradeHistoryArtifact) => ({
441
- id: artifact.id,
442
- kind: artifact.kind,
443
- path: artifact.path,
444
- runs: artifact.runs.map((run) => ({
445
- completionReport: run[rawCompletionReport] ?? run.completionReport,
446
- completionReportHash: run.completionReportHash,
447
- lockHashAtRun: run.lockHashAtRun,
448
- plan: run.plan,
449
- planContentHash: run.planContentHash,
450
- ...(run.provenance === undefined ? {} : { provenance: run.provenance }),
451
- report: run[rawReport] ?? run.report,
452
- })),
453
- schemaVersion: artifact.schemaVersion,
454
- });
455
-
456
- const nextHistoryArtifact = (params: {
457
- readonly entry: RegradeHistoryRun;
458
- readonly lockHashAtRun: string;
459
- readonly path: string;
460
- readonly plan: RegradePlanArtifact;
461
- readonly planContentHash: string;
462
- readonly prior: RegradeHistoryArtifact | undefined;
463
- }): RegradeHistoryArtifact => ({
464
- id:
465
- params.prior?.id ??
466
- params.plan.transitionId ??
467
- mintTransitionId(
468
- regradePlanSlugForBody(params.plan.plan),
469
- params.planContentHash,
470
- params.lockHashAtRun
471
- ),
472
- kind: 'regrade-history',
473
- path: params.path,
474
- runs:
475
- params.prior === undefined
476
- ? [params.entry]
477
- : [...params.prior.runs, params.entry],
478
- schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
479
- });
480
-
481
532
  /**
482
533
  * Append one applied run to the transition's consolidated history file. A
483
- * run whose plan content hash and source evidence equal either the last run's
484
- * pre-apply report or completed state is a replay: nothing is written and
485
- * `status: 'replay'` is surfaced instead of a duplicate record.
534
+ * Unchanged intent and classified state append a compact reference-only proof
535
+ * and surface `status: 'replay'`.
486
536
  */
487
537
  export const appendRegradeHistoryRun = (params: {
488
538
  readonly artifact: RegradePlanArtifact;
539
+ readonly changedFiles?: readonly RegradeChangedFileEvidence[];
489
540
  readonly completedReport?: RegradeReport;
490
541
  readonly report: RegradeReport;
491
542
  readonly rootDir: string;
543
+ readonly sourceRevision?: string;
492
544
  }): TrailsResult<RegradeHistorySummary, Error> => {
493
545
  const absolutePath = regradeHistoryPathForPlan(
494
546
  params.rootDir,
@@ -497,142 +549,88 @@ export const appendRegradeHistoryRun = (params: {
497
549
  const relativePath = rootRelativePath(params.rootDir, absolutePath);
498
550
  const planContentHash = regradePlanContentHash(params.artifact.plan);
499
551
  const lockHashAtRun = regradeSourceHash(params.report);
500
- const currentSourceHashes = regradeSourceHashes(params.report);
501
552
  const completionReport = params.completedReport ?? params.report;
502
- const entry = historyEntryFor({
503
- artifact: params.artifact,
504
- completionReport,
505
- lockHashAtRun,
506
- planContentHash,
507
- report: params.report,
508
- });
509
- if (entry.isErr()) {
510
- return entry;
511
- }
512
553
 
513
- let prior: RegradeHistoryArtifact | undefined;
554
+ let current: RegradeHistoryArtifact | undefined;
514
555
  if (existsSync(absolutePath)) {
515
556
  const existing = readRegradeHistoryArtifact(absolutePath);
516
557
  if (existing.isErr()) {
517
558
  return existing;
518
559
  }
519
- prior = existing.value;
520
- const verified = verifyRegradeHistoryRuns(prior);
521
- if (verified.isErr()) {
522
- return verified;
523
- }
524
- if (
525
- params.artifact.transitionId !== undefined &&
526
- params.artifact.transitionId !== prior.id
527
- ) {
528
- return Result.err(
529
- new ValidationError(
530
- 'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
531
- {
532
- context: {
533
- history: prior.id,
534
- path: relativePath,
535
- plan: params.artifact.transitionId,
536
- },
537
- }
538
- )
539
- );
540
- }
541
- const lastRun = prior.runs.at(-1);
542
- // A plan that carries the transition id (adjust round-trips, plan
543
- // re-derivation) may evolve the plan identity on the same spine. A plan
544
- // WITHOUT the id that disagrees with the recorded plan identity is a
545
- // name collision, not a continuation — refuse instead of mixing runs
546
- // from unrelated transitions into one history.
547
- if (
548
- params.artifact.transitionId === undefined &&
549
- lastRun !== undefined &&
550
- lastRun.plan.plan.id !== params.artifact.plan.id
551
- ) {
552
- return Result.err(
553
- new ValidationError(
554
- 'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
555
- {
556
- context: {
557
- history: lastRun.plan.plan.id,
558
- path: relativePath,
559
- plan: params.artifact.plan.id,
560
- },
561
- }
562
- )
563
- );
564
- }
565
- if (
566
- lastRun !== undefined &&
567
- lastRun.planContentHash === planContentHash &&
568
- (currentSourceHashes.includes(lastRun.lockHashAtRun) ||
569
- currentSourceHashes.includes(lastRun.completionReportHash))
570
- ) {
571
- return Result.ok(historySummaryFor(prior, 'replay', lastRun.provenance));
572
- }
560
+ current = existing.value;
573
561
  }
574
-
575
- const artifact = nextHistoryArtifact({
576
- entry: entry.value,
562
+ const sourceRevision =
563
+ params.sourceRevision === undefined
564
+ ? resolveRegradeSourceRevision(params.rootDir)
565
+ : Result.ok(params.sourceRevision);
566
+ if (sourceRevision.isErr()) {
567
+ return sourceRevision;
568
+ }
569
+ return appendReceiptHistoryRun({
570
+ absolutePath,
571
+ artifact: params.artifact,
572
+ changedFiles: params.changedFiles ?? [],
573
+ completedReport: completionReport,
574
+ current,
577
575
  lockHashAtRun,
578
- path: relativePath,
579
- plan: params.artifact,
580
576
  planContentHash,
581
- prior,
577
+ relativePath,
578
+ report: params.report,
579
+ rootDir: params.rootDir,
580
+ sourceRevision: sourceRevision.value,
582
581
  });
583
- const writableArtifact = writableHistoryArtifact(artifact);
584
- const parsed = regradeHistoryArtifactSchema.safeParse(writableArtifact);
585
- if (!parsed.success) {
586
- return Result.err(
587
- new ValidationError('Invalid Regrade history artifact.', {
588
- context: { issues: parsed.error.issues, path: relativePath },
589
- })
590
- );
591
- }
592
- try {
593
- mkdirSync(dirname(absolutePath), { recursive: true });
594
- writeFileSync(
595
- absolutePath,
596
- `${JSON.stringify(writableArtifact, null, 2)}\n`
597
- );
598
- } catch (error) {
599
- return Result.err(
600
- new InternalError('Failed to write Regrade history entry.', {
601
- ...(error instanceof Error ? { cause: error } : {}),
602
- context: { path: relativePath },
603
- })
604
- );
605
- }
606
- return Result.ok(
607
- historySummaryFor(artifact, 'applied', entry.value.provenance)
608
- );
609
582
  };
610
583
 
611
584
  const hasPathSeparator = (value: string): boolean =>
612
585
  value.includes('/') || value.includes('\\');
613
586
 
614
587
  /**
615
- * Resolve a transition name (with or without a `.json` suffix) to its
616
- * consolidated history file. Path references are rejected graduated
617
- * history lookups are by transition name only.
588
+ * Resolve an opaque transition id to its validated consolidated history file.
589
+ * Path and filename references are rejected; the operator never selects the
590
+ * generator-owned receipt filename.
618
591
  */
619
592
  export const resolveRegradeHistoryPath = (
620
593
  rootDir: string,
621
594
  ref: string
622
- ): TrailsResult<string, ValidationError> => {
595
+ ): TrailsResult<string, Error> => {
623
596
  if (hasPathSeparator(ref) || isAbsolute(ref)) {
624
597
  return Result.err(
625
598
  new ValidationError(
626
- `Regrade history reference "${ref}" must be a transition name.`
599
+ `Regrade history reference "${ref}" must be an opaque transition id.`
627
600
  )
628
601
  );
629
602
  }
630
- const name = ref.endsWith('.json') ? ref.slice(0, -'.json'.length) : ref;
631
- const path = join(regradePlanDirectory(rootDir), 'history', `${name}.json`);
632
- if (!existsSync(path)) {
603
+ const historyDir = join(regradePlanDirectory(rootDir), 'history');
604
+ if (!existsSync(historyDir)) {
633
605
  return Result.err(
634
606
  new ValidationError(`No Regrade history for transition "${ref}" found.`)
635
607
  );
636
608
  }
637
- return Result.ok(path);
609
+ const matches: string[] = [];
610
+ for (const name of readdirSync(historyDir).toSorted()) {
611
+ if (!name.endsWith('.json')) {
612
+ continue;
613
+ }
614
+ const path = join(historyDir, name);
615
+ const history = readRegradeHistoryArtifact(path);
616
+ if (history.isErr()) {
617
+ return history;
618
+ }
619
+ if (history.value.id === ref) {
620
+ matches.push(path);
621
+ }
622
+ }
623
+ if (matches.length === 0) {
624
+ return Result.err(
625
+ new ValidationError(`No Regrade history for transition "${ref}" found.`)
626
+ );
627
+ }
628
+ if (matches.length > 1) {
629
+ return Result.err(
630
+ new ValidationError(
631
+ `Multiple Regrade histories claim transition "${ref}".`
632
+ )
633
+ );
634
+ }
635
+ return Result.ok(matches[0] as string);
638
636
  };