@viccydev/pi-fpa 0.4.1 → 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,7 +1,15 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
- import { commitArtifact, commitArtifactFromPath } from "./store.ts";
4
+ import { composeApprovedForecast } from "./compose.ts";
5
+ import { enqueueDashboardRefresh } from "../fpa-dashboard/coordinator.ts";
6
+ import {
7
+ commitArtifact,
8
+ commitArtifactFromPath,
9
+ readArtifactByRef,
10
+ readProjectJsonFile,
11
+ type ArtifactRefV2,
12
+ } from "./store.ts";
5
13
 
6
14
  function toolResult(value: Record<string, unknown>) {
7
15
  return {
@@ -10,6 +18,17 @@ function toolResult(value: Record<string, unknown>) {
10
18
  };
11
19
  }
12
20
 
21
+ const artifactRefSchema = Type.Object({
22
+ scope_id: Type.String({ minLength: 1, maxLength: 256 }),
23
+ cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
24
+ artifact_type: Type.Union([
25
+ Type.Literal("approved_cycle_forecast"),
26
+ Type.Literal("execution_receipt"),
27
+ ]),
28
+ entry_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
29
+ body_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
30
+ }, { additionalProperties: false });
31
+
13
32
  export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
14
33
  pi.registerTool({
15
34
  name: "fpa_artifact_commit",
@@ -31,6 +50,13 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
31
50
  minLength: 1,
32
51
  description: "Project-relative path to a JSON file holding the canonical artifact. Mutually exclusive with artifact.",
33
52
  })),
53
+ context: Type.Optional(Type.Object({
54
+ scope_id: Type.String({ minLength: 1, maxLength: 256 }),
55
+ cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
56
+ forecast_role: Type.Optional(Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan")])),
57
+ upstream_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 16 })),
58
+ promote_legacy_pointer: Type.Optional(Type.Boolean()),
59
+ }, { additionalProperties: false })),
34
60
  },
35
61
  { additionalProperties: false },
36
62
  ),
@@ -41,14 +67,103 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
41
67
  if (hasInline === hasPath) {
42
68
  throw new Error("Supply exactly one of artifact or artifact_path.");
43
69
  }
70
+ const commitOptions = params.context ? {
71
+ context: {
72
+ scope_id: params.context.scope_id,
73
+ cycle_id: params.context.cycle_id,
74
+ forecast_role: params.context.forecast_role,
75
+ upstream_refs: params.context.upstream_refs as ArtifactRefV2[] | undefined,
76
+ },
77
+ promoteLegacyPointer: params.context.promote_legacy_pointer,
78
+ } : undefined;
44
79
  const committed = hasPath
