@viccydev/pi-fpa 0.2.1 → 0.3.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  面向 Pi 的完整 FP&A 周期资源包,目标运行时为 `@earendil-works/pi-coding-agent` 0.84.1。
4
4
 
5
- 本包分发 Prompt Template、Skill、参考合同,以及一个只读数据 Extension(`fpa-data`)。包内不含业务数据与模型凭证,也不实现工作流状态机。多 Agent 隔离、人工审批、Artifact 持久化和真实外部执行仍由宿主运行时或独立工作流承担。
5
+ 本包分发 Prompt Template、Skill、参考合同,以及数据读取、规范化 Artifact 和看板投影三个 Extension。包内不含业务数据与模型凭证,也不实现工作流状态机。多 Agent 隔离、人工审批和真实外部执行仍由宿主运行时或独立工作流承担。
6
6
 
7
7
  ## 包含的资源
8
8
 
@@ -23,6 +23,7 @@ Skill:
23
23
  - `fpa-forecast-approved-strategy`
24
24
  - `fpa-review-cycle`
25
25
  - `fpa-execute-approved-strategy`
26
+ - `fpa-refresh-dashboard`
26
27
 
27
28
  执行 Skill 设置了 `disable-model-invocation: true`,不会出现在模型可主动调用的 Skill 摘要中,也没有对应 Prompt。只有用户显式输入 `/skill:fpa-execute-approved-strategy` 才能加载它;即使显式加载,缺少精确批准、单独执行授权、真实 adapter、幂等键或成功 preflight 时也必须保持 `blocked`,不得产生外部变更。
28
29
 
@@ -47,6 +48,19 @@ Extension 内置的关键防护:
47
48
  - Apple 指标按来源语义聚合:COUNT 求和、AVERAGE 取日均、LATEST 取期末值。
48
49
  - cohort 规模缺失(2026-08-01 之前)时 LTV/留存分母返回 NULL。
49
50
 
51
+ ## Artifact 与看板 Extension
52
+
53
+ `fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
54
+
55
+ `fpa-dashboard` 提供两个工具:
56
+
57
+ | 工具 | 作用 |
58
+ | --- | --- |
59
+ | `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
60
+ | `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
61
+
62
+ 看板按 `app_id + store + channel_group` 精确限定同口径 Actuals,比率全部在聚合后重算,缺值保持 `NULL`;未获批的付费分片只形成告警,不混入预测对比,上周期对比按当前 Actuals 已覆盖的等长日历窗口计算。发布器写内容寻址的数据集,并最后原子提升 `manifest.json`,不会让 Web 端读到半成品代际。
63
+
50
64
  ### 凭证配置
51
65
 
52
66
  Extension 通过 Supabase Management API 只读查询,凭证仅从环境变量读取,绝不写入包内:
@@ -94,12 +108,12 @@ pi list
94
108
  团队分发建议使用固定 Git tag:
95
109
 
96
110
  ```bash
97
- pi install git:github.com/linyqh/pi-fpa@v0.2.0
111
+ pi install git:github.com/linyqh/pi-fpa@v0.3.0
98
112
  ```
99
113
 
100
114
  ## 发布到 npm
101
115
 
102
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如当前 `0.2.0` 对应 `v0.2.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
116
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如当前 `0.3.0` 对应 `v0.3.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
103
117
 
104
118
  首次发布前需要完成一次仓库配置:
105
119
 
@@ -114,6 +128,7 @@ pi install git:github.com/linyqh/pi-fpa@v0.2.0
114
128
  ```text
115
129
  /fpa-plan-cycle /path/to/project 2026-Q3 "按 App、Store、Channel Group 规划;预算上限见 planning input"
116
130
  /fpa-review-cycle /path/to/project 2026-Q3 "使用已冻结 forecast 和新到达的 Actuals snapshot"
