@dogpile/sdk 0.3.1 → 0.5.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +1 -0
  3. package/dist/browser/index.js +2328 -237
  4. package/dist/browser/index.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/providers/openai-compatible.d.ts +11 -0
  10. package/dist/providers/openai-compatible.d.ts.map +1 -1
  11. package/dist/providers/openai-compatible.js +88 -2
  12. package/dist/providers/openai-compatible.js.map +1 -1
  13. package/dist/runtime/audit.d.ts +42 -0
  14. package/dist/runtime/audit.d.ts.map +1 -0
  15. package/dist/runtime/audit.js +73 -0
  16. package/dist/runtime/audit.js.map +1 -0
  17. package/dist/runtime/broadcast.d.ts.map +1 -1
  18. package/dist/runtime/broadcast.js +39 -36
  19. package/dist/runtime/broadcast.js.map +1 -1
  20. package/dist/runtime/cancellation.d.ts +26 -0
  21. package/dist/runtime/cancellation.d.ts.map +1 -1
  22. package/dist/runtime/cancellation.js +38 -1
  23. package/dist/runtime/cancellation.js.map +1 -1
  24. package/dist/runtime/coordinator.d.ts +79 -1
  25. package/dist/runtime/coordinator.d.ts.map +1 -1
  26. package/dist/runtime/coordinator.js +979 -61
  27. package/dist/runtime/coordinator.js.map +1 -1
  28. package/dist/runtime/decisions.d.ts +25 -3
  29. package/dist/runtime/decisions.d.ts.map +1 -1
  30. package/dist/runtime/decisions.js +241 -3
  31. package/dist/runtime/decisions.js.map +1 -1
  32. package/dist/runtime/defaults.d.ts +37 -1
  33. package/dist/runtime/defaults.d.ts.map +1 -1
  34. package/dist/runtime/defaults.js +359 -4
  35. package/dist/runtime/defaults.js.map +1 -1
  36. package/dist/runtime/engine.d.ts +17 -4
  37. package/dist/runtime/engine.d.ts.map +1 -1
  38. package/dist/runtime/engine.js +770 -35
  39. package/dist/runtime/engine.js.map +1 -1
  40. package/dist/runtime/health.d.ts +51 -0
  41. package/dist/runtime/health.d.ts.map +1 -0
  42. package/dist/runtime/health.js +85 -0
  43. package/dist/runtime/health.js.map +1 -0
  44. package/dist/runtime/introspection.d.ts +96 -0
  45. package/dist/runtime/introspection.d.ts.map +1 -0
  46. package/dist/runtime/introspection.js +31 -0
  47. package/dist/runtime/introspection.js.map +1 -0
  48. package/dist/runtime/metrics.d.ts +44 -0
  49. package/dist/runtime/metrics.d.ts.map +1 -0
  50. package/dist/runtime/metrics.js +12 -0
  51. package/dist/runtime/metrics.js.map +1 -0
  52. package/dist/runtime/model.d.ts.map +1 -1
  53. package/dist/runtime/model.js +34 -7
  54. package/dist/runtime/model.js.map +1 -1
  55. package/dist/runtime/provenance.d.ts +25 -0
  56. package/dist/runtime/provenance.d.ts.map +1 -0
  57. package/dist/runtime/provenance.js +13 -0
  58. package/dist/runtime/provenance.js.map +1 -0
  59. package/dist/runtime/sequential.d.ts.map +1 -1
  60. package/dist/runtime/sequential.js +47 -37
  61. package/dist/runtime/sequential.js.map +1 -1
  62. package/dist/runtime/shared.d.ts.map +1 -1
  63. package/dist/runtime/shared.js +39 -36
  64. package/dist/runtime/shared.js.map +1 -1
  65. package/dist/runtime/tracing.d.ts +31 -0
  66. package/dist/runtime/tracing.d.ts.map +1 -0
  67. package/dist/runtime/tracing.js +18 -0
  68. package/dist/runtime/tracing.js.map +1 -0
  69. package/dist/runtime/validation.d.ts +10 -0
  70. package/dist/runtime/validation.d.ts.map +1 -1
  71. package/dist/runtime/validation.js +73 -0
  72. package/dist/runtime/validation.js.map +1 -1
  73. package/dist/types/events.d.ts +339 -12
  74. package/dist/types/events.d.ts.map +1 -1
  75. package/dist/types/replay.d.ts +7 -1
  76. package/dist/types/replay.d.ts.map +1 -1
  77. package/dist/types.d.ts +255 -6
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js.map +1 -1
  80. package/package.json +39 -1
  81. package/src/index.ts +15 -0
  82. package/src/providers/openai-compatible.ts +83 -3
  83. package/src/runtime/audit.ts +121 -0
  84. package/src/runtime/broadcast.ts +40 -37
  85. package/src/runtime/cancellation.ts +59 -1
  86. package/src/runtime/coordinator.ts +1221 -61
  87. package/src/runtime/decisions.ts +307 -4
  88. package/src/runtime/defaults.ts +389 -4
  89. package/src/runtime/engine.ts +1004 -35
  90. package/src/runtime/health.ts +136 -0
  91. package/src/runtime/introspection.ts +122 -0
  92. package/src/runtime/metrics.ts +45 -0
  93. package/src/runtime/model.ts +38 -6
  94. package/src/runtime/provenance.ts +43 -0
  95. package/src/runtime/sequential.ts +49 -38
  96. package/src/runtime/shared.ts +40 -37
  97. package/src/runtime/tracing.ts +35 -0
  98. package/src/runtime/validation.ts +81 -0
  99. package/src/types/events.ts +369 -12
  100. package/src/types/replay.ts +14 -1
  101. package/src/types.ts +279 -4