45
- ? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string)
46
- : await commitArtifact(ctx.cwd, params.artifact);
80
+ ? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string, commitOptions)
81
+ : await commitArtifact(ctx.cwd, params.artifact, commitOptions);
82
+ let dashboardRefresh;
83
+ let dashboardRefreshError: string | undefined;
84
+ if (committed.artifactRef && params.context) {
85
+ try {
86
+ const forecastRef = committed.artifactType === "approved_cycle_forecast"
87
+ ? committed.artifactRef
88
+ : params.context.upstream_refs?.find((ref) => ref.artifact_type === "approved_cycle_forecast") as ArtifactRefV2 | undefined;
89
+ if (!forecastRef) throw new Error("Assigned execution commit has no forecast ref for dashboard refresh.");
90
+ dashboardRefresh = await enqueueDashboardRefresh(ctx.cwd, {
91
+ preset: "forecast-closed-loop-v1",
92
+ locale: "zh-CN",
93
+ scope_id: params.context.scope_id,
94
+ cycle_id: params.context.cycle_id,
95
+ forecast_ref: forecastRef,
96
+ ...(committed.artifactType === "approved_cycle_forecast" && params.context.forecast_role ? { forecast_role: params.context.forecast_role } : {}),
97
+ ...(committed.artifactType === "execution_receipt" ? { execution_ref: committed.artifactRef } : {}),
98
+ reason: committed.artifactType === "approved_cycle_forecast" ? "forecast_committed" : "execution_committed",
99
+ });
100
+ } catch (error) {
101
+ // The immutable ledger commit is already durable. Report the
102
+ // secondary delivery failure explicitly instead of pretending the
103
+ // commit itself failed and encouraging an unsafe duplicate retry.
104
+ dashboardRefreshError = error instanceof Error ? error.message : String(error);
105
+ }
106
+ }
47
107
  return toolResult({
48
- status: "committed",
108
+ status: dashboardRefreshError ? "committed_refresh_enqueue_failed" : "committed",
49
109
  artifact_type: committed.artifactType,
50
110
  immutable_fingerprint: committed.fingerprint,
51
111
  path: committed.path,
112
+ ledger_status: committed.ledgerStatus,
113
+ ...(committed.artifactRef ? { artifact_ref: committed.artifactRef } : {}),
114
+ ...(dashboardRefresh ? { dashboard_refresh: dashboardRefresh } : {}),
115
+ ...(dashboardRefreshError ? { dashboard_refresh_error: dashboardRefreshError } : {}),
116
+ });
117
+ },
118
+ });
119
+
120
+ pi.registerTool({
121
+ name: "fpa_artifact_read",
122
+ label: "Read Exact FP&A Artifact",
123
+ description: "Resolve and verify one immutable FP&A artifact by its exact scope, cycle, entry, and body fingerprint. Never falls back to the current pointer.",
124
+ promptSnippet: "Read an exact immutable FP&A artifact reference",
125
+ parameters: Type.Object({ artifact_ref: artifactRefSchema }, { additionalProperties: false }),
126
+ executionMode: "parallel",
127
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
128
+ const read = await readArtifactByRef(ctx.cwd, params.artifact_ref);
129
+ return toolResult({
130
+ status: "resolved",
131
+ artifact_ref: params.artifact_ref,
132
+ artifact: read.artifact as unknown as Record<string, unknown>,
133
+ path: read.path,
134
+ });
135
+ },
136
+ });
137
+
138
+ pi.registerTool({
139
+ name: "fpa_forecast_compose",
140
+ label: "Compose Approved Forecast",
141
+ description:
142
+ "Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
143
+ "Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks the plan's slice keys against the dimension values ua_spend actually uses. " +
144
+ "Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
145
+ promptSnippet: "Derive a complete approved forecast from a compact plan",
146
+ promptGuidelines: [
147
+ "Author the compact plan, not the full artifact: supply the allocation and each slice's ROAS assumption and let this tool derive the rest.",
148
+ "Do not compute revenue, ROAS, or consolidated totals by hand; hand-rounded values fail the commit reconciliation.",
149
+ ],
150
+ parameters: Type.Object(
151
+ {
152
+ plan_path: Type.String({
153
+ minLength: 1,
154
+ description: "Project-relative path to the compact forecast plan JSON file.",
155
+ }),
156
+ },
157
+ { additionalProperties: false },
158
+ ),
159
+ executionMode: "parallel",
160
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
161
+ const plan = await readProjectJsonFile(ctx.cwd, params.plan_path, "plan_path");
162
+ const composed = await composeApprovedForecast(plan, { signal });
163
+ return toolResult({
164
+ status: "composed",
165
+ artifact: composed.artifact as unknown as Record<string, unknown>,
166
+ diagnostics: composed.diagnostics as unknown as Record<string, unknown>,
52
167
  });
53
168
  },
54
169
  });
@@ -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,26 +104,176 @@ 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
  *
66
244
  * Same fail-closed posture as the committed artifacts themselves: no escaping
67
245
  * the project root, no symlinks, no unbounded reads.
68
246
  */
69
- async function resolveDraftPath(projectRoot: string, artifactPath: string): Promise<string> {
70
- if (!artifactPath.trim()) throw new Error("artifact_path must be a non-empty path.");
247
+ async function resolveDraftPath(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<string> {
248
+ if (!artifactPath.trim()) throw new Error(`${label} must be a non-empty path.`);
71
249
  const root = await realpath(projectRoot);
72
250
  const resolved = resolve(root, artifactPath);
73
251
  const relation = relative(root, resolved);
74
252
  if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) {
75
- throw new Error("artifact_path must stay inside the project directory.");
253
+ throw new Error(`${label} must stay inside the project directory.`);
76
254
  }
77
255
  const stat = await lstat(resolved);
78
- if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("artifact_path must point at a regular file, not a symlink.");
79
- if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`artifact_path exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
256
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must point at a regular file, not a symlink.`);
257
+ if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${label} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
80
258
  return resolved;
81
259
  }
82
260
 
