@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.
@@ -0,0 +1,147 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const DASHBOARD_CONTRACT_VERSION = 1;
6
+
7
+ const PRESET_VERSIONS = {
8
+ "forecast-closed-loop-v1": 1,
9
+ } as const;
10
+
11
+ const PROJECTOR_SOURCE_FILES = [
12
+ "extensions/fpa-artifacts/contracts.ts",
13
+ "extensions/fpa-artifacts/store.ts",
14
+ "extensions/fpa-dashboard/actuals.ts",
15
+ "extensions/fpa-dashboard/cycle-operating-projection.ts",
16
+ "extensions/fpa-dashboard/forward-outlook.ts",
17
+ "extensions/fpa-dashboard/forecast-accuracy.ts",
18
+ "extensions/fpa-dashboard/coordinator.ts",
19
+ "extensions/fpa-dashboard/index.ts",
20
+ "extensions/fpa-dashboard/projector.ts",
21
+ "extensions/fpa-dashboard/provenance.ts",
22
+ "extensions/fpa-dashboard/publisher.ts",
23
+ "extensions/fpa-dashboard/schema.ts",
24
+ "extensions/fpa-dashboard/service.ts",
25
+ "extensions/fpa-dashboard/source.ts",
26
+ "extensions/fpa-data/calc.ts",
27
+ "extensions/fpa-data/registry.ts",
28
+ "extensions/fpa-data/runtime.ts",
29
+ "extensions/fpa-data/sql.ts",
30
+ "extensions/fpa-data/supabase.ts",
31
+ ] as const;
32
+
33
+ export interface DashboardProjectorProvenance {
34
+ package_name: "@viccydev/pi-fpa";
35
+ package_version: string;
36
+ code_sha256: string;
37
+ dashboard_contract_version: number;
38
+ preset_id: keyof typeof PRESET_VERSIONS;
39
+ preset_version: number;
40
+ }
41
+
42
+ export interface DashboardProjectorRuntime {
43
+ package_realpath_sha256: string;
44
+ }
45
+
46
+ export interface LoadedProjectorProvenance {
47
+ projector: DashboardProjectorProvenance;
48
+ runtime: DashboardProjectorRuntime;
49
+ }
50
+
51
+ interface BaseProjectorProvenance {
52
+ package_name: "@viccydev/pi-fpa";
53
+ package_version: string;
54
+ code_sha256: string;
55
+ dashboard_contract_version: number;
56
+ runtime: DashboardProjectorRuntime;
57
+ }
58
+
59
+ const SHA256_RE = /^[a-f0-9]{64}$/;
60
+
61
+ function sha256(value: string | Uint8Array): string {
62
+ return createHash("sha256").update(value).digest("hex");
63
+ }
64
+
65
+ function record(value: unknown, path: string): Record<string, unknown> {
66
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
67
+ return value as Record<string, unknown>;
68
+ }
69
+
70
+ function requiredString(value: unknown, path: string): string {
71
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
72
+ return value;
73
+ }
74
+
75
+ function requiredSha256(value: unknown, path: string): string {
76
+ const parsed = requiredString(value, path);
77
+ if (!SHA256_RE.test(parsed)) throw new Error(`${path} must be a SHA-256 digest.`);
78
+ return parsed;
79
+ }
80
+
81
+ export function validateProjectorProvenance(value: unknown): DashboardProjectorProvenance {
82
+ const source = record(value, "projector");
83
+ const packageName = requiredString(source.package_name, "projector.package_name");
84
+ if (packageName !== "@viccydev/pi-fpa") throw new Error("projector.package_name is unsupported.");
85
+ const presetId = requiredString(source.preset_id, "projector.preset_id");
86
+ if (!(presetId in PRESET_VERSIONS)) throw new Error("projector.preset_id is unsupported.");
87
+ const contractVersion = source.dashboard_contract_version;
88
+ if (contractVersion !== DASHBOARD_CONTRACT_VERSION) throw new Error(`projector.dashboard_contract_version must be ${DASHBOARD_CONTRACT_VERSION}.`);
89
+ const presetVersion = source.preset_version;
90
+ if (!Number.isInteger(presetVersion) || presetVersion !== PRESET_VERSIONS[presetId as keyof typeof PRESET_VERSIONS]) {
91
+ throw new Error("projector.preset_version does not match projector.preset_id.");
92
+ }
93
+ return {
94
+ package_name: "@viccydev/pi-fpa",
95
+ package_version: requiredString(source.package_version, "projector.package_version"),
96
+ code_sha256: requiredSha256(source.code_sha256, "projector.code_sha256"),
97
+ dashboard_contract_version: contractVersion as number,
98
+ preset_id: presetId as keyof typeof PRESET_VERSIONS,
99
+ preset_version: presetVersion as number,
100
+ };
101
+ }
102
+
103
+ export function validateProjectorRuntime(value: unknown): DashboardProjectorRuntime {
104
+ const source = record(value, "runtime");
105
+ return {
106
+ package_realpath_sha256: requiredSha256(source.package_realpath_sha256, "runtime.package_realpath_sha256"),
107
+ };
108
+ }
109
+
110
+ async function loadBaseProjectorProvenance(): Promise<BaseProjectorProvenance> {
111
+ const packageRoot = await realpath(fileURLToPath(new URL("../../", import.meta.url)));
112
+ const packageJson = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8")) as Record<string, unknown>;
113
+ if (packageJson.name !== "@viccydev/pi-fpa" || typeof packageJson.version !== "string" || packageJson.version.trim() === "") {
114
+ throw new Error("Could not identify the installed @viccydev/pi-fpa package.");
115
+ }
116
+ const codeHash = createHash("sha256");
117
+ for (const relativePath of PROJECTOR_SOURCE_FILES) {
118
+ const contents = await readFile(new URL(`../../${relativePath}`, import.meta.url));
119
+ codeHash.update(relativePath);
120
+ codeHash.update("\0");
121
+ codeHash.update(contents);
122
+ codeHash.update("\0");
123
+ }
124
+ return {
125
+ package_name: "@viccydev/pi-fpa",
126
+ package_version: packageJson.version,
127
+ code_sha256: codeHash.digest("hex"),
128
+ dashboard_contract_version: DASHBOARD_CONTRACT_VERSION,
129
+ runtime: { package_realpath_sha256: sha256(packageRoot) },
130
+ };
131
+ }
132
+
133
+ export async function loadProjectorProvenance(presetId: string): Promise<LoadedProjectorProvenance> {
134
+ if (!(presetId in PRESET_VERSIONS)) throw new Error(`Unsupported dashboard preset "${presetId}".`);
135
+ const base = await loadBaseProjectorProvenance();
136
+ return {
137
+ projector: {
138
+ package_name: base.package_name,
139
+ package_version: base.package_version,
140
+ code_sha256: base.code_sha256,
141
+ dashboard_contract_version: base.dashboard_contract_version,
142
+ preset_id: presetId as keyof typeof PRESET_VERSIONS,
143
+ preset_version: PRESET_VERSIONS[presetId as keyof typeof PRESET_VERSIONS],
144
+ },
145
+ runtime: base.runtime,
146
+ };
147
+ }
@@ -1,17 +1,27 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
2
+ import { link, lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
4
 
5
5
  import { stableJson } from "../fpa-artifacts/store.ts";
6
6
  import type { DashboardBuild, DashboardWidget } from "./projector.ts";
7
+ import {
8
+ validateProjectorProvenance,
9
+ validateProjectorRuntime,
10
+ type DashboardProjectorProvenance,
11
+ type DashboardProjectorRuntime,
12
+ } from "./provenance.ts";
7
13
  import { validateDatasetForWidget } from "./schema.ts";
14
+ import { validateActualsSnapshot, type DashboardActualsSnapshot } from "./source.ts";
8
15
 
9
- const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
16
+ const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,191}\.json$/;
10
17
 