131
+ /skill:fpa-refresh-dashboard "预览并发布当前项目的预测闭环看板"
117
132
  ```
118
133
 
119
134
  规划入口不会进入真实策略执行。需要执行获批策略时,必须在单独、已授权的运行中显式调用:
@@ -0,0 +1,624 @@
1
+ export const ARTIFACT_TYPES = ["approved_cycle_forecast", "execution_receipt"] as const;
2
+ export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
3
+
4
+ export const ALLOCATION_ACTIONS = ["stop", "decrease", "hold", "increase", "explore"] as const;
5
+ export type AllocationAction = (typeof ALLOCATION_ACTIONS)[number];
6
+
7
+ export interface Period {
8
+ start_inclusive: string;
9
+ end_exclusive: string;
10
+ timezone: string;
11
+ }
12
+
13
+ export interface ScenarioMetric {
14
+ downside: number | null;
15
+ base: number | null;
16
+ upside: number | null;
17
+ unit: string;
18
+ window: string;
19
+ }
20
+
21
+ export interface ForecastAllocation {
22
+ app_id: string;
23
+ store: string;
24
+ channel_group: string;
25
+ baseline_spend: number | null;
26
+ approved_spend: number;
27
+ action: AllocationAction;
28
+ }
29
+
30
+ export interface ForecastSlice {
31
+ app_id: string;
32
+ store: string;
33
+ channel_group: string;
34
+ metrics: Record<string, ScenarioMetric>;
35
+ }
36
+
37
+ export interface CalibrationPolicy {
38
+ stop_loss_roas_lt: number;
39
+ deviation_warning_abs_gte: number;
40
+ deviation_trigger_abs_gt: number;
41
+ policy_version: string;
42
+ }
43
+
44
+ export interface ApprovedCycleForecastInput {
45
+ artifact_type: "approved_cycle_forecast";
46
+ status: "complete" | "complete_with_limits" | "blocked";
47
+ forecast_version: string;
48
+ strategy_version: string;
49
+ strategy_review_id: string;
50
+ human_approval_id: string;
51
+ approval_conditions_satisfied: boolean;
52
+ target_period: Period;
53
+ data_as_of: string;
54
+ source_snapshot_ids: string[];
55
+ assumption_version: string;
56
+ model_version: string;
57
+ reporting_currency: string;
58
+ approved_allocation: ForecastAllocation[];
59
+ forecast_by_slice: ForecastSlice[];
60
+ consolidated_forecast: Record<string, ScenarioMetric>;
61
+ calibration_policy: CalibrationPolicy;
62
+ unsupported_metrics: unknown[];
63
+ reconciliation_checks: unknown[];
64
+ frozen_at: string;
65
+ }
66
+
67
+ export interface ApprovedCycleForecast extends ApprovedCycleForecastInput {
68
+ immutable_fingerprint: string;
69
+ }
70
+
71
+ export interface ExecutionSlice {
72
+ app_id: string;
73
+ store: string;
74
+ channel_group: string;
75
+ action: string;
76
+ planned_spend: number;
77
+ applied_spend: number | null;
78
+ evidence_ids: string[];
79
+ }
80
+
81
+ export interface ExecutionReceiptInput {
82
+ artifact_type: "execution_receipt";
83
+ status: "complete" | "complete_with_limits" | "blocked";
84
+ forecast_version: string;
85
+ strategy_version: string;
86
+ human_approval_id: string;
87
+ execution_request_id: string;
88
+ execution_mode: "manual" | "adapter";
89
+ verification_status: "reported" | "verified" | "failed";
90
+ adapter: string;
91
+ target_accounts: string[];
92
+ idempotency_key: string;
93
+ target_period: Period;
94
+ reporting_currency: string;
95
+ preflight: {
96
+ result: "pass" | "fail";
97
+ observed_state_fingerprint: string;
98
+ };
99
+ dry_run: {
100
+ supported: boolean;
101
+ result: "pass" | "fail" | "not_supported";
102
+ };
103
+ requested_mutations: unknown[];
104
+ applied_mutations: unknown[];
105
+ failed_mutations: unknown[];
106
+ reconciliation: {
107
+ result: "pass" | "partial" | "fail";
108
+ resulting_state_fingerprint: string | null;
109
+ };
110
+ executed_at: string | null;
111
+ slices: ExecutionSlice[];
112
+ external_receipt_ids: string[];
113
+ blockers: unknown[];
114
+ }
115
+
116
+ export interface ExecutionReceipt extends ExecutionReceiptInput {
117
+ immutable_fingerprint: string;
118
+ }
119
+
120
+ export type CanonicalArtifactInput = ApprovedCycleForecastInput | ExecutionReceiptInput;
121
+ export type CanonicalArtifact = ApprovedCycleForecast | ExecutionReceipt;
122
+
123
+ const TOP_LEVEL_KEYS: Record<ArtifactType, readonly string[]> = {
124
+ approved_cycle_forecast: [
125
+ "artifact_type",
126
+ "status",
127
+ "forecast_version",
128
+ "strategy_version",
129
+ "strategy_review_id",
130
+ "human_approval_id",
131
+ "approval_conditions_satisfied",
132
+ "target_period",
133
+ "data_as_of",
134
+ "source_snapshot_ids",
135
+ "assumption_version",
136
+ "model_version",
137
+ "reporting_currency",
138
+ "approved_allocation",
139
+ "forecast_by_slice",
140
+ "consolidated_forecast",
141
+ "calibration_policy",
142
+ "unsupported_metrics",
143
+ "reconciliation_checks",
144
+ "frozen_at",
145
+ ],
146
+ execution_receipt: [
147
+ "artifact_type",
148
+ "status",
149
+ "forecast_version",
150
+ "strategy_version",
151
+ "human_approval_id",
152
+ "execution_request_id",
153
+ "execution_mode",
154
+ "verification_status",
155
+ "adapter",
156
+ "target_accounts",
157
+ "idempotency_key",
158
+ "target_period",
159
+ "reporting_currency",
160
+ "preflight",
161
+ "dry_run",
162
+ "requested_mutations",
163
+ "applied_mutations",
164
+ "failed_mutations",
165
+ "reconciliation",
166
+ "executed_at",
167
+ "slices",
168
+ "external_receipt_ids",
169
+ "blockers",
170
+ ],
171
+ };
172
+
173
+ function record(value: unknown, path: string): Record<string, unknown> {
174
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
175
+ throw new Error(`${path} must be an object.`);
176
+ }
177
+ return value as Record<string, unknown>;
178
+ }
179
+
180
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
181
+ const allowedSet = new Set(allowed);
182
+ for (const key of Object.keys(value)) {
183
+ if (!allowedSet.has(key)) throw new Error(`${path}.${key} is not allowed.`);
184
+ }
185
+ for (const key of allowed) {
186
+ if (!(key in value)) throw new Error(`${path}.${key} is required.`);
187
+ }
188
+ }
189
+
190
+ function string(value: unknown, path: string): string {
191
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
192
+ return value;
193
+ }
194
+
195
+ function number(value: unknown, path: string, nullable = false): number | null {
196
+ if (nullable && value === null) return null;
197
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number${nullable ? " or null" : ""}.`);
198
+ return value;
199
+ }
200
+
201
+ function nonNegative(value: unknown, path: string, nullable = false): number | null {
202
+ const parsed = number(value, path, nullable);
203
+ if (parsed !== null && parsed < 0) throw new Error(`${path} must be non-negative.`);
204
+ return parsed;
205
+ }
206
+
207
+ function boolean(value: unknown, path: string): boolean {
208
+ if (typeof value !== "boolean") throw new Error(`${path} must be a boolean.`);
209
+ return value;
210
+ }
211
+
212
+ function array(value: unknown, path: string): unknown[] {
213
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
214
+ return value;
215
+ }
216
+
217
+ function jsonValue(value: unknown, path: string): unknown {
218
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
219
+ if (typeof value === "number" && Number.isFinite(value)) return value;
220
+ if (Array.isArray(value)) return value.map((item, index) => jsonValue(item, `${path}[${index}]`));
221
+ if (typeof value === "object") {
222
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, jsonValue(item, `${path}.${key}`)]));
223
+ }
224
+ throw new Error(`${path} must contain only JSON-compatible values.`);
225
+ }
226
+
227
+ function jsonArray(value: unknown, path: string): unknown[] {
228
+ return array(value, path).map((item, index) => jsonValue(item, `${path}[${index}]`));
229
+ }
230
+
231
+ function strings(value: unknown, path: string): string[] {
232
+ return array(value, path).map((item, index) => string(item, `${path}[${index}]`));
233
+ }
234
+
235
+ function enumValue<const T extends readonly string[]>(value: unknown, values: T, path: string): T[number] {
236
+ if (typeof value !== "string" || !values.includes(value)) {
237
+ throw new Error(`${path} must be one of: ${values.join(", ")}.`);
238
+ }
239
+ return value as T[number];
240
+ }
241
+
242
+ function isoTimestamp(value: unknown, path: string): string {
243
+ const parsed = string(value, path);
244
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(parsed) || Number.isNaN(Date.parse(parsed))) {
245
+ throw new Error(`${path} must be an ISO timestamp with an explicit Z or UTC offset.`);
246
+ }
247
+ return parsed;
248
+ }
249
+
250
+ function period(value: unknown, path: string): Period {
251
+ const source = record(value, path);
252
+ exactKeys(source, ["start_inclusive", "end_exclusive", "timezone"], path);
253
+ const result = {
254
+ start_inclusive: isoTimestamp(source.start_inclusive, `${path}.start_inclusive`),
255
+ end_exclusive: isoTimestamp(source.end_exclusive, `${path}.end_exclusive`),
256
+ timezone: string(source.timezone, `${path}.timezone`),
257
+ };
258
+ if (Date.parse(result.start_inclusive) >= Date.parse(result.end_exclusive)) {
259
+ throw new Error(`${path}.end_exclusive must be after start_inclusive.`);
260
+ }
261
+ for (const [boundary, timestamp] of [["start_inclusive", result.start_inclusive], ["end_exclusive", result.end_exclusive]] as const) {
262
+ const instant = new Date(timestamp);
263
+ let parts: Intl.DateTimeFormatPart[];
264
+ try {
265
+ parts = new Intl.DateTimeFormat("en-US", {
266
+ timeZone: result.timezone,
267
+ hourCycle: "h23",
268
+ hour: "2-digit",
269
+ minute: "2-digit",
270
+ second: "2-digit",
271
+ }).formatToParts(instant);
272
+ } catch {
273
+ throw new Error(`${path}.timezone must be a supported IANA timezone.`);
274
+ }
275
+ const byType = new Map(parts.map((part) => [part.type, part.value]));
276
+ if (byType.get("hour") !== "00" || byType.get("minute") !== "00" || byType.get("second") !== "00" || instant.getUTCMilliseconds() !== 0) {
277
+ throw new Error(`${path}.${boundary} must be local midnight in ${result.timezone}.`);
278
+ }
279
+ }
280
+ return result;
281
+ }
282
+
283
+ export function canonicalForecastWindow(value: Period): string {
284
+ return `${value.start_inclusive}..${value.end_exclusive}@${value.timezone}`;
285
+ }
286
+
287
+ function currency(value: unknown, path: string): string {
288
+ const parsed = string(value, path).toUpperCase();
289
+ if (!/^[A-Z]{3}$/.test(parsed)) throw new Error(`${path} must be an ISO-4217 currency code.`);
290
+ return parsed;
291
+ }
292
+
293
+ function scenarioMetric(value: unknown, path: string): ScenarioMetric {
294
+ const source = record(value, path);
295
+ exactKeys(source, ["downside", "base", "upside", "unit", "window"], path);
296
+ return {
297
+ downside: number(source.downside, `${path}.downside`, true),
298
+ base: number(source.base, `${path}.base`, true),
299
+ upside: number(source.upside, `${path}.upside`, true),
300
+ unit: string(source.unit, `${path}.unit`),
301
+ window: string(source.window, `${path}.window`),
302
+ };
303
+ }
304
+
305
+ function scenarioMetrics(value: unknown, path: string): Record<string, ScenarioMetric> {
306
+ const source = record(value, path);
307
+ const entries = Object.entries(source);
308
+ if (entries.length === 0) throw new Error(`${path} must contain at least one metric.`);
309
+ return Object.fromEntries(entries.map(([name, metric]) => [string(name, `${path} key`), scenarioMetric(metric, `${path}.${name}`)]));
310
+ }
311
+
312
+ export function sliceKey(value: { app_id: string; store: string; channel_group: string }): string {
313
+ return `${value.app_id}\u0000${value.store}\u0000${value.channel_group}`;
314
+ }
315
+
316
+ const SCENARIOS = ["downside", "base", "upside"] as const;
317
+
318
+ function reconcileNumber(actual: number | null, expected: number | null, path: string): void {
319
+ if (actual === null && expected === null) return;
320
+ if (actual === null || expected === null) throw new Error(`${path} does not reconcile: expected ${expected}, received ${actual}.`);
321
+ const tolerance = Math.max(0.000001, Math.abs(expected) * 0.000001);
322
+ if (Math.abs(actual - expected) > tolerance) {
323
+ throw new Error(`${path} does not reconcile: expected ${expected}, received ${actual}.`);
324
+ }
325
+ }
326
+
327
+ function derivedRatio(numerator: number | null, denominator: number | null): number | null {
328
+ if (numerator === null || denominator === null || denominator === 0) return null;
329
+ return numerator / denominator;
330
+ }
331
+
332
+ function reconcileRoas(metrics: Record<string, ScenarioMetric>, path: string): void {
333
+ const roas = metrics.roas;
334
+ if (!roas) return;
335
+ const revenue = metrics.revenue;
336
+ const spend = metrics.spend;
337
+ if (!revenue || !spend) throw new Error(`${path}.roas requires revenue and spend metrics.`);
338
+ for (const scenario of SCENARIOS) {
339
+ reconcileNumber(roas[scenario], derivedRatio(revenue[scenario], spend[scenario]), `${path}.roas.${scenario}`);
340
+ }
341
+ }
342
+
343
+ function validateFinancialMetricSemantics(
344
+ metrics: Record<string, ScenarioMetric>,
345
+ reportingCurrency: string,
346
+ expectedWindow: string,
347
+ path: string,
348
+ ): void {
349
+ for (const metricName of ["spend", "revenue"] as const) {
350
+ const metric = metrics[metricName];
351
+ if (!metric) throw new Error(`${path}.${metricName} is required.`);
352
+ if (metric.unit !== reportingCurrency) throw new Error(`${path}.${metricName}.unit must equal reporting_currency ${reportingCurrency}.`);
353
+ if (metric.window !== expectedWindow) throw new Error(`${path}.${metricName}.window must equal ${expectedWindow}.`);
354
+ }
355
+ if (metrics.roas) {
356
+ if (metrics.roas.unit !== "ratio") throw new Error(`${path}.roas.unit must be ratio.`);
357
+ if (metrics.roas.window !== expectedWindow) throw new Error(`${path}.roas.window must equal ${expectedWindow}.`);
358
+ }
359
+ }
360
+
361
+ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycleForecastInput {
362
+ exactKeys(source, TOP_LEVEL_KEYS.approved_cycle_forecast, "artifact");
363
+ const targetPeriod = period(source.target_period, "artifact.target_period");
364
+ const allocations = array(source.approved_allocation, "artifact.approved_allocation").map((value, index) => {
365
+ const path = `artifact.approved_allocation[${index}]`;
366
+ const item = record(value, path);
367
+ exactKeys(item, ["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action"], path);
368
+ return {
369
+ app_id: string(item.app_id, `${path}.app_id`),
370
+ store: string(item.store, `${path}.store`),
371
+ channel_group: string(item.channel_group, `${path}.channel_group`),
372
+ baseline_spend: nonNegative(item.baseline_spend, `${path}.baseline_spend`, true),
373
+ approved_spend: nonNegative(item.approved_spend, `${path}.approved_spend`) as number,
374
+ action: enumValue(item.action, ALLOCATION_ACTIONS, `${path}.action`),
375
+ };
376
+ });
377
+ if (allocations.length === 0) throw new Error("artifact.approved_allocation must not be empty.");
378
+ if (allocations.length > 500) throw new Error("artifact.approved_allocation cannot exceed 500 slices.");
379
+
380
+ const forecastSlices = array(source.forecast_by_slice, "artifact.forecast_by_slice").map((value, index) => {
381
+ const path = `artifact.forecast_by_slice[${index}]`;
382
+ const item = record(value, path);
383
+ exactKeys(item, ["app_id", "store", "channel_group", "metrics"], path);
384
+ return {
385
+ app_id: string(item.app_id, `${path}.app_id`),
386
+ store: string(item.store, `${path}.store`),
387
+ channel_group: string(item.channel_group, `${path}.channel_group`),
388
+ metrics: scenarioMetrics(item.metrics, `${path}.metrics`),
389
+ };
390
+ });
391
+
392
+ const allocationKeys = new Set<string>();
393
+ for (const allocation of allocations) {
394
+ const key = sliceKey(allocation);
395
+ if (allocationKeys.has(key)) throw new Error(`artifact.approved_allocation contains duplicate slice ${key.replaceAll("\u0000", " / ")}.`);
396
+ allocationKeys.add(key);
397
+ }
398
+ const forecastKeys = new Set<string>();
399
+ for (const slice of forecastSlices) {
400
+ const key = sliceKey(slice);
401
+ if (forecastKeys.has(key)) throw new Error(`artifact.forecast_by_slice contains duplicate slice ${key.replaceAll("\u0000", " / ")}.`);
402
+ forecastKeys.add(key);
403
+ if (!allocationKeys.has(key)) throw new Error("Every forecast_by_slice row must map to approved_allocation.");
404
+ }
405
+ if (forecastKeys.size !== allocationKeys.size) throw new Error("Every approved_allocation row must have one forecast_by_slice row.");
406
+
407
+ const consolidated = scenarioMetrics(source.consolidated_forecast, "artifact.consolidated_forecast");
408
+ const reportingCurrency = currency(source.reporting_currency, "artifact.reporting_currency");
409
+ if (!consolidated.spend || !consolidated.revenue) throw new Error("artifact.consolidated_forecast requires spend and revenue metrics.");
410
+ const expectedWindow = canonicalForecastWindow(targetPeriod);
411
+ validateFinancialMetricSemantics(consolidated, reportingCurrency, expectedWindow, "artifact.consolidated_forecast");
412
+ for (const slice of forecastSlices) {
413
+ const path = `artifact.forecast_by_slice[${sliceKey(slice).replaceAll("\u0000", " / ")}]`;
414
+ validateFinancialMetricSemantics(slice.metrics, reportingCurrency, expectedWindow, `${path}.metrics`);
415
+ reconcileRoas(slice.metrics, `${path}.metrics`);
416
+ }
417
+ for (const metricName of ["spend", "revenue"] as const) {
418
+ const consolidatedMetric = consolidated[metricName];
419
+ if (!consolidatedMetric) throw new Error(`artifact.consolidated_forecast.${metricName} is required.`);
420
+ for (const scenario of SCENARIOS) {
421
+ const values = forecastSlices.map((slice) => slice.metrics[metricName]?.[scenario] ?? null);
422
+ const expected = values.every((value) => value !== null)
423
+ ? values.reduce<number>((total, value) => total + (value as number), 0)
424
+ : null;
425
+ reconcileNumber(consolidatedMetric[scenario], expected, `artifact.consolidated_forecast.${metricName}.${scenario}`);
426
+ }
427
+ }
428
+ reconcileRoas(consolidated, "artifact.consolidated_forecast");
429
+ for (const allocation of allocations) {
430
+ const forecastSlice = forecastSlices.find((slice) => sliceKey(slice) === sliceKey(allocation)) as ForecastSlice;
431
+ reconcileNumber(forecastSlice.metrics.spend.base, allocation.approved_spend, `artifact.forecast_by_slice[${sliceKey(allocation).replaceAll("\u0000", " / ")}].metrics.spend.base`);
432
+ }
433
+ const allocationTotal = allocations.reduce((total, item) => total + item.approved_spend, 0);
434
+ const consolidatedSpend = consolidated.spend?.base;
435
+ if (consolidatedSpend === undefined || consolidatedSpend === null) {
436
+ throw new Error("artifact.consolidated_forecast.spend.base is required.");
437
+ }
438
+ if (Math.abs(allocationTotal - consolidatedSpend) > 0.01) {
439
+ throw new Error(`Approved allocation ${allocationTotal} does not reconcile to consolidated spend ${consolidatedSpend}.`);
440
+ }
441
+
442
+ const policySource = record(source.calibration_policy, "artifact.calibration_policy");
443
+ exactKeys(policySource, ["stop_loss_roas_lt", "deviation_warning_abs_gte", "deviation_trigger_abs_gt", "policy_version"], "artifact.calibration_policy");
444
+ const policy = {
445
+ stop_loss_roas_lt: nonNegative(policySource.stop_loss_roas_lt, "artifact.calibration_policy.stop_loss_roas_lt") as number,
446
+ deviation_warning_abs_gte: nonNegative(policySource.deviation_warning_abs_gte, "artifact.calibration_policy.deviation_warning_abs_gte") as number,
447
+ deviation_trigger_abs_gt: nonNegative(policySource.deviation_trigger_abs_gt, "artifact.calibration_policy.deviation_trigger_abs_gt") as number,
448
+ policy_version: string(policySource.policy_version, "artifact.calibration_policy.policy_version"),
449
+ };
450
+ if (policy.deviation_warning_abs_gte > policy.deviation_trigger_abs_gt) {
451
+ throw new Error("Calibration warning threshold cannot exceed the trigger threshold.");
452
+ }
453
+
454
+ const status = enumValue(source.status, ["complete", "complete_with_limits", "blocked"] as const, "artifact.status");
455
+ const approvalConditionsSatisfied = boolean(source.approval_conditions_satisfied, "artifact.approval_conditions_satisfied");
456
+ if (status !== "blocked" && !approvalConditionsSatisfied) {
457
+ throw new Error("A non-blocked approved forecast requires all approval conditions to be satisfied.");
458
+ }
459
+ return {
460
+ artifact_type: "approved_cycle_forecast",
461
+ status,
462
+ forecast_version: string(source.forecast_version, "artifact.forecast_version"),
463
+ strategy_version: string(source.strategy_version, "artifact.strategy_version"),
464
+ strategy_review_id: string(source.strategy_review_id, "artifact.strategy_review_id"),
465
+ human_approval_id: string(source.human_approval_id, "artifact.human_approval_id"),
466
+ approval_conditions_satisfied: approvalConditionsSatisfied,
467
+ target_period: targetPeriod,
468
+ data_as_of: isoTimestamp(source.data_as_of, "artifact.data_as_of"),
469
+ source_snapshot_ids: strings(source.source_snapshot_ids, "artifact.source_snapshot_ids"),
470
+ assumption_version: string(source.assumption_version, "artifact.assumption_version"),
471
+ model_version: string(source.model_version, "artifact.model_version"),
472
+ reporting_currency: reportingCurrency,
473
+ approved_allocation: allocations,
474
+ forecast_by_slice: forecastSlices,
475
+ consolidated_forecast: consolidated,
476
+ calibration_policy: policy,
477
+ unsupported_metrics: jsonArray(source.unsupported_metrics, "artifact.unsupported_metrics"),
478
+ reconciliation_checks: jsonArray(source.reconciliation_checks, "artifact.reconciliation_checks"),
479
+ frozen_at: isoTimestamp(source.frozen_at, "artifact.frozen_at"),
480
+ };
481
+ }
482
+
483
+ function validateExecutionReceipt(source: Record<string, unknown>): ExecutionReceiptInput {
484
+ exactKeys(source, TOP_LEVEL_KEYS.execution_receipt, "artifact");
485
+ const slices = array(source.slices, "artifact.slices").map((value, index) => {
486
+ const path = `artifact.slices[${index}]`;
487
+ const item = record(value, path);
488
+ exactKeys(item, ["app_id", "store", "channel_group", "action", "planned_spend", "applied_spend", "evidence_ids"], path);
489
+ return {
490
+ app_id: string(item.app_id, `${path}.app_id`),
491
+ store: string(item.store, `${path}.store`),
492
+ channel_group: string(item.channel_group, `${path}.channel_group`),
493
+ action: string(item.action, `${path}.action`),
494
+ planned_spend: nonNegative(item.planned_spend, `${path}.planned_spend`) as number,
495
+ applied_spend: nonNegative(item.applied_spend, `${path}.applied_spend`, true),
496
+ evidence_ids: strings(item.evidence_ids, `${path}.evidence_ids`),
497
+ };
498
+ });
499
+ const sliceKeys = new Set<string>();
500
+ for (const slice of slices) {
501
+ const key = sliceKey(slice);
502
+ if (sliceKeys.has(key)) throw new Error(`artifact.slices contains duplicate slice ${key.replaceAll("\u0000", " / ")}.`);
503
+ sliceKeys.add(key);
504
+ }
505
+
506
+ const preflightSource = record(source.preflight, "artifact.preflight");
507
+ exactKeys(preflightSource, ["result", "observed_state_fingerprint"], "artifact.preflight");
508
+ const preflight = {
509
+ result: enumValue(preflightSource.result, ["pass", "fail"] as const, "artifact.preflight.result"),
510
+ observed_state_fingerprint: string(preflightSource.observed_state_fingerprint, "artifact.preflight.observed_state_fingerprint"),
511
+ };
512
+ const dryRunSource = record(source.dry_run, "artifact.dry_run");
513
+ exactKeys(dryRunSource, ["supported", "result"], "artifact.dry_run");
514
+ const dryRun = {
515
+ supported: boolean(dryRunSource.supported, "artifact.dry_run.supported"),
516
+ result: enumValue(dryRunSource.result, ["pass", "fail", "not_supported"] as const, "artifact.dry_run.result"),
517
+ };
518
+ if (dryRun.supported && dryRun.result === "not_supported") {
519
+ throw new Error("artifact.dry_run.result cannot be not_supported when dry_run.supported is true.");
520
+ }
521
+ if (!dryRun.supported && dryRun.result !== "not_supported") {
522
+ throw new Error("artifact.dry_run.result must be not_supported when dry_run.supported is false.");
523
+ }
524
+
525
+ const reconciliationSource = record(source.reconciliation, "artifact.reconciliation");
526
+ exactKeys(reconciliationSource, ["result", "resulting_state_fingerprint"], "artifact.reconciliation");
527
+ const reconciliation = {
528
+ result: enumValue(reconciliationSource.result, ["pass", "partial", "fail"] as const, "artifact.reconciliation.result"),
529
+ resulting_state_fingerprint: reconciliationSource.resulting_state_fingerprint === null
530
+ ? null
531
+ : string(reconciliationSource.resulting_state_fingerprint, "artifact.reconciliation.resulting_state_fingerprint"),
532
+ };
533
+ const requestedMutations = jsonArray(source.requested_mutations, "artifact.requested_mutations");
534
+ const appliedMutations = jsonArray(source.applied_mutations, "artifact.applied_mutations");
535
+ const failedMutations = jsonArray(source.failed_mutations, "artifact.failed_mutations");
536
+ const status = enumValue(source.status, ["complete", "complete_with_limits", "blocked"] as const, "artifact.status");
537
+ const verificationStatus = enumValue(source.verification_status, ["reported", "verified", "failed"] as const, "artifact.verification_status");
538
+ const externalReceiptIds = strings(source.external_receipt_ids, "artifact.external_receipt_ids");
539
+ const executedAt = source.executed_at === null ? null : isoTimestamp(source.executed_at, "artifact.executed_at");
540
+ if (status === "blocked" && (appliedMutations.length > 0 || slices.some((slice) => slice.applied_spend !== null))) {
541
+ throw new Error("A blocked execution receipt cannot contain applied mutations or applied spend.");
542
+ }
543
+ if (status === "blocked" && executedAt !== null) {
544
+ throw new Error("A blocked execution receipt cannot have executed_at.");
545
+ }
546
+ if (verificationStatus === "verified") {
547
+ if (executedAt === null) throw new Error("Verified execution requires executed_at evidence.");
548
+ if (reconciliation.result !== "pass" || reconciliation.resulting_state_fingerprint === null) {
549
+ throw new Error("Verified execution requires a passing reconciliation and resulting-state fingerprint evidence.");
550
+ }
551
+ if (slices.length === 0 || slices.some((slice) => slice.applied_spend === null || slice.evidence_ids.length === 0)) {
552
+ throw new Error("Verified execution requires applied spend and evidence for every receipt slice.");
553
+ }
554
+ }
555
+ if (status === "complete" && verificationStatus !== "verified") {
556
+ throw new Error("A complete execution receipt must be verified; reported or failed execution is complete_with_limits or blocked.");
557
+ }
558
+ return {
559
+ artifact_type: "execution_receipt",
560
+ status,
561
+ forecast_version: string(source.forecast_version, "artifact.forecast_version"),
562
+ strategy_version: string(source.strategy_version, "artifact.strategy_version"),
563
+ human_approval_id: string(source.human_approval_id, "artifact.human_approval_id"),
564
+ execution_request_id: string(source.execution_request_id, "artifact.execution_request_id"),
565
+ execution_mode: enumValue(source.execution_mode, ["manual", "adapter"] as const, "artifact.execution_mode"),
566
+ verification_status: verificationStatus,
567
+ adapter: string(source.adapter, "artifact.adapter"),
568
+ target_accounts: strings(source.target_accounts, "artifact.target_accounts"),
569
+ idempotency_key: string(source.idempotency_key, "artifact.idempotency_key"),
570
+ target_period: period(source.target_period, "artifact.target_period"),
571
+ reporting_currency: currency(source.reporting_currency, "artifact.reporting_currency"),
572
+ preflight,
573
+ dry_run: dryRun,
574
+ requested_mutations: requestedMutations,
575
+ applied_mutations: appliedMutations,
576
+ failed_mutations: failedMutations,
577
+ reconciliation,
578
+ executed_at: executedAt,
579
+ slices,
580
+ external_receipt_ids: externalReceiptIds,
581
+ blockers: jsonArray(source.blockers, "artifact.blockers"),
582
+ };
583
+ }
584
+
585
+ export function validateArtifact(value: unknown): CanonicalArtifactInput {
586
+ const source = record(value, "artifact");
587
+ const artifactType = enumValue(source.artifact_type, ARTIFACT_TYPES, "artifact.artifact_type");
588
+ return artifactType === "approved_cycle_forecast"
589
+ ? validateApprovedForecast(source)
590
+ : validateExecutionReceipt(source);
591
+ }
592
+
593
+ export function isApprovedCycleForecast(value: CanonicalArtifact): value is ApprovedCycleForecast {
594
+ return value.artifact_type === "approved_cycle_forecast";
595
+ }
596
+
597
+ export function isExecutionReceiptForForecast(
598
+ execution: Pick<ExecutionReceiptInput, "forecast_version">,
599
+ forecast: Pick<ApprovedCycleForecastInput, "forecast_version">,
600
+ ): boolean {
601
+ return execution.forecast_version === forecast.forecast_version;
602
+ }
603
+
604
+ export function validateExecutionAgainstForecast(
605
+ execution: ExecutionReceiptInput,
606
+ forecast: ApprovedCycleForecastInput,
607
+ ): void {
608
+ if (execution.forecast_version !== forecast.forecast_version) throw new Error("Execution receipt forecast_version does not match the approved forecast.");
609
+ if (execution.strategy_version !== forecast.strategy_version) throw new Error("Execution receipt strategy_version does not match the approved forecast.");
610
+ if (execution.human_approval_id !== forecast.human_approval_id) throw new Error("Execution receipt human_approval_id does not match the approved forecast.");
611
+ const allocationByKey = new Map(forecast.approved_allocation.map((allocation) => [sliceKey(allocation), allocation]));
612
+ const executionKeys = new Set<string>();
613
+ for (const slice of execution.slices) {
614
+ const key = sliceKey(slice);
615
+ executionKeys.add(key);
616
+ const allocation = allocationByKey.get(key);
617
+ if (!allocation) throw new Error(`Execution receipt contains an unapproved slice ${key.replaceAll("\u0000", " / ")}.`);
618
+ if (slice.action !== allocation.action) throw new Error(`Execution receipt action does not match the approved allocation for ${key.replaceAll("\u0000", " / ")}.`);
619
+ reconcileNumber(slice.planned_spend, allocation.approved_spend, `execution_receipt.slices[${key.replaceAll("\u0000", " / ")}].planned_spend`);
620
+ }
621
+ if (execution.status === "complete" && executionKeys.size !== allocationByKey.size) {
622
+ throw new Error("A complete execution receipt must contain every approved allocation slice.");
623
+ }
624
+ }