@viccydev/pi-fpa 0.5.0 → 0.6.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.
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
2
+ import { link, lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
 
5
5
  import {
@@ -16,14 +16,58 @@ export interface CommitArtifactResult {
16
16
  artifactType: ArtifactType;
17
17
  fingerprint: string;
18
18
  path: string;
19
+ ledgerStatus: "assigned" | "legacy_unassigned";
20
+ artifactRef?: ArtifactRefV2;
19
21
  }
20
22
 
21
23
  export interface ReadArtifactResult {
22
24
  artifact: CanonicalArtifact;
23
25
  fingerprint: string;
24
26
  path: string;
27
+ ledgerContext?: ArtifactLedgerContext;
25
28
  }
26
29
 
30
+ export interface ArtifactRefV2 {
31
+ scope_id: string;
32
+ cycle_id: string;
33
+ artifact_type: ArtifactType;
34
+ entry_id: string;
35
+ body_fingerprint: string;
36
+ }
37
+
38
+ export interface ArtifactCommitContext {
39
+ scope_id: string;
40
+ cycle_id: string;
41
+ upstream_refs?: ArtifactRefV2[];
42
+ forecast_role?: "original" | "eac" | "next_plan";
43
+ }
44
+
45
+ export interface ArtifactLedgerContext {
46
+ scope_id: string;
47
+ cycle_id: string;
48
+ upstream_refs: ArtifactRefV2[];
49
+ forecast_role?: "original" | "eac" | "next_plan";
50
+ }
51
+
52
+ export interface CommitArtifactOptions {
53
+ context?: ArtifactCommitContext;
54
+ promoteLegacyPointer?: boolean;
55
+ }
56
+
57
+ interface ArtifactLedgerEntry extends ArtifactRefV2 {
58
+ kind: "fpa.artifact.entry";
59
+ schema_version: 2;
60
+ body_ref: string;
61
+ created_at: string;
62
+ available_at: string;
63
+ upstream_refs: ArtifactRefV2[];
64
+ forecast_role?: "original" | "eac" | "next_plan";
65
+ assignment_status: "assigned";
66
+ }
67
+
68
+ const SHA256_RE = /^[a-f0-9]{64}$/;
69
+ const CONTEXT_ID_MAX_LENGTH = 256;
70
+
27
71
  export function stableJson(value: unknown): string {
28
72
  if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
29
73
  if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
@@ -60,6 +104,140 @@ async function existingArtifactDirectory(projectRoot: string): Promise<string> {
60
104
  return artifactsDir;
61
105
  }
62
106
 
107
+ async function ensureOwnedDirectory(parent: string, name: string): Promise<string> {
108
+ const path = join(parent, name);
109
+ try {
110
+ const stat = await lstat(path);
111
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
114
+ await mkdir(path, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => {
115
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
116
+ });
117
+ const stat = await lstat(path);
118
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
119
+ }
120
+ return path;
121
+ }
122
+
123
+ async function ledgerDirectories(projectRoot: string): Promise<{ root: string; objects: string; entries: string; heads: string }> {
124
+ const artifacts = await ensureArtifactDirectory(projectRoot);
125
+ const root = await ensureOwnedDirectory(artifacts, ".ledger");
126
+ return {
127
+ root,
128
+ objects: await ensureOwnedDirectory(root, "objects"),
129
+ entries: await ensureOwnedDirectory(root, "entries"),
130
+ heads: await ensureOwnedDirectory(root, "heads"),
131
+ };
132
+ }
133
+
134
+ async function existingLedgerDirectories(projectRoot: string): Promise<{ root: string; objects: string; entries: string }> {
135
+ const artifacts = await existingArtifactDirectory(projectRoot);
136
+ const root = join(artifacts, ".ledger");
137
+ const objects = join(root, "objects");
138
+ const entries = join(root, "entries");
139
+ for (const [path, label] of [[root, ".ledger"], [objects, ".ledger/objects"], [entries, ".ledger/entries"]] as const) {
140
+ const stat = await lstat(path);
141
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a regular directory, not a symlink.`);
142
+ }
143
+ return { root, objects, entries };
144
+ }
145
+
146
+ async function syncDirectory(path: string): Promise<void> {
147
+ const handle = await open(path, "r");
148
+ try {
149
+ await handle.sync();
150
+ } finally {
151
+ await handle.close();
152
+ }
153
+ }
154
+
155
+ async function atomicReplace(directory: string, destination: string, contents: string): Promise<void> {
156
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
157
+ const handle = await open(temporary, "wx", 0o600);
158
+ let closed = false;
159
+ try {
160
+ await handle.writeFile(contents, "utf8");
161
+ await handle.sync();
162
+ await handle.close();
163
+ closed = true;
164
+ await rename(temporary, destination);
165
+ await syncDirectory(directory);
166
+ } catch (error) {
167
+ if (!closed) await handle.close().catch(() => undefined);
168
+ await unlink(temporary).catch(() => undefined);
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<"created" | "exists"> {
174
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
175
+ const handle = await open(temporary, "wx", 0o600);
176
+ let closed = false;
177
+ try {
178
+ await handle.writeFile(contents, "utf8");
179
+ await handle.sync();
180
+ await handle.close();
181
+ closed = true;
182
+ try {
183
+ await link(temporary, destination);
184
+ } catch (error) {
185
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
186
+ throw error;
187
+ }
188
+ await syncDirectory(directory);
189
+ return "created";
190
+ } finally {
191
+ if (!closed) await handle.close().catch(() => undefined);
192
+ await unlink(temporary).catch(() => undefined);
193
+ }
194
+ }
195
+
196
+ function contextId(value: unknown, path: string): string {
197
+ if (typeof value !== "string" || value.trim() === "" || value.length > CONTEXT_ID_MAX_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
198
+ throw new Error(`${path} must be a non-empty string of at most ${CONTEXT_ID_MAX_LENGTH} characters without control characters.`);
199
+ }
200
+ return value;
201
+ }
202
+
203
+ export function validateArtifactRef(value: unknown, path = "artifact_ref", allowEnvelopeFields = false): ArtifactRefV2 {
204
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
205
+ const source = value as Record<string, unknown>;
206
+ const allowed = new Set(["scope_id", "cycle_id", "artifact_type", "entry_id", "body_fingerprint"]);
207
+ if (!allowEnvelopeFields) for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
208
+ const artifactType = source.artifact_type;
209
+ if (artifactType !== "approved_cycle_forecast" && artifactType !== "execution_receipt") throw new Error(`${path}.artifact_type is unsupported.`);
210
+ if (typeof source.entry_id !== "string" || !SHA256_RE.test(source.entry_id)) throw new Error(`${path}.entry_id must be a SHA-256 digest.`);
211
+ if (typeof source.body_fingerprint !== "string" || !SHA256_RE.test(source.body_fingerprint)) throw new Error(`${path}.body_fingerprint must be a SHA-256 digest.`);
212
+ return {
213
+ scope_id: contextId(source.scope_id, `${path}.scope_id`),
214
+ cycle_id: contextId(source.cycle_id, `${path}.cycle_id`),
215
+ artifact_type: artifactType,
216
+ entry_id: source.entry_id,
217
+ body_fingerprint: source.body_fingerprint,
218
+ };
219
+ }
220
+
221
+ function validateCommitContext(value: ArtifactCommitContext): ArtifactLedgerContext {
222
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("context must be an object.");
223
+ const source = value as unknown as Record<string, unknown>;
224
+ const allowed = new Set(["scope_id", "cycle_id", "upstream_refs", "forecast_role"]);
225
+ for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`context.${key} is not allowed.`);
226
+ const upstream = source.upstream_refs === undefined ? [] : source.upstream_refs;
227
+ if (!Array.isArray(upstream) || upstream.length > 16) throw new Error("context.upstream_refs must be an array of at most 16 artifact refs.");
228
+ const upstreamRefs = upstream.map((ref, index) => validateArtifactRef(ref, `context.upstream_refs[${index}]`));
229
+ if (new Set(upstreamRefs.map((ref) => ref.entry_id)).size !== upstreamRefs.length) throw new Error("context.upstream_refs must not contain duplicates.");
230
+ if (source.forecast_role !== undefined && source.forecast_role !== "original" && source.forecast_role !== "eac" && source.forecast_role !== "next_plan") {
231
+ throw new Error("context.forecast_role must be original, eac, or next_plan.");
232
+ }
233
+ return {
234
+ scope_id: contextId(source.scope_id, "context.scope_id"),
235
+ cycle_id: contextId(source.cycle_id, "context.cycle_id"),
236
+ upstream_refs: upstreamRefs,
237
+ ...(source.forecast_role ? { forecast_role: source.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
238
+ };
239
+ }
240
+
63
241
  /**
64
242
  * Resolve a caller-supplied draft path inside the project.
65
243
  *
@@ -106,45 +284,192 @@ export async function readProjectJsonFile(projectRoot: string, artifactPath: str
106
284
  * many turns as it takes, and this entry point keeps the validation,
107
285
  * reconciliation, fingerprinting, and atomic write identical to the inline path.
108
286
  */
109
- export async function commitArtifactFromPath(projectRoot: string, artifactPath: string): Promise<CommitArtifactResult> {
110
- return commitArtifact(projectRoot, await readProjectJsonFile(projectRoot, artifactPath));
287
+ export async function commitArtifactFromPath(projectRoot: string, artifactPath: string, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
288
+ return commitArtifact(projectRoot, await readProjectJsonFile(projectRoot, artifactPath), options);
111
289
  }
112
290
 
291
+ async function writeLegacyPointer(projectRoot: string, committed: CanonicalArtifact): Promise<string> {
292
+ const artifactsDir = await ensureArtifactDirectory(projectRoot);
293
+ const destination = join(artifactsDir, `${committed.artifact_type}.json`);
294
+ await atomicReplace(artifactsDir, destination, `${JSON.stringify(committed, null, 2)}\n`);
295
+ return destination;
296
+ }
297
+
298
+ function entryIdentity(
299
+ context: ArtifactLedgerContext,
300
+ artifactType: ArtifactType,
301
+ bodyFingerprint: string,
302
+ ): string {
303
+ return createHash("sha256").update(stableJson({
304
+ scope_id: context.scope_id,
305
+ cycle_id: context.cycle_id,
306
+ artifact_type: artifactType,
307
+ body_fingerprint: bodyFingerprint,
308
+ upstream_refs: context.upstream_refs,
309
+ ...(context.forecast_role ? { forecast_role: context.forecast_role } : {}),
310
+ })).digest("hex");
311
+ }
312
+
313
+ function validateLedgerEntry(value: Record<string, unknown>, path: string): { ref: ArtifactRefV2; context: ArtifactLedgerContext } {
314
+ const allowed = new Set([
315
+ "kind", "schema_version", "scope_id", "cycle_id", "artifact_type", "entry_id", "body_fingerprint",
316
+ "body_ref", "created_at", "available_at", "upstream_refs", "forecast_role", "assignment_status",
317
+ ]);
318
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
319
+ if (value.kind !== "fpa.artifact.entry" || value.schema_version !== 2 || value.assignment_status !== "assigned") {
320
+ throw new Error(`${path} has an unsupported kind, schema, or assignment status.`);
321
+ }
322
+ const ref = validateArtifactRef(value, path, true);
323
+ if (value.body_ref !== `objects/${ref.body_fingerprint}.json`) throw new Error(`${path} has an invalid body_ref.`);
324
+ for (const field of ["created_at", "available_at"] as const) {
325
+ if (typeof value[field] !== "string" || Number.isNaN(Date.parse(value[field]))) throw new Error(`${path}.${field} must be an ISO timestamp.`);
326
+ }
327
+ if (!Array.isArray(value.upstream_refs) || value.upstream_refs.length > 16) throw new Error(`${path}.upstream_refs must be an array of at most 16 artifact refs.`);
328
+ const upstreamRefs = value.upstream_refs.map((item, index) => validateArtifactRef(item, `${path}.upstream_refs[${index}]`));
329
+ if (value.forecast_role !== undefined && value.forecast_role !== "original" && value.forecast_role !== "eac" && value.forecast_role !== "next_plan") {
330
+ throw new Error(`${path}.forecast_role is unsupported.`);
331
+ }
332
+ if (ref.artifact_type === "execution_receipt" && value.forecast_role !== undefined) throw new Error(`${path}.forecast_role is allowed only for forecasts.`);
333
+ const context: ArtifactLedgerContext = {
334
+ scope_id: ref.scope_id,
335
+ cycle_id: ref.cycle_id,
336
+ upstream_refs: upstreamRefs,
337
+ ...(value.forecast_role ? { forecast_role: value.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
338
+ };
339
+ const expectedEntryId = entryIdentity(context, ref.artifact_type, ref.body_fingerprint);
340
+ if (expectedEntryId !== ref.entry_id) throw new Error(`${path} identity does not match its upstream refs.`);
341
+ return { ref, context };
342
+ }
343
+
344
+ async function readBoundedJson(path: string, label: string): Promise<Record<string, unknown>> {
345
+ const stat = await lstat(path);
346
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular file, not a symlink.`);
347
+ if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${label} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
348
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
349
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must contain a JSON object.`);
350
+ return parsed as Record<string, unknown>;
351
+ }
113
352
 
114
- export async function commitArtifact(projectRoot: string, input: unknown): Promise<CommitArtifactResult> {
353
+ function canonicalFromStored(source: Record<string, unknown>, artifactType: ArtifactType, label: string): CanonicalArtifact {
354
+ const body = { ...source };
355
+ const fingerprint = body.immutable_fingerprint;
356
+ delete body.immutable_fingerprint;
357
+ if (typeof fingerprint !== "string" || !SHA256_RE.test(fingerprint)) throw new Error(`${label} has no valid immutable_fingerprint.`);
358
+ const artifact = validateArtifact(body);
359
+ if (artifact.artifact_type !== artifactType) throw new Error(`${label} contains ${artifact.artifact_type}, expected ${artifactType}.`);
360
+ if (artifactFingerprint(artifact) !== fingerprint) throw new Error(`${label} fingerprint mismatch: the committed artifact was modified after freezing.`);
361
+ return { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
362
+ }
363
+
364
+ async function commitLedgerEntry(
365
+ projectRoot: string,
366
+ committed: CanonicalArtifact,
367
+ context: ArtifactLedgerContext,
368
+ ): Promise<{ ref: ArtifactRefV2; bodyPath: string }> {
369
+ const directories = await ledgerDirectories(projectRoot);
370
+ const fingerprint = committed.immutable_fingerprint;
371
+ const objectPath = join(directories.objects, `${fingerprint}.json`);
372
+ const objectContents = `${JSON.stringify(committed, null, 2)}\n`;
373
+ if (await appendOnlyWrite(directories.objects, objectPath, objectContents) === "exists") {
374
+ const existing = canonicalFromStored(await readBoundedJson(objectPath, `ledger object ${fingerprint}`), committed.artifact_type, `ledger object ${fingerprint}`);
375
+ if (existing.immutable_fingerprint !== fingerprint) throw new Error(`Ledger object ${fingerprint} conflicts with its content address.`);
376
+ }
377
+
378
+ const entryId = entryIdentity(context, committed.artifact_type, fingerprint);
379
+ const ref: ArtifactRefV2 = {
380
+ scope_id: context.scope_id,
381
+ cycle_id: context.cycle_id,
382
+ artifact_type: committed.artifact_type,
383
+ entry_id: entryId,
384
+ body_fingerprint: fingerprint,
385
+ };
386
+ const entryPath = join(directories.entries, `${entryId}.json`);
387
+ const now = new Date().toISOString();
388
+ const entry: ArtifactLedgerEntry = {
389
+ kind: "fpa.artifact.entry",
390
+ schema_version: 2,
391
+ ...ref,
392
+ body_ref: `objects/${fingerprint}.json`,
393
+ created_at: now,
394
+ available_at: now,
395
+ upstream_refs: context.upstream_refs,
396
+ ...(context.forecast_role ? { forecast_role: context.forecast_role } : {}),
397
+ assignment_status: "assigned",
398
+ };
399
+ if (await appendOnlyWrite(directories.entries, entryPath, `${JSON.stringify(entry, null, 2)}\n`) === "exists") {
400
+ const existing = await readBoundedJson(entryPath, `ledger entry ${entryId}`);
401
+ const existingRef = validateLedgerEntry(existing, `ledger entry ${entryId}`).ref;
402
+ if (stableJson(existingRef) !== stableJson(ref)) throw new Error(`Ledger entry ${entryId} conflicts with the requested artifact ref.`);
403
+ }
404
+ const headId = createHash("sha256").update(stableJson({
405
+ scope_id: context.scope_id,
406
+ cycle_id: context.cycle_id,
407
+ artifact_type: committed.artifact_type,
408
+ })).digest("hex");
409
+ await atomicReplace(directories.heads, join(directories.heads, `${headId}.json`), `${JSON.stringify({
410
+ kind: "fpa.artifact.head",
411
+ schema_version: 2,
412
+ ...ref,
413
+ updated_at: now,
414
+ }, null, 2)}\n`);
415
+ return { ref, bodyPath: objectPath };
416
+ }
417
+
418
+ export async function commitArtifact(projectRoot: string, input: unknown, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
115
419
  const artifact = validateArtifact(input);
420
+ const context = options.context ? validateCommitContext(options.context) : null;
421
+ if (artifact.artifact_type === "approved_cycle_forecast" && context?.forecast_role === "original"
422
+ && Date.parse(artifact.frozen_at) > Date.parse(artifact.target_period.start_inclusive)) {
423
+ throw new Error("An original forecast must be frozen no later than the target period start; use forecast_role=eac for an in-period reforecast.");
424
+ }
116
425
  if (artifact.artifact_type === "execution_receipt") {
117
- const forecastRead = await readCommittedArtifact(projectRoot, "approved_cycle_forecast");
426
+ if (context?.forecast_role !== undefined) throw new Error("context.forecast_role is allowed only for approved forecasts.");
427
+ let forecastRead: ReadArtifactResult;
428
+ if (context) {
429
+ if (context.upstream_refs.length !== 1 || context.upstream_refs[0].artifact_type !== "approved_cycle_forecast") {
430
+ throw new Error("An assigned execution receipt requires exactly one approved_cycle_forecast upstream ref.");
431
+ }
432
+ const forecastRef = context.upstream_refs[0];
433
+ if (forecastRef.scope_id !== context.scope_id || forecastRef.cycle_id !== context.cycle_id) {
434
+ throw new Error("Execution context scope_id and cycle_id must match its forecast upstream ref.");
435
+ }
436
+ forecastRead = await readArtifactByRef(projectRoot, forecastRef);
437
+ } else {
438
+ forecastRead = await readCommittedArtifact(projectRoot, "approved_cycle_forecast");
439
+ }
118
440
  if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
119
441
  validateExecutionAgainstForecast(artifact, forecastRead.artifact);
120
442
  }
121
443
  const fingerprint = artifactFingerprint(artifact);
122
444
  const committed = { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
123
- const artifactsDir = await ensureArtifactDirectory(projectRoot);
124
- const destination = join(artifactsDir, `${artifact.artifact_type}.json`);
125
- const temporary = join(artifactsDir, `.${artifact.artifact_type}.${randomUUID()}.tmp`);
445
+ const ledgerCommit = context ? await commitLedgerEntry(projectRoot, committed, context) : undefined;
446
+ const artifactRef = ledgerCommit?.ref;
447
+ const promoteLegacyPointer = options.promoteLegacyPointer ?? true;
448
+ const destination = promoteLegacyPointer
449
+ ? await writeLegacyPointer(projectRoot, committed)
450
+ : ledgerCommit?.bodyPath ?? join(await ensureArtifactDirectory(projectRoot), `${artifact.artifact_type}.json`);
126
451
 
127
- const handle = await open(temporary, "wx", 0o600);
128
- let closed = false;
129
- try {
130
- await handle.writeFile(`${JSON.stringify(committed, null, 2)}\n`, "utf8");
131
- await handle.sync();
132
- await handle.close();
133
- closed = true;
134
- await rename(temporary, destination);
135
- const directoryHandle = await open(artifactsDir, "r");
136
- try {
137
- await directoryHandle.sync();
138
- } finally {
139
- await directoryHandle.close();
140
- }
141
- } catch (error) {
142
- if (!closed) await handle.close().catch(() => undefined);
143
- await unlink(temporary).catch(() => undefined);
144
- throw error;
145
- }
452
+ return {
453
+ artifactType: artifact.artifact_type,
454
+ fingerprint,
455
+ path: destination,
456
+ ledgerStatus: artifactRef ? "assigned" : "legacy_unassigned",
457
+ ...(artifactRef ? { artifactRef } : {}),
458
+ };
459
+ }
146
460
 
147
- return { artifactType: artifact.artifact_type, fingerprint, path: destination };
461
+ export async function readArtifactByRef(projectRoot: string, value: unknown): Promise<ReadArtifactResult> {
462
+ const ref = validateArtifactRef(value);
463
+ const directories = await existingLedgerDirectories(projectRoot);
464
+ const entryPath = join(directories.entries, `${ref.entry_id}.json`);
465
+ const entry = await readBoundedJson(entryPath, `ledger entry ${ref.entry_id}`);
466
+ const validatedEntry = validateLedgerEntry(entry, `ledger entry ${ref.entry_id}`);
467
+ const storedRef = validatedEntry.ref;
468
+ if (stableJson(storedRef) !== stableJson(ref)) throw new Error(`Ledger entry ${ref.entry_id} does not match the requested artifact ref.`);
469
+ const bodyPath = join(directories.objects, `${ref.body_fingerprint}.json`);
470
+ const artifact = canonicalFromStored(await readBoundedJson(bodyPath, `ledger object ${ref.body_fingerprint}`), ref.artifact_type, `ledger object ${ref.body_fingerprint}`);
471
+ if (artifact.immutable_fingerprint !== ref.body_fingerprint) throw new Error(`Ledger object ${ref.body_fingerprint} does not match the artifact ref.`);
472
+ return { artifact, fingerprint: ref.body_fingerprint, path: bodyPath, ledgerContext: validatedEntry.context };
148
473
  }
149
474
 
150
475
  export async function readCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult> {
@@ -159,17 +484,8 @@ export async function readCommittedArtifact(projectRoot: string, artifactType: A
159
484
  throw new Error(`${artifactType} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
160
485
  }
161
486
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${artifactType} must contain a JSON object.`);
162
- const source = { ...(parsed as Record<string, unknown>) };
163
- const fingerprint = source.immutable_fingerprint;
164
- delete source.immutable_fingerprint;
165
- if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
166
- throw new Error(`${artifactType} has no valid immutable_fingerprint.`);
167
- }
168
- const artifact = validateArtifact(source);
169
- if (artifact.artifact_type !== artifactType) throw new Error(`${path} contains ${artifact.artifact_type}, expected ${artifactType}.`);
170
- const expected = artifactFingerprint(artifact);
171
- if (expected !== fingerprint) throw new Error(`${artifactType} fingerprint mismatch: the committed artifact was modified after freezing.`);
172
- return { artifact: { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact, fingerprint, path };
487
+ const artifact = canonicalFromStored(parsed as Record<string, unknown>, artifactType, artifactType);
488
+ return { artifact, fingerprint: artifact.immutable_fingerprint, path };
173
489
  }
174
490
 
175
491
  export async function readOptionalCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult | null> {