11
18
  export interface PublishDashboardOptions {
12
19
  cwd: string;
13
20
  build: DashboardBuild;
21
+ projector: DashboardProjectorProvenance;
22
+ runtime: DashboardProjectorRuntime;
14
23
  publishedAt?: string;
24
+ actualsSnapshot?: DashboardActualsSnapshot;
15
25
  }
16
26
 
17
27
  export interface PublishDashboardResult {
@@ -59,7 +69,7 @@ function contentAddressDataset(widget: DashboardWidget): PublishedDataset {
59
69
  const contents = `${JSON.stringify(widget.data, null, 2)}\n`;
60
70
  const digest = sha256(stableJson(widget.data));
61
71
  const logicalBase = widget.dataset.slice(0, -".json".length);
62
- const filename = `${logicalBase}.${digest.slice(0, 12)}.json`;
72
+ const filename = `${logicalBase}.${digest}.json`;
63
73
  if (!DATASET_FILE_RE.test(filename)) {
64
74
  throw new Error(`Content-addressed dataset name "${filename}" exceeds the dashboard contract.`);
65
75
  }
@@ -102,33 +112,93 @@ async function atomicWrite(destination: string, contents: string): Promise<void>
102
112
  }
103
113
  }
104
114
 
115
+ async function appendOnlyWrite(directory: string, destination: string, contents: string, verifyExisting = true): Promise<void> {
116
+ const temporary = join(directory, `.${basename(destination)}.${randomUUID()}.tmp`);
117
+ const handle = await open(temporary, "wx", 0o600);
118
+ try {
119
+ await handle.writeFile(contents, "utf8");
120
+ await handle.sync();
121
+ } finally {
122
+ await handle.close();
123
+ }
124
+ try {
125
+ await link(temporary, destination);
126
+ } catch (error) {
127
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
128
+ if (verifyExisting && await readFile(destination, "utf8") !== contents) throw new Error(`Append-only dashboard object ${basename(destination)} already exists with different content.`);
129
+ } finally {
130
+ await unlink(temporary).catch(() => undefined);
131
+ }
132
+ }
133
+
105
134
  export async function resolveDashboardDir(cwd: string): Promise<string> {
106
135
  const projectRoot = await realpath(cwd);
107
136
  return join(dirname(projectRoot), ".fpa-dashboard");
108
137
  }
