@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,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,8 +61,32 @@ 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;
78
+ /**
79
+ * Whether ua_spend holds any paid rows in the target window at all, ignoring
80
+ * the approved slice keys.
81
+ *
82
+ * A forecast whose slice keys do not exist in the mart produces exactly the
83
+ * same empty Actuals as a cycle that simply has not started yet, and the two
84
+ * are indistinguishable from the scoped queries alone — the scoped query is
85
+ * what determines coverage, so a wrong key erases its own evidence. Probing
86
+ * once without the key predicate separates them: data in the window plus no
87
+ * matching slice means the keys are wrong, not that the period is early.
88
+ */
89
+ period_has_unscoped_actuals: boolean;
42
90
  coverage: {
43
91
  date_min: string | null;
44
92
  date_max: string | null;
@@ -100,10 +148,12 @@ function period(value: unknown, path: string): Period {
100
148
 
101
149
  export function validateActualsSnapshot(value: unknown): DashboardActualsSnapshot {
102
150
  const source = record(value, "actuals");
151
+ const snapshotPeriod = period(source.period, "actuals.period");
103
152
  const coverage = record(source.coverage, "actuals.coverage");
104
153
  const dailySource = source.daily;
105
154
  const slicesSource = source.slices;
106
155
  const receiptsSource = source.query_receipts;
156
+ const evidenceSource = source.snapshot_evidence === undefined ? null : record(source.snapshot_evidence, "actuals.snapshot_evidence");
107
157
  if (!Array.isArray(dailySource)) throw new Error("actuals.daily must be an array.");
108
158
  if (!Array.isArray(slicesSource)) throw new Error("actuals.slices must be an array.");
109
159
  if (!Array.isArray(receiptsSource)) throw new Error("actuals.query_receipts must be an array.");
@@ -143,11 +193,96 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
143
193
  if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
144
194
  const scopeMode = source.scope_mode === undefined ? "app" : string(source.scope_mode, "actuals.scope_mode");
145
195
  if (scopeMode !== "app" && scopeMode !== "portfolio") throw new Error('actuals.scope_mode must be "app" or "portfolio".');
196
+ if (source.period_has_unscoped_actuals !== undefined && typeof source.period_has_unscoped_actuals !== "boolean") {
197
+ throw new Error("actuals.period_has_unscoped_actuals must be a boolean.");
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
+ }
146
277
  return {
147
- period: period(source.period, "actuals.period"),
278
+ period: snapshotPeriod,
148
279
  data_as_of: string(source.data_as_of, "actuals.data_as_of"),
280
+ snapshot_evidence: snapshotEvidence,
149
281
  reporting_currency: reportingCurrency,
150
282
  scope_mode: scopeMode,
283
+ // Absent means the probe was never run, which is only ever the benign
284
+ // reading: nothing here may invent evidence that the keys are wrong.
285
+ period_has_unscoped_actuals: source.period_has_unscoped_actuals === true,
151
286
  coverage: {
152
287
  date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
153
288
  date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
@@ -3,17 +3,27 @@ import { lstat, readFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  import { resolveDashboardDir } from "./publisher.ts";
6
+ import {
7
+ validateProjectorProvenance,
8
+ validateProjectorRuntime,
9
+ type DashboardProjectorProvenance,
10
+ type DashboardProjectorRuntime,
11
+ } from "./provenance.ts";
6
12
  import { stableJson } from "../fpa-artifacts/store.ts";
7
13
  import { DASHBOARD_WIDGET_TYPES, validateDatasetForWidget, type DashboardWidgetType } from "./schema.ts";
14
+ import { validateActualsSnapshot } from "./source.ts";
8
15
 
9
- const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
10
- const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{12})\.([a-f0-9]{12})\.json$/;
16
+ const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,191}\.json$/;
17
+ const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{64})\.([a-f0-9]{64})\.json$/;
11
18
 
12
19
  export interface DashboardStatus {
13
20
  dashboard_exists: boolean;
14
21
  dashboard_dir: string;
15
22
  generation_id?: string;
16
23
  updated_at?: string;
24
+ projector?: DashboardProjectorProvenance;
25
+ runtime?: DashboardProjectorRuntime;
26
+ provenance_status?: "verified" | "legacy_missing" | "invalid";
17
27
  widget_count: number;
18
28
  diagnostics: Array<{ target: string; message: string }>;
19
29
  }
@@ -50,6 +60,9 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
50
60
  return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "unsupported dashboard kind/schema or widgets shape" }] };