@@ -1,3 +1,4 @@
1
+ import { DogpileError } from "../types.js";
1
2
  import type {
2
3
  AgentSpec,
3
4
  Budget,
@@ -18,7 +19,9 @@ import type {
18
19
  RunEventLog,
19
20
  RunMetadata,
20
21
  RunUsage,
22
+ OnChildFailureMode,
21
23
  Tier,
24
+ Trace,
22
25
  TranscriptEntry,
23
26
  TranscriptLink
24
27
  } from "../types.js";
@@ -129,6 +132,38 @@ export function addCost(left: CostSummary, right: CostSummary): CostSummary {
129
132
  };
130
133
  }
131
134
 
135
+ export function resolveOnChildFailure(
136
+ runOption: OnChildFailureMode | undefined,
137
+ engineOption: OnChildFailureMode | undefined
138
+ ): OnChildFailureMode {
139
+ // onChildFailure precedence: per-run option > engine option > default.
140
+ return runOption ?? engineOption ?? "continue";
141
+ }
142
+
143
+ /**
144
+ * Walk a parent's events and accumulate the cost contributed by every
145
+ * sub-run (BUDGET-03 / D-06). Internal helper — not part of the public surface.
146
+ *
147
+ * - `sub-run-completed` events contribute `event.subResult.cost`.
148
+ * - `sub-run-failed` events contribute `event.partialCost` (real provider
149
+ * spend captured before the throw).
150
+ *
151
+ * Used by the `parent-rollup-drift` parity check in
152
+ * {@link recomputeAccountingFromTrace} to verify the parent's recorded
153
+ * accounting equals `localOnly + Σ children` recursively.
154
+ */
155
+ export function accumulateSubRunCost(events: readonly RunEvent[]): CostSummary {
156
+ let total = emptyCost();
157
+ for (const event of events) {
158
+ if (event.type === "sub-run-completed") {
159
+ total = addCost(total, event.subResult.cost);
160
+ } else if (event.type === "sub-run-failed") {
161
+ total = addCost(total, event.partialCost);
162
+ }
163
+ }
164
+ return total;
165
+ }
166
+
132
167
  export function createTranscriptLink(transcript: readonly TranscriptEntry[]): TranscriptLink {
133
168
  return {
134
169
  kind: "trace-transcript",
@@ -201,8 +236,8 @@ export function createRunMetadata(options: {
201
236
  tier: options.tier,
202
237
  modelProviderId: options.modelProviderId,
203
238
  agentsUsed: options.agentsUsed,
204
- startedAt: firstEvent?.at ?? "",
205
- completedAt: lastEvent?.at ?? ""
239
+ startedAt: eventTimestamp(firstEvent) ?? "",
240
+ completedAt: eventTimestamp(lastEvent) ?? ""
206
241
  };
207
242
  }
208
243
 
@@ -274,6 +309,13 @@ export function createReplayTraceBudgetStateChanges(
274
309
  case "model-output-chunk":
275
310
  case "tool-call":
276
311
  case "tool-result":
312
+ case "sub-run-started":
313
+ case "sub-run-completed":
314
+ case "sub-run-failed":
315
+ case "sub-run-parent-aborted":
316
+ case "sub-run-budget-clamped":
317
+ case "sub-run-queued":
318
+ case "sub-run-concurrency-clamped":
277
319
  return [];
278
320
  }
279
321
  });