261
+ /**
262
+ * Read and parse a JSON file the agent wrote somewhere inside the project.
263
+ *
264
+ * Shared by every by-path entry point so they all get the same fail-closed
265
+ * posture, and so a malformed file is reported as a parse error against the
266
+ * caller's own path rather than as something deeper and less obvious.
267
+ */
268
+ export async function readProjectJsonFile(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<unknown> {
269
+ const path = await resolveDraftPath(projectRoot, artifactPath, label);
270
+ try {
271
+ return JSON.parse(await readFile(path, "utf8"));
272
+ } catch (error) {
273
+ throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
274
+ }
275
+ }
276
+
83
277
  /**
84
278
  * Commit a canonical artifact an agent wrote to disk instead of passing inline.
85
279
  *
@@ -90,51 +284,192 @@ async function resolveDraftPath(projectRoot: string, artifactPath: string): Prom
90
284
  * many turns as it takes, and this entry point keeps the validation,
91
285
  * reconciliation, fingerprinting, and atomic write identical to the inline path.
92
286
  */
93
- export async function commitArtifactFromPath(projectRoot: string, artifactPath: string): Promise<CommitArtifactResult> {
94
- const path = await resolveDraftPath(projectRoot, artifactPath);
95
- let parsed: unknown;
96
- try {
97
- parsed = JSON.parse(await readFile(path, "utf8"));
98
- } catch (error) {
99
- throw new Error(`artifact_path is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
287
+ export async function commitArtifactFromPath(projectRoot: string, artifactPath: string, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
288
+ return commitArtifact(projectRoot, await readProjectJsonFile(projectRoot, artifactPath), options);
289
+ }
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.`);
100
331
  }
101
- return commitArtifact(projectRoot, parsed);
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 };
102
342
  }
103
343
 
104
- export async function commitArtifact(projectRoot: string, input: unknown): Promise<CommitArtifactResult> {
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
+ }
352
+
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> {
105
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
+ }
106
425
  if (artifact.artifact_type === "execution_receipt") {
107
- 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
+ }
108
440
  if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
109
441
  validateExecutionAgainstForecast(artifact, forecastRead.artifact);
110
442
  }
111
443
  const fingerprint = artifactFingerprint(artifact);
112
444
  const committed = { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
113
- const artifactsDir = await ensureArtifactDirectory(projectRoot);
114
- const destination = join(artifactsDir, `${artifact.artifact_type}.json`);
115
- 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`);
116
451
 
117
- const handle = await open(temporary, "wx", 0o600);
118
- let closed = false;
119
- try {
120
- await handle.writeFile(`${JSON.stringify(committed, null, 2)}\n`, "utf8");
121
- await handle.sync();
122
- await handle.close();
123
- closed = true;
124
- await rename(temporary, destination);
125
- const directoryHandle = await open(artifactsDir, "r");
126
- try {
127
- await directoryHandle.sync();
128
- } finally {
129
- await directoryHandle.close();
130
- }
131
- } catch (error) {
132
- if (!closed) await handle.close().catch(() => undefined);
133
- await unlink(temporary).catch(() => undefined);
134
- throw error;
135
- }
452
+ return {
453
+ artifactType: artifact.artifact_type,
454
+ fingerprint,
455
+ path: destination,
456
+ ledgerStatus: artifactRef ? "assigned" : "legacy_unassigned",
457
+ ...(artifactRef ? { artifactRef } : {}),
458
+ };
459
+ }
136
460
 
137
- 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 };
138
473
  }
139
474
 
140
475
  export async function readCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult> {
@@ -149,17 +484,8 @@ export async function readCommittedArtifact(projectRoot: string, artifactType: A
149
484
  throw new Error(`${artifactType} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
150
485
  }
151
486
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${artifactType} must contain a JSON object.`);
152
- const source = { ...(parsed as Record<string, unknown>) };
153
- const fingerprint = source.immutable_fingerprint;
154
- delete source.immutable_fingerprint;
155
- if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
156
- throw new Error(`${artifactType} has no valid immutable_fingerprint.`);
157
- }
158
- const artifact = validateArtifact(source);
159
- if (artifact.artifact_type !== artifactType) throw new Error(`${path} contains ${artifact.artifact_type}, expected ${artifactType}.`);
160
- const expected = artifactFingerprint(artifact);
161
- if (expected !== fingerprint) throw new Error(`${artifactType} fingerprint mismatch: the committed artifact was modified after freezing.`);
162
- 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 };
163
489
  }
164
490
 
165
491
  export async function readOptionalCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult | null> {