109
138
 
110
- export function dashboardBuildFingerprint(build: DashboardBuild): string {
139
+ function generationSource(source: DashboardBuild["source"]): Record<string, unknown> {
140
+ const {
141
+ actuals_snapshot_ref: _actualsSnapshotRef,
142
+ actuals_snapshot_evidence: evidence,
143
+ ...stableSource
144
+ } = source;
145
+ const stableEvidence = evidence ? Object.fromEntries(Object.entries({
146
+ consistency: evidence.consistency,
147
+ source_close_signal_id: evidence.source_close_signal_id,
148
+ source_closed_through: evidence.source_closed_through,
149
+ source_close_emitted_at: evidence.source_close_emitted_at,
150
+ source_close_signal_sha256: evidence.source_close_signal_sha256,
151
+ period_complete: evidence.period_complete,
152
+ reconciled: evidence.reconciled,
153
+ }).filter(([, value]) => value !== undefined)) : undefined;
154
+ return {
155
+ ...stableSource,
156
+ ...(stableEvidence ? { actuals_snapshot_evidence: stableEvidence } : {}),
157
+ };
158
+ }
159
+
160
+ export function dashboardBuildFingerprint(build: DashboardBuild, projector: DashboardProjectorProvenance): string {
111
161
  assertBuild(build);
162
+ const validatedProjector = validateProjectorProvenance(projector);
112
163
  return sha256(stableJson({
113
164
  title: build.title,
114
165
  widgets: build.widgets,
115
- source: build.source,
166
+ // Observation metadata proves when/how a query ran, but it is not business
167
+ // content. Keep it in the receipt while excluding it from generation identity
168
+ // so preview and publish of unchanged Actuals remain byte-stable.
169
+ source: generationSource(build.source),
116
170
  warnings: build.warnings,
171
+ ...(build.operating_projection ? { operating_projection: build.operating_projection } : {}),
172
+ ...(build.forward_outlook ? { forward_outlook: build.forward_outlook } : {}),
173
+ ...(build.forecast_accuracy ? { forecast_accuracy: build.forecast_accuracy } : {}),
174
+ projector: validatedProjector,
117
175
  }));
118
176
  }
119
177
 
120
178
  export async function publishDashboard(options: PublishDashboardOptions): Promise<PublishDashboardResult> {
121
179
  assertBuild(options.build);
180
+ const projector = validateProjectorProvenance(options.projector);
181
+ const runtime = validateProjectorRuntime(options.runtime);
122
182
  const dashboardDir = await resolveDashboardDir(options.cwd);
123
183
  await ensureOwnedDirectory(dashboardDir, "Dashboard directory");
124
184
  const datasetsDir = join(dashboardDir, "datasets");
125
185
  await ensureOwnedDirectory(datasetsDir, "Dashboard datasets directory");
186
+ const generationsDir = join(dashboardDir, "generations");
187
+ await ensureOwnedDirectory(generationsDir, "Dashboard generations directory");
188
+ if (options.actualsSnapshot) {
189
+ const actuals = validateActualsSnapshot(options.actualsSnapshot);
190
+ const actualsDigest = sha256(stableJson(actuals));
191
+ if (options.build.source.actuals_snapshot_ref !== actualsDigest) throw new Error("Dashboard Actuals snapshot ref does not match the supplied immutable snapshot.");
192
+ const actualsDir = join(dashboardDir, "actuals");
193
+ await ensureOwnedDirectory(actualsDir, "Dashboard Actuals snapshot directory");
194
+ await appendOnlyWrite(actualsDir, join(actualsDir, `${actualsDigest}.json`), `${JSON.stringify(actuals, null, 2)}\n`);
195
+ }
126
196
 
127
197
  const publishedAt = options.publishedAt ?? new Date().toISOString();
128
- const fingerprint = dashboardBuildFingerprint(options.build);
198
+ const fingerprint = dashboardBuildFingerprint(options.build, projector);
129
199
  const datasets = options.build.widgets.map(contentAddressDataset);
130
200
  for (const dataset of datasets) {
131
- await atomicWrite(join(datasetsDir, dataset.filename), dataset.contents);
201
+ await appendOnlyWrite(datasetsDir, join(datasetsDir, dataset.filename), dataset.contents);
132
202
  }
133
203
 
134
204
  const receipt = {
@@ -138,12 +208,17 @@ export async function publishDashboard(options: PublishDashboardOptions): Promis
138
208
  published_at: publishedAt,
139
209
  source: options.build.source,
140
210
  warnings: options.build.warnings,
211
+ ...(options.build.operating_projection ? { operating_projection: options.build.operating_projection } : {}),
212
+ ...(options.build.forward_outlook ? { forward_outlook: options.build.forward_outlook } : {}),
213
+ ...(options.build.forecast_accuracy ? { forecast_accuracy: options.build.forecast_accuracy } : {}),
214
+ projector,
215
+ runtime,
141
216
  datasets: datasets.map(({ logical, filename, sha256 }) => ({ logical, filename, sha256 })),
142
217
  };
143
218
  const receiptContents = `${JSON.stringify(receipt, null, 2)}\n`;
144
219
  const receiptDigest = sha256(stableJson(receipt));
145
- const receiptFilename = `build-receipt.${fingerprint.slice(0, 12)}.${receiptDigest.slice(0, 12)}.json`;
146
- await atomicWrite(join(dashboardDir, receiptFilename), receiptContents);
220
+ const receiptFilename = `build-receipt.${fingerprint}.${receiptDigest}.json`;
221
+ await appendOnlyWrite(dashboardDir, join(dashboardDir, receiptFilename), receiptContents);
147
222
  // Compatibility pointer for older diagnostics. The manifest below points to
148
223
  // the generation-specific receipt, so concurrent publishers cannot mix lineage.
149
224
  await atomicWrite(join(dashboardDir, "build-receipt.json"), receiptContents);
@@ -163,6 +238,21 @@ export async function publishDashboard(options: PublishDashboardOptions): Promis
163
238
  dataset: byLogical.get(widget.dataset),
164
239
  })),