51
61
  }
52
62
  const receiptDatasets = new Map<string, string>();
63
+ let projector: DashboardProjectorProvenance | undefined;
64
+ let runtime: DashboardProjectorRuntime | undefined;
65
+ let provenanceStatus: DashboardStatus["provenance_status"];
53
66
  const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
54
67
  ? source.buildReceipt
55
68
  : "build-receipt.json";
@@ -62,6 +75,23 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
62
75
  if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
63
76
  throw new Error("has an unsupported build receipt kind or schema");
64
77
  }
78
+ if (receiptSource.projector === undefined && receiptSource.runtime === undefined) {
79
+ provenanceStatus = "legacy_missing";
80
+ } else {
81
+ provenanceStatus = "verified";
82
+ try {
83
+ projector = validateProjectorProvenance(receiptSource.projector);
84
+ } catch (error) {
85
+ provenanceStatus = "invalid";
86
+ diagnostics.push({ target: `${receiptFilename} projector`, message: error instanceof Error ? error.message : String(error) });
87
+ }
88
+ try {
89
+ runtime = validateProjectorRuntime(receiptSource.runtime);
90
+ } catch (error) {
91
+ provenanceStatus = "invalid";
92
+ diagnostics.push({ target: `${receiptFilename} runtime`, message: error instanceof Error ? error.message : String(error) });
93
+ }
94
+ }
65
95
  if (typeof source.generationId === "string" && receiptSource.generation_id !== source.generationId) {
66
96
  diagnostics.push({ target: receiptFilename, message: "generation does not match manifest.json" });
67
97
  }
@@ -70,15 +100,33 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
70
100
  }
71
101
  const receiptNameMatch = receiptFilename.match(GENERATION_RECEIPT_RE);
72
102
  if (receiptNameMatch) {
73
- if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId.slice(0, 12)) {
74
- diagnostics.push({ target: receiptFilename, message: "filename generation prefix does not match manifest.json" });
103
+ if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId) {
104
+ diagnostics.push({ target: receiptFilename, message: "filename generation digest does not match manifest.json" });
75
105
  }
76
106
  const receiptDigest = createHash("sha256").update(stableJson(receipt)).digest("hex");
77
- if (receiptNameMatch[2] !== receiptDigest.slice(0, 12)) {
107
+ if (receiptNameMatch[2] !== receiptDigest) {
78
108
  diagnostics.push({ target: receiptFilename, message: "receipt content digest does not match its filename" });
79
109
  }
80
110
  }
81
111
  if (!Array.isArray(receiptSource.datasets)) throw new Error("datasets must be an array");
112
+ if (receiptSource.source && typeof receiptSource.source === "object" && !Array.isArray(receiptSource.source)) {
113
+ const actualsRef = (receiptSource.source as Record<string, unknown>).actuals_snapshot_ref;
114
+ if (actualsRef !== undefined) {
115
+ if (typeof actualsRef !== "string" || !/^[a-f0-9]{64}$/.test(actualsRef)) {
116
+ diagnostics.push({ target: `${receiptFilename} source.actuals_snapshot_ref`, message: "must be a sha256 digest" });
117
+ } else {
118
+ try {
119
+ const snapshot = await readJson(join(dashboardDir, "actuals", `${actualsRef}.json`), 2 * 1024 * 1024);
120
+ const validated = validateActualsSnapshot(snapshot);
121
+ if (createHash("sha256").update(stableJson(validated)).digest("hex") !== actualsRef) {
122
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: "Actuals snapshot content digest does not match its immutable ref" });
123
+ }
124
+ } catch (error) {
125
+ diagnostics.push({ target: `actuals/${actualsRef}.json`, message: error instanceof Error ? error.message : String(error) });
126
+ }
127
+ }
128
+ }
129
+ }
82
130
  for (const [index, raw] of receiptSource.datasets.entries()) {
83
131
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
84
132
  diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "must be an object" });