@@ -323,7 +365,7 @@ export function createReplayTraceProtocolDecision(
323
365
  eventType: event.type,
324
366
  protocol,
325
367
  decision: options.decision ?? defaultProtocolDecision(event),
326
- at: event.at,
368
+ at: eventTimestamp(event),
327
369
  ...(options.turn !== undefined ? { turn: options.turn } : {}),
328
370
  ...(options.phase !== undefined ? { phase: options.phase } : {}),
329
371
  ...(options.round !== undefined ? { round: options.round } : {}),
@@ -408,6 +450,39 @@ export function createReplayTraceProtocolDecision(
408
450
  output: event.output,
409
451
  cost: event.cost
410
452
  };
453
+ case "sub-run-started":
454
+ return {
455
+ ...base,
456
+ input: event.intent
457
+ };
458
+ case "sub-run-completed":
459
+ return {
460
+ ...base,
461
+ output: event.subResult.output,
462
+ cost: event.subResult.cost
463
+ };
464
+ case "sub-run-failed":
465
+ return {
466
+ ...base
467
+ };
468
+ case "sub-run-parent-aborted":
469
+ return {
470
+ ...base
471
+ };
472
+ case "sub-run-budget-clamped":
473
+ return {
474
+ ...base
475
+ };
476
+ case "sub-run-queued":
477
+ return {
478
+ ...base,
479
+ childRunId: event.childRunId,
480
+ queuePosition: event.queuePosition
481
+ };
482
+ case "sub-run-concurrency-clamped":
483
+ return {
484
+ ...base
485
+ };
411
486
  }
412
487
  }
413
488
 
@@ -433,6 +508,20 @@ function defaultProtocolDecision(event: RunEvent): ReplayTraceProtocolDecisionTy
433
508
  return "stop-for-budget";
434
509
  case "final":
435
510
  return "finalize-output";
511
+ case "sub-run-started":
512
+ return "start-sub-run";
513
+ case "sub-run-completed":
514
+ return "complete-sub-run";
515
+ case "sub-run-failed":
516
+ return "fail-sub-run";
517
+ case "sub-run-parent-aborted":
518
+ return "mark-sub-run-parent-aborted";
519
+ case "sub-run-budget-clamped":
520
+ return "mark-sub-run-budget-clamped";
521
+ case "sub-run-queued":
522
+ return "queue-sub-run";
523
+ case "sub-run-concurrency-clamped":
524
+ return "mark-sub-run-concurrency-clamped";
436
525
  }
437
526
  }
438
527
 
@@ -461,7 +550,7 @@ export function createReplayTraceFinalOutput(output: string, event: RunEvent): R
461
550
  kind: "replay-trace-final-output",
462
551
  output,
463
552
  cost: emptyCost(),
464
- completedAt: event.at,
553
+ completedAt: eventTimestamp(event),
465
554
  transcript: {
466
555
  kind: "trace-transcript",
467
556
  entryCount: 0,
@@ -470,6 +559,14 @@ export function createReplayTraceFinalOutput(output: string, event: RunEvent): R
470
559
  };
471
560
  }
472
561
 