165
240
  };
241
+ const generationRecord = {
242
+ kind: "fpa.dashboard.generation",
243
+ schema_version: 1,
244
+ generation_id: fingerprint,
245
+ published_at: publishedAt,
246
+ title: options.build.title,
247
+ build_receipt: receiptFilename,
248
+ source: options.build.source,
249
+ warnings: options.build.warnings,
250
+ projector,
251
+ widgets: manifest.widgets,
252
+ };
253
+ // One immutable catalog entry per content generation. Re-publishing the same
254
+ // deterministic generation never rewrites its historical identity.
255
+ await appendOnlyWrite(generationsDir, join(generationsDir, `${fingerprint}.json`), `${JSON.stringify(generationRecord, null, 2)}\n`, false);
166
256
  // This is the commit point: every referenced dataset and receipt is
167
257
  // generation-specific, so concurrent publishers remain atomic without locks.
168
258
  await atomicWrite(join(dashboardDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -0,0 +1,179 @@
1
+ import {
2
+ readArtifactByRef,
3
+ readCommittedArtifact,
4
+ readOptionalCommittedArtifact,
5
+ type ArtifactRefV2,
6
+ type ReadArtifactResult,
7
+ } from "../fpa-artifacts/store.ts";
8
+ import { realpath } from "node:fs/promises";
9
+ import { basename } from "node:path";
10
+ import {
11
+ isExecutionReceiptForForecast,
12
+ sliceKey,
13
+ type ApprovedCycleForecast,
14
+ type ApprovedCycleForecastInput,
15
+ type CanonicalArtifact,
16
+ type ExecutionReceipt,
17
+ type ExecutionReceiptInput,
18
+ } from "../fpa-artifacts/contracts.ts";
19
+ import { dashboardActualsSnapshotFingerprint, dashboardActualsWatermark, detectSliceKeyMismatch, loadDashboardActuals } from "./actuals.ts";
20
+ import type { DashboardActualsSnapshot } from "./source.ts";
21
+ import { projectCycleDecisionWidgets, projectCycleOperatingProjection } from "./cycle-operating-projection.ts";
22
+ import { projectDashboard, type DashboardBuild } from "./projector.ts";
23
+ import { projectForwardOutlook, projectForwardOutlookWidgets } from "./forward-outlook.ts";
24
+ import { loadHistoricalOperatingProjections, projectForecastAccuracy, projectForecastAccuracyWidgets } from "./forecast-accuracy.ts";
25
+ import {
26
+ loadProjectorProvenance,
27
+ type DashboardProjectorProvenance,
28
+ type DashboardProjectorRuntime,
29
+ } from "./provenance.ts";
30
+
31
+ export interface ExactDashboardInputs {
32
+ scopeId: string;
33
+ cycleId: string;
34
+ forecastRef: ArtifactRefV2;
35
+ executionRef?: ArtifactRefV2;
36
+ nextForecastRef?: ArtifactRefV2;
37
+ forwardForecastRefs?: ArtifactRefV2[];
38
+ }
39
+
40
+ export interface DashboardProjectionResult {
41
+ build: DashboardBuild;
42
+ forecast: ApprovedCycleForecast;
43
+ forecastRead: ReadArtifactResult;
44
+ executionRead: ReadArtifactResult | null;
45
+ sliceKeyMismatch: string | null;
46
+ projector: DashboardProjectorProvenance;
47
+ runtime: DashboardProjectorRuntime;
48
+ actuals: DashboardActualsSnapshot;
49
+ }
50
+
51
+ function withoutFingerprint<T extends CanonicalArtifact>(artifact: T): Omit<T, "immutable_fingerprint"> {
52
+ const { immutable_fingerprint: _fingerprint, ...input } = artifact;
53
+ return input;
54
+ }
55
+
56
+ /**
57
+ * The single business projection entry point used by the interactive tool and
58
+ * the durable coordinator. Hosts may schedule it, but never reproduce its FP&A
59
+ * calculations or write dashboard files themselves.
60
+ */
61
+ export async function buildDashboardProjection(
62
+ cwd: string,
63
+ preset: string,
64
+ locale: string,
65
+ exact?: ExactDashboardInputs,
66
+ signal?: AbortSignal,
67
+ ): Promise<DashboardProjectionResult> {
68
+ const [forecastRead, provenance, projectRoot] = await Promise.all([
69
+ exact ? readArtifactByRef(cwd, exact.forecastRef) : readCommittedArtifact(cwd, "approved_cycle_forecast"),
70
+ loadProjectorProvenance(preset),
71
+ realpath(cwd),
72
+ ]);
73
+ if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
74
+ if (exact && (exact.forecastRef.scope_id !== exact.scopeId || exact.forecastRef.cycle_id !== exact.cycleId || exact.forecastRef.artifact_type !== "approved_cycle_forecast")) {
75
+ throw new Error("forecast_ref must identify an approved forecast in the requested scope_id and cycle_id.");
76
+ }
77
+ const forecast = forecastRead.artifact as ApprovedCycleForecast;
78
+ const forwardRefs = exact?.forwardForecastRefs ?? (exact?.nextForecastRef ? [exact.nextForecastRef] : []);
79
+ if (forwardRefs.length > 6) throw new Error("forwardForecastRefs must contain at most six forecasts.");
80
+ if (exact?.nextForecastRef && forwardRefs.length > 0 && forwardRefs[0].entry_id !== exact.nextForecastRef.entry_id) {
81
+ throw new Error("nextForecastRef must equal the first forwardForecastRefs entry.");
82
+ }
83
+ const forwardForecastReads = await Promise.all(forwardRefs.map((ref) => readArtifactByRef(cwd, ref)));
84
+ const nextForecastRead = forwardForecastReads[0] ?? null;
85
+ if (exact?.nextForecastRef && (exact.nextForecastRef.scope_id !== exact.scopeId || exact.nextForecastRef.artifact_type !== "approved_cycle_forecast")) {
86
+ throw new Error("next_forecast_ref must identify an approved forecast in the requested scope_id.");
87
+ }
88
+ if (nextForecastRead && nextForecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("next_forecast_ref resolved to the wrong artifact type.");
89
+ const committedExecutionRead = exact
90
+ ? exact.executionRef ? await readArtifactByRef(cwd, exact.executionRef) : null
91
+ : await readOptionalCommittedArtifact(cwd, "execution_receipt");
92
+ if (exact?.executionRef && (exact.executionRef.scope_id !== exact.scopeId || exact.executionRef.cycle_id !== exact.cycleId || exact.executionRef.artifact_type !== "execution_receipt")) {
93
+ throw new Error("execution_ref must identify an execution receipt in the requested scope_id and cycle_id.");
94
+ }
95
+ const executionRead = committedExecutionRead?.artifact.artifact_type === "execution_receipt" && isExecutionReceiptForForecast(committedExecutionRead.artifact, forecast)
96
+ ? committedExecutionRead
97
+ : null;
98
+ const execution = executionRead?.artifact.artifact_type === "execution_receipt"
99
+ ? executionRead.artifact as ExecutionReceipt
100
+ : null;
101
+ const actuals = await loadDashboardActuals(forecast.target_period, { slices: forecast.approved_allocation }, signal);
102
+ const build = projectDashboard({
103
+ forecast: withoutFingerprint(forecast) as ApprovedCycleForecastInput,
104
+ actuals,
105
+ ...(execution ? { execution: withoutFingerprint(execution) as ExecutionReceiptInput } : {}),
106
+ locale,
107
+ });
108
+ build.source.project_name = basename(projectRoot);
109
+ build.source.actuals_watermark = dashboardActualsWatermark(actuals);
110
+ build.source.actuals_snapshot_ref = dashboardActualsSnapshotFingerprint(actuals);
111
+ build.source.forecast_fingerprint = forecastRead.fingerprint;
112
+ if (executionRead) build.source.execution_fingerprint = executionRead.fingerprint;
113
+ if (exact) {
114
+ build.source.scope_id = exact.scopeId;
115
+ build.source.cycle_id = exact.cycleId;
116
+ build.source.forecast_ref = exact.forecastRef.entry_id;
117
+ if (forecastRead.ledgerContext?.forecast_role) build.source.forecast_role = forecastRead.ledgerContext.forecast_role;
118
+ build.source.actuals_snapshot_evidence = actuals.snapshot_evidence;
119
+ if (exact.executionRef) build.source.execution_ref = exact.executionRef.entry_id;
120
+ if (forwardRefs[0]) {
121
+ build.source.next_forecast_ref = forwardRefs[0].entry_id;
122
+ build.source.next_forecast_fingerprint = forwardRefs[0].body_fingerprint;
123
+ }
124
+ if (forwardRefs.length > 0) {
125
+ for (const [index, ref] of forwardRefs.entries()) {
126
+ if (ref.scope_id !== exact.scopeId || ref.artifact_type !== "approved_cycle_forecast") throw new Error(`forwardForecastRefs[${index}] must be an approved forecast in the requested scope.`);
127
+ }
128
+ build.source.forward_forecast_refs = forwardRefs.map((ref) => ref.entry_id);
129
+ build.source.forward_forecast_fingerprints = forwardRefs.map((ref) => ref.body_fingerprint);
130
+ }
131
+ build.operating_projection = projectCycleOperatingProjection({
132
+ scope_id: exact.scopeId,
133
+ cycle_id: exact.cycleId,
134
+ current_forecast: withoutFingerprint(forecast),
135
+ current_forecast_role: forecastRead.ledgerContext?.forecast_role,
136
+ actuals,
137
+ ...(execution ? { execution: withoutFingerprint(execution) } : {}),
138
+ ...(nextForecastRead?.artifact.artifact_type === "approved_cycle_forecast"
139
+ ? {
140
+ next_forecast: withoutFingerprint(nextForecastRead.artifact),
141
+ next_forecast_role: nextForecastRead.ledgerContext?.forecast_role,
142
+ }
143
+ : {}),
144
+ });
145
+ const baseWidgets = build.widgets;
146
+ const decisionWidgets = projectCycleDecisionWidgets(build.operating_projection, locale);
147
+ build.warnings.push(...build.operating_projection.warnings);
148
+ build.forward_outlook = projectForwardOutlook({
149
+ current_forecast: withoutFingerprint(forecast),
150
+ forward_forecasts: forwardForecastReads.map((read, index) => ({ ref: forwardRefs[index], forecast: withoutFingerprint(read.artifact) })),
151
+ });
152
+ build.forecast_accuracy = projectForecastAccuracy([
153
+ ...await loadHistoricalOperatingProjections(cwd, exact.scopeId),
154
+ build.operating_projection,
155
+ ]);
156
+ build.widgets = [
157
+ ...decisionWidgets,
158
+ ...projectForwardOutlookWidgets(build.forward_outlook, locale),
159
+ ...projectForecastAccuracyWidgets(build.forecast_accuracy, locale),
160
+ ...baseWidgets,
161
+ ];
162
+ build.warnings.push(...build.forward_outlook.warnings);
163
+ build.warnings.push(...build.forecast_accuracy.warnings);
164
+ }
165
+
166
+ const plannedKeys = new Set(forecast.approved_allocation.map(sliceKey));
167
+ const actualKeys = new Set(actuals.slices.map(sliceKey));
168
+ const unplanned = actuals.slices.filter((slice) => !plannedKeys.has(sliceKey(slice)) && ((slice.spend ?? 0) > 0 || (slice.revenue ?? 0) !== 0));
169
+ const missing = forecast.approved_allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
170
+ if (unplanned.length > 0) build.warnings.push(`${unplanned.length} paid Actuals slices are outside the approved allocation and were excluded from like-for-like forecast totals and the strategy table.`);
171
+ if (missing.length > 0) build.warnings.push(`${missing.length} approved slices have no current-period Actuals row; their values remain null.`);
172
+ if (committedExecutionRead && !executionRead) build.warnings.push(`Ignored stale execution receipt for forecast ${committedExecutionRead.artifact.forecast_version}; current forecast is ${forecast.forecast_version}.`);
173
+ if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
174
+ if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
175
+
176
+ const sliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
177
+ if (sliceKeyMismatch) build.warnings.push(sliceKeyMismatch);
178
+ return { build, forecast, forecastRead, executionRead, actuals, sliceKeyMismatch, ...provenance };
179
+ }