@@ -148,6 +196,9 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
148
196
  dashboard_dir: dashboardDir,
149
197
  ...(typeof source.generationId === "string" ? { generation_id: source.generationId } : {}),
150
198
  ...(typeof source.updatedAt === "string" ? { updated_at: source.updatedAt } : {}),
199
+ ...(projector ? { projector } : {}),
200
+ ...(runtime ? { runtime } : {}),
201
+ ...(provenanceStatus ? { provenance_status: provenanceStatus } : {}),
151
202
  widget_count: source.widgets.length,
152
203
  diagnostics,
153
204
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",
@@ -16,20 +16,30 @@
16
16
  "access": "public",
17
17
  "registry": "https://registry.npmjs.org"
18
18
  },
19
+ "engines": {
20
+ "node": ">=22.19"
21
+ },
19
22
  "files": [
20
23
  "README.md",
24
+ "bin",
21
25
  "prompts",
22
26
  "skills",
23
27
  "extensions"
24
28
  ],
29
+ "bin": {
30
+ "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
31
+ },
25
32
  "scripts": {
26
- "test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
33
+ "test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
27
34
  "test:structure": "node tests/package-structure.test.mjs",
28
- "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
29
- "test:loader": "node tests/pi-loader-smoke.mjs",
35
+ "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
36
+ "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
30
37
  "test:live": "node tests/live-smoke.mjs",
31
38
  "pack:check": "npm pack --dry-run"
32
39
  },