562
+ function eventTimestamp(event: RunEvent): string;
563
+ function eventTimestamp(event: RunEvent | undefined): string | undefined;
564
+ function eventTimestamp(event: RunEvent | undefined): string | undefined {
565
+ if (event === undefined) return undefined;
566
+ if ("at" in event) return event.at;
567
+ return event.type === "model-response" ? event.completedAt : event.startedAt;
568
+ }
569
+
473
570
  export function nextProviderCallId(
474
571
  runId: string,
475
572
  providerCalls: readonly ReplayTraceProviderCall[]
@@ -500,6 +597,7 @@ export function canonicalizeRunResult(result: RunResult): RunResult {
500
597
  cost: canonicalizeSerializable(result.cost),
501
598
  ...(result.evaluation !== undefined ? { evaluation: canonicalizeSerializable(result.evaluation) } : {}),
502
599
  eventLog,
600
+ health: canonicalizeSerializable(result.health),
503
601
  metadata: canonicalizeSerializable(result.metadata),
504
602
  output: result.output,
505
603
  ...(result.quality !== undefined ? { quality: canonicalizeSerializable(result.quality) } : {}),
@@ -515,6 +613,293 @@ export function stableJsonStringify(value: unknown): string {
515
613
  return JSON.stringify(canonicalizeSerializable(value));
516
614
  }
517
615
 
616
+ /**
617
+ * The eight numeric fields recursively verified by `recomputeAccountingFromTrace`.
618
+ *
619
+ * These are the only summable scalars on `RunAccounting`. Non-numeric fields
620
+ * (`kind`, `tier`, `budget`, `termination`, `budgetStateChanges`) and derived
621
+ * ratios (`usdCapUtilization`, `totalTokenCapUtilization`) are NOT in this set.
622
+ */
623
+ const RECOMPUTE_FIELD_ORDER: readonly [
624
+ "cost.usd",
625
+ "cost.inputTokens",
626
+ "cost.outputTokens",
627
+ "cost.totalTokens",
628
+ "usage.usd",
629
+ "usage.inputTokens",
630
+ "usage.outputTokens",
631
+ "usage.totalTokens"
632
+ ] = [
633
+ "cost.usd",
634
+ "cost.inputTokens",
635
+ "cost.outputTokens",
636
+ "cost.totalTokens",
637
+ "usage.usd",
638
+ "usage.inputTokens",
639
+ "usage.outputTokens",
640
+ "usage.totalTokens"
641
+ ];
642
+
643
+ const USD_FIELDS: ReadonlySet<string> = new Set(["cost.usd", "usage.usd"]);
644
+ const FLOAT_EPSILON = 1e-9;
645
+
646
+ function readNumericField(accounting: RunAccounting, field: (typeof RECOMPUTE_FIELD_ORDER)[number]): number {
647
+ switch (field) {
648
+ case "cost.usd":
649
+ return accounting.cost.usd;
650
+ case "cost.inputTokens":
651
+ return accounting.cost.inputTokens;
652
+ case "cost.outputTokens":
653
+ return accounting.cost.outputTokens;
654
+ case "cost.totalTokens":
655
+ return accounting.cost.totalTokens;
656
+ case "usage.usd":
657
+ return accounting.usage.usd;
658
+ case "usage.inputTokens":
659
+ return accounting.usage.inputTokens;
660
+ case "usage.outputTokens":
661
+ return accounting.usage.outputTokens;
662
+ case "usage.totalTokens":
663
+ return accounting.usage.totalTokens;
664
+ }
665
+ }
666
+
667
+ function fieldsEqual(field: (typeof RECOMPUTE_FIELD_ORDER)[number], a: number, b: number): boolean {
668
+ if (USD_FIELDS.has(field)) {
669
+ return Math.abs(a - b) < FLOAT_EPSILON;
670
+ }
671
+ return a === b;
672
+ }
673
+
674
+ function firstDifferingField(
675
+ recorded: RunAccounting,
676
+ recomputed: RunAccounting
677
+ ): { readonly field: (typeof RECOMPUTE_FIELD_ORDER)[number]; readonly recorded: number; readonly recomputed: number } | null {
678
+ for (const field of RECOMPUTE_FIELD_ORDER) {
679
+ const a = readNumericField(recorded, field);
680
+ const b = readNumericField(recomputed, field);
681
+ if (!fieldsEqual(field, a, b)) {
682
+ return { field, recorded: a, recomputed: b };
683
+ }
684
+ }
685
+ return null;
686
+ }
687
+
688
+ function buildLocalAccounting(trace: Trace): RunAccounting {
689
+ return createRunAccounting({
690
+ tier: trace.tier,
691
+ ...(trace.budget.caps ? { budget: trace.budget.caps } : {}),
692
+ ...(trace.budget.termination ? { termination: trace.budget.termination } : {}),
693
+ cost: trace.finalOutput.cost,
694
+ events: trace.events
695
+ });
696
+ }
697
+
698
+ export function lastCostBearingEventCost(events: readonly RunEvent[]): CostSummary | null {
699
+ for (let index = events.length - 1; index >= 0; index -= 1) {
700
+ const event = events[index];
701
+ if (event === undefined) continue;
702
+ if (
703
+ event.type === "final" ||
704
+ event.type === "agent-turn" ||
705
+ event.type === "broadcast" ||
706
+ event.type === "budget-stop"
707
+ ) {
708
+ return event.cost;
709
+ }
710
+ }
711
+ return null;
712
+ }
713
+
714
+ /**
715
+ * Recompute a parent's `RunAccounting` from a saved `Trace` for replay-time
716
+ * tamper detection.
717
+ *
718
+ * @remarks
719
+ * Returns the parent's local `RunAccounting` (built the same way `replay()`
720
+ * builds it today, from `trace.finalOutput.cost` and `trace.events`). While
721
+ * walking events, every `sub-run-completed` is recursed into and the
722
+ * recomputed child accounting is compared field-by-field to the recorded
723
+ * `event.subResult.accounting`. A mismatch on any of the eight enumerated
724
+ * numeric fields throws `DogpileError({ code: "invalid-configuration" })`
725
+ * with `detail.reason: "trace-accounting-mismatch"` and a concrete
726
+ * `detail.field` identifying the first differing numeric.
727
+ *
728
+ * Pure: no provider calls, no I/O, no clock reads.
729
+ *
730
+ * Non-summed fields (`kind`, `tier`, `budget`, `termination`,
731
+ * `budgetStateChanges`) and derived ratios (`usdCapUtilization`,
732
+ * `totalTokenCapUtilization`) are not in the comparison set.
733
+ */
734
+ export function recomputeAccountingFromTrace(trace: Trace): RunAccounting {
735
+ const local = buildLocalAccounting(trace);
736
+
737
+ // Parent-level integrity: the recorded `trace.finalOutput.cost` must match
738
+ // the cost on the last cost-bearing event. On a clean trace this holds by
739
+ // construction (every protocol writes `totalCost` into the final event).
740
+ // On a trace where `finalOutput.cost` was mutated without updating the
741
+ // events (or vice versa), this catches the drift.
742
+ const lastEventCost = lastCostBearingEventCost(trace.events);
743
+ if (lastEventCost !== null) {
744
+ const reconstructedFromEvents: RunAccounting = createRunAccounting({
745
+ tier: trace.tier,
746
+ ...(trace.budget.caps ? { budget: trace.budget.caps } : {}),
747
+ ...(trace.budget.termination ? { termination: trace.budget.termination } : {}),
748
+ cost: lastEventCost,
749
+ events: trace.events
750
+ });
751
+ const drift = firstDifferingField(local, reconstructedFromEvents);
752
+ if (drift !== null) {
753
+ throw new DogpileError({
754
+ code: "invalid-configuration",
755
+ message: `Trace accounting mismatch at parent run ${trace.runId}: field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed}.`,
756
+ retryable: false,
757
+ detail: {
758
+ kind: "trace-validation",
759
+ reason: "trace-accounting-mismatch",
760
+ eventIndex: -1,
761
+ childRunId: trace.runId,
762
+ field: drift.field,
763
+ recorded: drift.recorded,
764
+ recomputed: drift.recomputed
765
+ }
766
+ });
767
+ }
768
+ }
769
+
770
+ // BUDGET-03 / D-04: parent-rollup-drift parity check. Runs BEFORE the
771
+ // child recurse loop so a tampered child cost surfaces with the dedicated
772
+ // `subReason: "parent-rollup-drift"` rather than the generic
773
+ // `trace-accounting-mismatch` from the recurse check.
774
+ //
775
+ // The discriminator: each sub-run-completed event stores cost in TWO places
776
+ // (`subResult.cost` and `subResult.accounting.cost`). They must agree
777
+ // field-by-field — they are the parent-side roll-up source vs the
778
+ // child-side accounting source. Drift indicates someone mutated one without
779
+ // the other. For sub-run-failed events, `partialCost` must equal the cost
780
+ // implied by the partial trace's last cost-bearing event.
781
+ //
782
+ // Plus: Σ children must not exceed the parent's recorded total — cost is
783
+ // monotonic. A child total > parent total is unambiguous tampering.
784
+ for (let eventIndex = 0; eventIndex < trace.events.length; eventIndex += 1) {
785
+ const event = trace.events[eventIndex];
786
+ if (event === undefined) continue;
787
+ if (event.type === "sub-run-completed") {
788
+ const childRecordedRollup = createRunAccounting({
789
+ tier: trace.tier,
790
+ cost: event.subResult.cost,
791
+ events: []
792
+ });
793
+ const childRecordedAccounting = event.subResult.accounting;
794
+ const drift = firstDifferingField(childRecordedAccounting, childRecordedRollup);
795
+ if (drift !== null) {
796
+ throw new DogpileError({
797
+ code: "invalid-configuration",
798
+ message: `Trace parent-rollup mismatch at sub-run ${event.childRunId}: field "${drift.field}" recorded ${drift.recorded} on accounting, ${drift.recomputed} on subResult.cost.`,
799
+ retryable: false,
800
+ detail: {
801
+ kind: "trace-validation",
802
+ reason: "trace-accounting-mismatch",
803
+ subReason: "parent-rollup-drift",
804
+ eventIndex,
805
+ childRunId: event.childRunId,
806
+ field: drift.field,
807
+ recorded: drift.recorded,
808
+ recomputed: drift.recomputed
809
+ }
810
+ });
811
+ }
812
+ } else if (event.type === "sub-run-failed") {
813
+ const partialFromTrace = lastCostBearingEventCost(event.partialTrace.events) ?? emptyCost();
814
+ const recordedAccounting = createRunAccounting({
815
+ tier: trace.tier,
816
+ cost: event.partialCost,
817
+ events: []
818
+ });
819
+ const recomputedAccounting = createRunAccounting({
820
+ tier: trace.tier,
821
+ cost: partialFromTrace,
822
+ events: []
823
+ });
824
+ const drift = firstDifferingField(recordedAccounting, recomputedAccounting);
825
+ if (drift !== null) {
826
+ throw new DogpileError({
827
+ code: "invalid-configuration",
828
+ message: `Trace parent-rollup mismatch at sub-run ${event.childRunId}: partialCost field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed} from partialTrace events.`,
829
+ retryable: false,
830
+ detail: {
831
+ kind: "trace-validation",
832
+ reason: "trace-accounting-mismatch",
833
+ subReason: "parent-rollup-drift",
834
+ eventIndex,
835
+ childRunId: event.childRunId,
836
+ field: drift.field,
837
+ recorded: drift.recorded,
838
+ recomputed: drift.recomputed
839
+ }
840
+ });
841
+ }
842
+ }
843
+ }
844
+
845
+ // Tree-level monotonicity: Σ children must be ≤ parent's recorded total
846
+ // across all 8 fields. Cost is non-negative and monotonic.
847
+ const subRunTotal = accumulateSubRunCost(trace.events);
848
+ const parentTotal = trace.finalOutput.cost;
849
+ for (const field of RECOMPUTE_FIELD_ORDER) {
850
+ if (field.startsWith("usage.")) continue; // usage mirrors cost; one check is enough.
851
+ const [, key] = field.split(".") as [string, keyof CostSummary];
852
+ const parentValue = parentTotal[key];
853
+ const childValue = subRunTotal[key];
854
+ if (childValue - parentValue > FLOAT_EPSILON) {
855
+ throw new DogpileError({
856
+ code: "invalid-configuration",
857
+ message: `Trace parent-rollup mismatch at run ${trace.runId}: field "${field}" Σ children ${childValue} exceeds parent recorded ${parentValue}.`,
858
+ retryable: false,
859
+ detail: {
860
+ kind: "trace-validation",
861
+ reason: "trace-accounting-mismatch",
862
+ subReason: "parent-rollup-drift",
863
+ eventIndex: -1,
864
+ childRunId: trace.runId,
865
+ field,
866
+ recorded: parentValue,
867
+ recomputed: childValue
868
+ }
869
+ });
870
+ }
871
+ }
872
+
873
+ // Child-level integrity: recurse into every sub-run-completed and verify
874
+ // its recorded `subResult.accounting` matches what the child trace recomputes.
875
+ for (let eventIndex = 0; eventIndex < trace.events.length; eventIndex += 1) {
876
+ const event = trace.events[eventIndex];
877
+ if (event === undefined || event.type !== "sub-run-completed") continue;
878
+
879
+ const childRecomputed = recomputeAccountingFromTrace(event.subResult.trace);
880
+ const childRecorded = event.subResult.accounting;
881
+ const drift = firstDifferingField(childRecorded, childRecomputed);
882
+ if (drift !== null) {
883
+ throw new DogpileError({
884
+ code: "invalid-configuration",
885
+ message: `Trace accounting mismatch at sub-run ${event.childRunId}: field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed}.`,
886
+ retryable: false,
887
+ detail: {
888
+ kind: "trace-validation",
889
+ reason: "trace-accounting-mismatch",
890
+ eventIndex,
891
+ childRunId: event.childRunId,
892
+ field: drift.field,
893
+ recorded: drift.recorded,
894
+ recomputed: drift.recomputed
895
+ }
896
+ });
897
+ }
898
+ }
899
+
900
+ return local;
901
+ }
902
+
518
903
  export function canonicalizeSerializable<T>(value: T): T {
519
904
  if (Array.isArray(value)) {
520
905
  return value.map((item) => canonicalizeSerializable(item)) as T;