@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.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +4 -1
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +85 -4
- package/extensions/fpa-artifacts/store.ts +355 -39
- package/extensions/fpa-dashboard/actuals.ts +215 -51
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +88 -79
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +118 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +7 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +2 -0
- package/skills/fpa-refresh-dashboard/SKILL.md +9 -1
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
146
|
-
await
|
|
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
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { sliceKey, type Period } from "../fpa-artifacts/contracts.ts";
|
|
4
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
2
5
|
|
|
3
6
|
export interface QueryReceipt {
|
|
4
7
|
dataset: string;
|
|
@@ -18,6 +21,27 @@ export interface DashboardActualSlice {
|
|
|
18
21
|
/** Sentinel used when no Actuals row exists yet, so no calendar date can be reported. */
|
|
19
22
|
export const ACTUALS_DATA_AS_OF_UNAVAILABLE = "unavailable";
|
|
20
23
|
|
|
24
|
+
function canonicalCalendarDate(value: string, label: string): string {
|
|
25
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${label} must use YYYY-MM-DD.`);
|
|
26
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
27
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
28
|
+
throw new Error(`${label} must be a real canonical calendar date.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finalPeriodDate(value: Period): string {
|
|
34
|
+
const finalInstant = new Date(Date.parse(value.end_exclusive) - 1);
|
|
35
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
36
|
+
timeZone: value.timezone,
|
|
37
|
+
year: "numeric",
|
|
38
|
+
month: "2-digit",
|
|
39
|
+
day: "2-digit",
|
|
40
|
+
}).formatToParts(finalInstant);
|
|
41
|
+
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
|
42
|
+
return `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
/**
|
|
22
46
|
* How Actuals were scoped against the frozen forecast.
|
|
23
47
|
*
|
|
@@ -37,6 +61,18 @@ export interface DashboardDailyPoint {
|
|
|
37
61
|
export interface DashboardActualsSnapshot {
|
|
38
62
|
period: Period;
|
|
39
63
|
data_as_of: string;
|
|
64
|
+
snapshot_evidence: {
|
|
65
|
+
snapshot_id: string | null;
|
|
66
|
+
available_at: string | null;
|
|
67
|
+
consistency: "single_statement" | "multi_query_unverified" | "legacy_unverified";
|
|
68
|
+
/** Explicit close signal emitted by the source ETL; time elapsed is never treated as closure. */
|
|
69
|
+
source_close_signal_id: string | null;
|
|
70
|
+
source_closed_through: string | null;
|
|
71
|
+
source_close_emitted_at: string | null;
|
|
72
|
+
source_close_signal_sha256: string | null;
|
|
73
|
+
period_complete: boolean;
|
|
74
|
+
reconciled: boolean;
|
|
75
|
+
};
|
|
40
76
|
reporting_currency: string;
|
|
41
77
|
scope_mode: DashboardScopeMode;
|
|
42
78
|
/**
|
|
@@ -112,10 +148,12 @@ function period(value: unknown, path: string): Period {
|
|
|
112
148
|
|
|
113
149
|
export function validateActualsSnapshot(value: unknown): DashboardActualsSnapshot {
|
|
114
150
|
const source = record(value, "actuals");
|
|
151
|
+
const snapshotPeriod = period(source.period, "actuals.period");
|
|
115
152
|
const coverage = record(source.coverage, "actuals.coverage");
|
|
116
153
|
const dailySource = source.daily;
|
|
117
154
|
const slicesSource = source.slices;
|
|
118
155
|
const receiptsSource = source.query_receipts;
|
|
156
|
+
const evidenceSource = source.snapshot_evidence === undefined ? null : record(source.snapshot_evidence, "actuals.snapshot_evidence");
|
|
119
157
|
if (!Array.isArray(dailySource)) throw new Error("actuals.daily must be an array.");
|
|
120
158
|
if (!Array.isArray(slicesSource)) throw new Error("actuals.slices must be an array.");
|
|
121
159
|
if (!Array.isArray(receiptsSource)) throw new Error("actuals.query_receipts must be an array.");
|
|
@@ -158,9 +196,88 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
|
|
|
158
196
|
if (source.period_has_unscoped_actuals !== undefined && typeof source.period_has_unscoped_actuals !== "boolean") {
|
|
159
197
|
throw new Error("actuals.period_has_unscoped_actuals must be a boolean.");
|
|
160
198
|
}
|
|
199
|
+
let snapshotEvidence: DashboardActualsSnapshot["snapshot_evidence"] = {
|
|
200
|
+
snapshot_id: null,
|
|
201
|
+
available_at: null,
|
|
202
|
+
consistency: "legacy_unverified",
|
|
203
|
+
source_close_signal_id: null,
|
|
204
|
+
source_closed_through: null,
|
|
205
|
+
source_close_emitted_at: null,
|
|
206
|
+
source_close_signal_sha256: null,
|
|
207
|
+
period_complete: false,
|
|
208
|
+
reconciled: false,
|
|
209
|
+
};
|
|
210
|
+
if (evidenceSource) {
|
|
211
|
+
const consistency = string(evidenceSource.consistency, "actuals.snapshot_evidence.consistency");
|
|
212
|
+
if (consistency !== "single_statement" && consistency !== "multi_query_unverified" && consistency !== "legacy_unverified") {
|
|
213
|
+
throw new Error("actuals.snapshot_evidence.consistency is unsupported.");
|
|
214
|
+
}
|
|
215
|
+
if (evidenceSource.snapshot_id !== null && typeof evidenceSource.snapshot_id !== "string") throw new Error("actuals.snapshot_evidence.snapshot_id must be a string or null.");
|
|
216
|
+
if (evidenceSource.available_at !== null && (typeof evidenceSource.available_at !== "string" || Number.isNaN(Date.parse(evidenceSource.available_at)))) {
|
|
217
|
+
throw new Error("actuals.snapshot_evidence.available_at must be an ISO timestamp or null.");
|
|
218
|
+
}
|
|
219
|
+
if (typeof evidenceSource.period_complete !== "boolean" || typeof evidenceSource.reconciled !== "boolean") {
|
|
220
|
+
throw new Error("actuals.snapshot_evidence period_complete and reconciled must be booleans.");
|
|
221
|
+
}
|
|
222
|
+
const sourceCloseSignalId = evidenceSource.source_close_signal_id === undefined || evidenceSource.source_close_signal_id === null
|
|
223
|
+
? null
|
|
224
|
+
: string(evidenceSource.source_close_signal_id, "actuals.snapshot_evidence.source_close_signal_id");
|
|
225
|
+
const sourceClosedThrough = evidenceSource.source_closed_through === undefined || evidenceSource.source_closed_through === null
|
|
226
|
+
? null
|
|
227
|
+
: string(evidenceSource.source_closed_through, "actuals.snapshot_evidence.source_closed_through");
|
|
228
|
+
if (sourceClosedThrough !== null) canonicalCalendarDate(sourceClosedThrough, "actuals.snapshot_evidence.source_closed_through");
|
|
229
|
+
const sourceCloseEmittedAt = evidenceSource.source_close_emitted_at === undefined || evidenceSource.source_close_emitted_at === null
|
|
230
|
+
? null
|
|
231
|
+
: string(evidenceSource.source_close_emitted_at, "actuals.snapshot_evidence.source_close_emitted_at");
|
|
232
|
+
if (sourceCloseEmittedAt !== null && (Number.isNaN(Date.parse(sourceCloseEmittedAt)) || new Date(sourceCloseEmittedAt).toISOString() !== sourceCloseEmittedAt)) {
|
|
233
|
+
throw new Error("actuals.snapshot_evidence.source_close_emitted_at must be a canonical ISO timestamp or null.");
|
|
234
|
+
}
|
|
235
|
+
const sourceCloseSignalSha256 = evidenceSource.source_close_signal_sha256 === undefined || evidenceSource.source_close_signal_sha256 === null
|
|
236
|
+
? null
|
|
237
|
+
: string(evidenceSource.source_close_signal_sha256, "actuals.snapshot_evidence.source_close_signal_sha256");
|
|
238
|
+
if (sourceCloseSignalSha256 !== null && !/^[a-f0-9]{64}$/.test(sourceCloseSignalSha256)) throw new Error("actuals.snapshot_evidence.source_close_signal_sha256 must be a SHA-256 digest or null.");
|
|
239
|
+
const closeTuple = [sourceCloseSignalId, sourceClosedThrough, sourceCloseEmittedAt, sourceCloseSignalSha256];
|
|
240
|
+
if (closeTuple.some((item) => item !== null) && closeTuple.some((item) => item === null)) {
|
|
241
|
+
throw new Error("actuals.snapshot_evidence source close evidence must be entirely present or entirely null.");
|
|
242
|
+
}
|
|
243
|
+
if (sourceCloseSignalId && sourceClosedThrough && sourceCloseEmittedAt && sourceCloseSignalSha256) {
|
|
244
|
+
const expectedDigest = createHash("sha256").update(stableJson({
|
|
245
|
+
kind: "fpa.actuals.source-close",
|
|
246
|
+
schema_version: 1,
|
|
247
|
+
dataset: "ua_spend",
|
|
248
|
+
signal_id: sourceCloseSignalId,
|
|
249
|
+
closed_through: sourceClosedThrough,
|
|
250
|
+
emitted_at: sourceCloseEmittedAt,
|
|
251
|
+
})).digest("hex");
|
|
252
|
+
if (sourceCloseSignalSha256 !== expectedDigest) throw new Error("actuals.snapshot_evidence source close digest does not match its canonical evidence.");
|
|
253
|
+
}
|
|
254
|
+
if (evidenceSource.period_complete && (!sourceCloseSignalId || !sourceClosedThrough || !sourceCloseEmittedAt || !sourceCloseSignalSha256)) {
|
|
255
|
+
throw new Error("period_complete requires complete explicit source close evidence.");
|
|
256
|
+
}
|
|
257
|
+
if (evidenceSource.period_complete && (
|
|
258
|
+
consistency !== "single_statement"
|
|
259
|
+
|| evidenceSource.available_at === null
|
|
260
|
+
|| (sourceClosedThrough as string) < finalPeriodDate(snapshotPeriod)
|
|
261
|
+
|| Date.parse(evidenceSource.available_at as string) < Date.parse(sourceCloseEmittedAt as string)
|
|
262
|
+
)) {
|
|
263
|
+
throw new Error("period_complete requires single-statement evidence observed after a source close signal covering the target period.");
|
|
264
|
+
}
|
|
265
|
+
snapshotEvidence = {
|
|
266
|
+
snapshot_id: evidenceSource.snapshot_id as string | null,
|
|
267
|
+
available_at: evidenceSource.available_at as string | null,
|
|
268
|
+
consistency,
|
|
269
|
+
source_close_signal_id: sourceCloseSignalId,
|
|
270
|
+
source_closed_through: sourceClosedThrough,
|
|
271
|
+
source_close_emitted_at: sourceCloseEmittedAt,
|
|
272
|
+
source_close_signal_sha256: sourceCloseSignalSha256,
|
|
273
|
+
period_complete: evidenceSource.period_complete,
|
|
274
|
+
reconciled: evidenceSource.reconciled,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
161
277
|
return {
|
|
162
|
-
period:
|
|
278
|
+
period: snapshotPeriod,
|
|
163
279
|
data_as_of: string(source.data_as_of, "actuals.data_as_of"),
|
|
280
|
+
snapshot_evidence: snapshotEvidence,
|
|
164
281
|
reporting_currency: reportingCurrency,
|
|
165
282
|
scope_mode: scopeMode,
|
|
166
283
|
// Absent means the probe was never run, which is only ever the benign
|