40
+ "dependencies": {
41
+ "jiti": "2.7.0"
42
+ },
33
43
  "peerDependencies": {
34
44
  "@earendil-works/pi-ai": "*",
35
45
  "@earendil-works/pi-coding-agent": "*",
@@ -29,7 +29,7 @@ If any requirement is missing, return `blocked` and make no external call. Never
29
29
  4. Present or record the dry-run result when the adapter supports it.
30
30
  5. Execute only the approved mutation set.
31
31
  6. Read back the resulting state and reconcile it with the requested state.
32
- 7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/execution_receipt.input.json`, then commit it with `fpa_artifact_commit` using `artifact_path`.
32
+ 7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/execution_receipt.input.json`, then commit it with `fpa_artifact_commit` using `artifact_path` and the exact `scope_id`, `cycle_id`, and approved Forecast `upstream_refs` entry returned by planning. Never validate against a mutable current pointer.
33
33
 
34
34
  ## Stop conditions
35
35
 
@@ -21,21 +21,31 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
21
21
  ## Procedure
22
22
 
23
23
  1. Verify approval and artifact lineage.
24
- 2. Lock the approved allocation and all approval conditions.
24
+ 2. Lock the approved allocation, accountable owner for each slice when known, and all approval conditions.
25
25
  3. Recalculate future-period operating outcomes from that allocation using the declared model.
26
26
  4. Provide downside, base, and upside values for each supported KPI.
27
27
  5. Reconcile allocation totals, formulas, and cross-metric identities.
28
- 6. Build the canonical `approved_cycle_forecast` input using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/approved_cycle_forecast.input.json`.
29
- 7. Commit it with `fpa_artifact_commit` using `artifact_path`.
30
-
31
- ## Why the draft file
32
-
33
- A full forecast is far too large to emit as a single inline tool argument: the
34
- call gets cut off at the model output limit, and what is left behind reads like
35
- a finished turn. Write the draft with the file tools instead — across as many
36
- turns and corrections as it takes — then commit the finished file by path. If a
37
- later step in this graph already commits the draft for you, stop after writing
38
- it and say so; do not claim a fingerprint you were not handed.
28
+ 6. Write the forecast plan to `artifacts/forecast_plan.json` using [artifact-contract.md](references/artifact-contract.md).
29
+ 7. Derive the artifact with `fpa_forecast_compose`, then freeze it with
30
+ `fpa_artifact_commit` and an assigned context containing the explicit
31
+ `scope_id`, `cycle_id`, and `forecast_role`. Use `original` only when the
32
+ forecast is frozen no later than period start; use `eac` for an in-period
33
+ reforecast and `next_plan` when it is the approved direct successor of the
34
+ active cycle.
35
+
36
+ ## Write the decision, not the arithmetic
37
+
38
+ The only judgement in a forecast is the allocation and each slice's ROAS
39
+ assumption. Revenue, the consolidated roll-up, units, windows, and `frozen_at`
40
+ all follow from those, and `fpa_forecast_compose` derives them — which is what
41
+ makes the identities the commit checks true by construction rather than
42
+ dependent on transcribing several hundred numbers without a slip.
43
+
44
+ So do not hand-compute revenue, totals, or ratios, and do not emit the full
45
+ artifact as one tool argument: that is the shape that gets cut off at the model
46
+ output limit and leaves a turn that reads finished but committed nothing. If a
47
+ later node in this graph composes and commits for you, stop after writing the
48
+ plan and say so; never claim a fingerprint you were not handed.
39
49
 
40
50
  ## Status and the publish gate
41
51
 
@@ -21,6 +21,7 @@ approved_allocation:
21
21
  baseline_spend: number | null
22
22
  approved_spend: number
23
23
  action: stop | decrease | hold | increase | explore
24
+ owner: string # optional accountable operator
24
25
  forecast_by_slice:
25
26
  - app_id: string
26
27
  store: string
@@ -45,11 +46,61 @@ frozen_at: timestamp
45
46
 
46
47
  Do not include `immutable_fingerprint` in the tool input; a draft that carries one is rejected. `fpa_artifact_commit` validates exact fields, approval state, allocation totals, every scenario's slice-to-consolidated totals, and supplied ROAS against aggregated revenue/spend. It computes the fingerprint and returns it after durable storage. The agent must not claim the artifact is frozen until that tool succeeds.
47
48
 
48
- Pass this artifact by path, not inline:
49
+ ## Author the plan, not the artifact
49
50
 
51
+ Almost none of the artifact above is a decision. The decision is the allocation
52
+ and the ROAS assumption behind each slice; revenue, the ROAS write-back, the
53
+ consolidated roll-up, every unit and window string, and `frozen_at` all follow
54
+ from those by arithmetic. Write the plan and let `fpa_forecast_compose` derive
55
+ the rest — it makes the identities the commit checks true by construction
56
+ instead of true if you typed them correctly.
57
+
58
+ ```yaml
59
+ # artifacts/forecast_plan.json
60
+ status: complete | complete_with_limits | blocked
61
+ forecast_version: string
62
+ strategy_version: string
63
+ strategy_review_id: string
64
+ human_approval_id: string
65
+ approval_conditions_satisfied: boolean
66
+ target_period: {start_inclusive: timestamp, end_exclusive: timestamp, timezone: string}
67
+ data_as_of: timestamp
68
+ source_snapshot_ids: []
69
+ assumption_version: string
70
+ model_version: string
71
+ reporting_currency: string
72
+ calibration_policy: {stop_loss_roas_lt, deviation_warning_abs_gte, deviation_trigger_abs_gt, policy_version}
73
+ unsupported_metrics: []
74
+ reconciliation_checks: []
75
+ slices:
76
+ - app_id: string # ua_spend.app_code
77
+ store: string # ua_spend.platform — ios | android, never a store name
78
+ channel_group: string # ua_spend.media_source, verbatim
79
+ baseline_spend: number | null
80
+ approved_spend: number
81
+ action: stop | decrease | hold | increase | explore
82
+ owner: string # optional; shown as the accountable operator on the CEO dashboard
83
+ roas: {downside: number|null, base: number|null, upside: number|null}
84
+ consolidated_extra_metrics: # optional; measured portfolio series only
85
+ metric_name: {downside, base, upside, unit}
50
86
  ```
51
- write artifacts/approved_cycle_forecast.input.json
52
- fpa_artifact_commit { "artifact_path": "artifacts/approved_cycle_forecast.input.json" }
87
+
88
+ Then:
89
+
90
+ ```
91
+ write artifacts/forecast_plan.json
92
+ fpa_forecast_compose { "plan_path": "artifacts/forecast_plan.json" }
93
+ fpa_artifact_commit { "artifact": <the artifact compose returned> }
53
94
  ```
54
95
 
55
- `artifact_path` is project-relative, must stay inside the project, and must be a regular file. The inline `artifact` parameter still works and is fine for small artifacts, but a forecast of any real size will be truncated at the model output limit if you try to emit it in one call.
96
+ Rules the plan has to respect, because compose enforces them:
97
+
98
+ - **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
99
+ - **A stopped slice has no ROAS.** `approved_spend: 0` means revenue 0 and `roas` null in all three scenarios — the contract derives ROAS as revenue/spend and calls a zero denominator null.
100
+ - **Slice keys must be values `ua_spend` actually uses.** A plan naming `google_play` where the mart says `android` is rejected with the valid values listed. The single-placeholder App axis for a scoped-out portfolio is the one permitted exception.
101
+ - **`status` and `unsupported_metrics` must agree.** `complete` requires an empty list; `complete_with_limits` requires a non-empty one. Scope excluded upstream is neither.
102
+ - **Do not hand-compute anything derived.** A ROAS rounded to four decimals misses the commit tolerance and costs a re-draft.
103
+
104
+ ## Committing an artifact you already hold
105
+
106
+ `fpa_artifact_commit` also takes `artifact_path` for an artifact written to disk, and `artifact` for one passed inline. Both are project-relative and fail closed. Emitting a full artifact by hand in a single tool call is what gets cut off at the model output limit — prefer the plan above.
@@ -11,6 +11,10 @@ Load `$fpa-apply-core-rules` first. Follow [dashboard-policy.md](references/dash
11
11
 
12
12
  Require a committed `approved_cycle_forecast` for the target project. Treat a committed `execution_receipt` as optional execution evidence; never describe manual reported execution as independently verified.
13
13
 
14
+ For an assigned ledger cycle, pass `scope_id`, `cycle_id`, and the exact
15
+ `forecast_ref`; pass exact execution/forward refs when available. Never provide
16
+ only part of this tuple or fall back to a mutable current pointer.
17
+
14
18
  ## Procedure
15
19
 
16
20
  1. Call `fpa_dashboard_status` to inspect the current generation and diagnostics.
@@ -20,10 +24,25 @@ Require a committed `approved_cycle_forecast` for the target project. Treat a co
20
24
  5. Publish only when the user explicitly asked to rebuild or publish the dashboard. Call the same tool with `mode: publish` and the exact preview fingerprint.
21
25
  6. Report the published generation, source forecast fingerprint, data cutoff, warnings, and dashboard path.
22
26
 
27
+ ## When preview returns `blocked_slice_key_mismatch`
28
+
29
+ Every approved slice is missing its Actuals while the target window demonstrably
30
+ holds paid rows. That is not an early cycle and not a scope limitation: the
31
+ frozen forecast is keyed on dimension values `ua_spend` never uses, so the
32
+ dashboard would publish complete-looking and entirely empty. Publish is refused.
33
+
34
+ Report it as a failure, name the offending key axis, and send the work back to
35
+ the node that froze the forecast. Do not re-preview hoping for a different
36
+ answer, and do not describe it as "the period has no data yet".
37
+
23
38
  ## Boundaries
24
39
 
25
40
  - Never write `.fpa-dashboard` with generic file or shell tools.
26
41
  - Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
27
42
  - Do not edit the approved forecast during projection.
28
43
  - If inputs change between preview and publish, preview again rather than bypassing the fingerprint check.
29
- - This skill performs one bounded refresh; it does not continuously monitor the dashboard.
44
+ - This skill performs one bounded human-requested refresh. Assigned artifact
45
+ commits and Actuals watermark changes are monitored separately by the durable
46
+ package coordinator running in the package-owned `fpa-dashboard-worker`
47
+ process (never in the read-only Web host); inspect or drain that queue with
48
+ `fpa_dashboard_refresh_queue` rather than recreating its writes manually.