@smartmemory/stratum 0.3.3 → 0.4.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 (81) hide show
  1. package/dist/cli/guard.js +63 -6
  2. package/dist/cli/guard.js.map +1 -1
  3. package/dist/cli/learn.js +226 -0
  4. package/dist/cli/learn.js.map +1 -0
  5. package/dist/cli/query_gate.js +2 -1
  6. package/dist/cli/query_gate.js.map +1 -1
  7. package/dist/cli/stratum.js +5 -2
  8. package/dist/cli/stratum.js.map +1 -1
  9. package/dist/connectors/background.js +58 -10
  10. package/dist/connectors/background.js.map +1 -1
  11. package/dist/connectors/base.js +16 -0
  12. package/dist/connectors/base.js.map +1 -1
  13. package/dist/connectors/cancellation.js +119 -0
  14. package/dist/connectors/cancellation.js.map +1 -0
  15. package/dist/connectors/claude-bg-worker.js +9 -1
  16. package/dist/connectors/claude-bg-worker.js.map +1 -1
  17. package/dist/connectors/claude.js +179 -86
  18. package/dist/connectors/claude.js.map +1 -1
  19. package/dist/connectors/codex.js +289 -61
  20. package/dist/connectors/codex.js.map +1 -1
  21. package/dist/connectors/runner.js +44 -3
  22. package/dist/connectors/runner.js.map +1 -1
  23. package/dist/contracts/events.json +34 -1
  24. package/dist/contracts/guard-signers.allowed +36 -0
  25. package/dist/contracts/mcp-surface.json +1097 -104
  26. package/dist/engine/checkpoint.js +8 -1
  27. package/dist/engine/checkpoint.js.map +1 -1
  28. package/dist/engine/engine.js +508 -42
  29. package/dist/engine/engine.js.map +1 -1
  30. package/dist/engine/evaluate.js +61 -0
  31. package/dist/engine/evaluate.js.map +1 -0
  32. package/dist/engine/ledger.js +12 -0
  33. package/dist/engine/ledger.js.map +1 -1
  34. package/dist/engine/receipts.js +91 -0
  35. package/dist/engine/receipts.js.map +1 -0
  36. package/dist/engine/state.js +51 -1
  37. package/dist/engine/state.js.map +1 -1
  38. package/dist/guard/authorization.js +71 -0
  39. package/dist/guard/authorization.js.map +1 -0
  40. package/dist/guard/descriptors.js +248 -0
  41. package/dist/guard/descriptors.js.map +1 -0
  42. package/dist/guard/errors.js +12 -0
  43. package/dist/guard/errors.js.map +1 -1
  44. package/dist/guard/evidence.js +24 -3
  45. package/dist/guard/evidence.js.map +1 -1
  46. package/dist/guard/sshsig.js +264 -0
  47. package/dist/guard/sshsig.js.map +1 -0
  48. package/dist/guard/store.js +13 -0
  49. package/dist/guard/store.js.map +1 -1
  50. package/dist/guard/transition.js +484 -33
  51. package/dist/guard/transition.js.map +1 -1
  52. package/dist/guard/trust.js +78 -0
  53. package/dist/guard/trust.js.map +1 -0
  54. package/dist/ir/schema.js +8 -1
  55. package/dist/ir/schema.js.map +1 -1
  56. package/dist/ir/validate.js +3 -1
  57. package/dist/ir/validate.js.map +1 -1
  58. package/dist/judge/judged.js +1 -1
  59. package/dist/judge/pricing.js +1 -0
  60. package/dist/judge/pricing.js.map +1 -1
  61. package/dist/learn/apply.js +600 -0
  62. package/dist/learn/apply.js.map +1 -0
  63. package/dist/learn/candidate.js +186 -0
  64. package/dist/learn/candidate.js.map +1 -0
  65. package/dist/learn/classify.js +181 -0
  66. package/dist/learn/classify.js.map +1 -0
  67. package/dist/learn/harvest.js +138 -0
  68. package/dist/learn/harvest.js.map +1 -0
  69. package/dist/learn/smartmemory_egress.js +330 -0
  70. package/dist/learn/smartmemory_egress.js.map +1 -0
  71. package/dist/mcp/server.js +155 -24
  72. package/dist/mcp/server.js.map +1 -1
  73. package/dist/policy/bundle.js +195 -0
  74. package/dist/policy/bundle.js.map +1 -0
  75. package/dist/policy/events.js +51 -0
  76. package/dist/policy/events.js.map +1 -0
  77. package/dist/policy/smartmemory_client.js +332 -0
  78. package/dist/policy/smartmemory_client.js.map +1 -0
  79. package/dist/policy/types.js +2 -0
  80. package/dist/policy/types.js.map +1 -0
  81. package/package.json +3 -2
@@ -4,13 +4,50 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
+ import { z } from "zod";
7
8
  import { runAgent } from "../connectors/runner.js";
8
9
  import { extractReferences } from "../ir/refs.js";
9
10
  import { validateSpec } from "../ir/validate.js";
10
- import { BudgetLedger, validUsage } from "./ledger.js";
11
+ import { LearnEgress } from "../learn/smartmemory_egress.js";
12
+ import { mergeBundleIntoSpec, policyRuleKey, predicateType, validateBundle } from "../policy/bundle.js";
13
+ import { buildFlowTerminalEvent, buildGateResolutionEvent } from "../policy/events.js";
14
+ import { emitPolicyEvent as postPolicyEvent } from "../policy/smartmemory_client.js";
15
+ import { BUDGET_KEYS, BudgetLedger, validConnectorTelemetry, validUsage } from "./ledger.js";
11
16
  import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
17
+ import { buildReceipt, findReceipt, ReceiptValidationError, spineSpent } from "./receipts.js";
12
18
  import { StateStore } from "./state.js";
13
19
  const execFileAsync = promisify(execFile);
20
+ /**
21
+ * The fixed shape every S1 `evaluate:` step must return. Engine-owned and
22
+ * strict — an author's `out` contract governs what is *referenceable*, this
23
+ * schema governs what the data must *be*. It is the trust anchor: a transport
24
+ * failure can never be laundered into a `closed` verdict, and the cross-field
25
+ * invariants (`closed` ⇒ no children, `open` ⇒ ≥1 child) are enforced here.
26
+ */
27
+ export const evaluatorResultSchema = z.object({
28
+ status: z.enum(["closed", "open", "failed"]),
29
+ children: z.array(z.unknown()),
30
+ reason: z.string(),
31
+ score: z.number().optional(),
32
+ route: z.enum(["claude", "codex"]).optional(),
33
+ }).strict().superRefine((result, ctx) => {
34
+ if (result.status === "closed" && result.children.length > 0) {
35
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "a closed verdict must carry no children" });
36
+ }
37
+ if (result.status === "open" && result.children.length === 0) {
38
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "an open verdict must carry at least one child" });
39
+ }
40
+ });
41
+ /**
42
+ * The engine validates the runner's ENVELOPE at runtime, not just the verdict
43
+ * inside it — the `ok` discriminant is the runner's word for whether it even
44
+ * succeeded, and a malformed envelope must never let a `{status:"closed"}`
45
+ * payload reach the success path. Same trust posture as the judge verdict.
46
+ */
47
+ const evaluateRunResultSchema = z.discriminatedUnion("ok", [
48
+ z.object({ ok: z.literal(true), result: z.unknown() }),
49
+ z.object({ ok: z.literal(false), kind: z.enum(["exit", "timeout", "parse"]), reason: z.string() }),
50
+ ]);
14
51
  export class CheckpointOperationError extends Error {
15
52
  errorType;
16
53
  available;
@@ -29,11 +66,17 @@ export class SpecValidationError extends Error {
29
66
  this.errors = errors;
30
67
  }
31
68
  }
69
+ export class InputValidationError extends SpecValidationError {
70
+ constructor(errors) { super(errors); this.message = "entry input validation failed"; }
71
+ }
32
72
  export class StratumEngine {
33
73
  store;
34
74
  evaluator;
35
75
  judge;
76
+ evaluateRunner;
36
77
  connector;
78
+ learnEgress;
79
+ learnEgressStartup;
37
80
  // Serializes load-modify-save per run: plan may hand out several ready steps, so
38
81
  // stepDone/resume can race in-process. The state root is owned by one engine process in v1.
39
82
  runLocks = new Map();
@@ -51,7 +94,19 @@ export class StratumEngine {
51
94
  this.evaluator = options.evaluator;
52
95
  if (options.judge)
53
96
  this.judge = options.judge;
97
+ if (options.evaluateRunner)
98
+ this.evaluateRunner = options.evaluateRunner;
54
99
  this.connector = options.connector ?? defaultConnector;
100
+ this.learnEgress = options.learnEgress ?? new LearnEgress({
101
+ ...options.learnEgressOptions,
102
+ store: this.store,
103
+ withReceiptUpdate: (runId, update) => this.withReceiptUpdate(runId, update),
104
+ });
105
+ this.learnEgressStartup = this.learnEgress.enabled()
106
+ ? this.learnEgress.drainAll().catch((error) => {
107
+ console.warn(`SmartMemory egress startup reconciliation failed: ${message(error)}`);
108
+ })
109
+ : Promise.resolve();
55
110
  }
56
111
  async loadRun(runId) {
57
112
  const active = this.activeRuns.get(runId);
@@ -85,27 +140,70 @@ export class StratumEngine {
85
140
  });
86
141
  return result;
87
142
  }
143
+ async withReceiptUpdate(runId, update) {
144
+ return this.withRunLock(runId, async () => {
145
+ const run = await this.loadRun(runId);
146
+ const result = await update(run);
147
+ await this.persist(run);
148
+ return result;
149
+ });
150
+ }
151
+ async closeLearnEgress() {
152
+ await this.learnEgressStartup;
153
+ await this.learnEgress.close();
154
+ }
88
155
  async plan(specInput, input, options = {}) {
89
156
  const validation = validateSpec(specInput);
90
157
  if (!validation.ok)
91
158
  throw new SpecValidationError(validation.errors);
92
- const flowName = validation.value.flows.entry;
93
- const flow = validation.value.flows[flowName];
159
+ let effectiveSpec = validation.value;
160
+ let policyFields = {};
161
+ if (options.policyBundle !== undefined) {
162
+ const bundle = validateBundle(options.policyBundle);
163
+ const merged = mergeBundleIntoSpec(effectiveSpec, bundle, options.policyStepSelector);
164
+ effectiveSpec = merged.spec;
165
+ policyFields = { bundle_id: merged.bundle_id, policy_rules: merged.policy_rules, policy_rules_version: 2, policy_verdicts: [] };
166
+ const policyBindings = Object.values(merged.policy_rules).flat();
167
+ const ruleCount = new Set(policyBindings.map((binding) => binding.rule_id)).size;
168
+ console.warn(`policy bundle ${merged.bundle_id}: ${ruleCount} rules bound to ${policyBindings.length} step-predicate pairs`);
169
+ if (bundle.rules.some((rule) => rule.bind.kind === "ensure" && rule.on_fail === "gate")) {
170
+ console.warn("ensure policy rule on_fail=gate is enforced as refuse in P1; gate routing is deferred to P3");
171
+ }
172
+ }
173
+ // Validate the policy-merged specification and entry input before allocating
174
+ // or persisting a run. A rejected request must never dispatch work.
175
+ const effectiveValidation = validateSpec(effectiveSpec);
176
+ if (!effectiveValidation.ok)
177
+ throw new SpecValidationError(effectiveValidation.errors);
178
+ effectiveSpec = effectiveValidation.value;
179
+ const flowName = effectiveSpec.flows.entry;
180
+ const parsedInput = effectiveValidation.inputs[flowName]?.safeParse(input);
181
+ if (!parsedInput)
182
+ throw new Error("entry flow input contract missing after validation");
183
+ if (!parsedInput.success) {
184
+ throw new InputValidationError(parsedInput.error.issues.map((issue) => ({
185
+ code: "INPUT_CONTRACT_INVALID",
186
+ path: ["input", ...issue.path].map((part, index) => typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`).join(""),
187
+ message: issue.message,
188
+ })));
189
+ }
190
+ const flow = effectiveSpec.flows[flowName];
94
191
  if (!flow)
95
192
  throw new Error("entry flow missing after validation");
96
193
  const steps = Object.create(null);
97
194
  for (const step of flow.steps)
98
195
  steps[step.id] = { status: "pending", attempts: [], spent: {} };
99
196
  const run = {
100
- id: randomUUID(), spec: validation.value, revisionDigest: digest(validation.value), generationCounter: 0,
101
- input, flowName, status: "running", flowSpent: {}, steps,
197
+ id: randomUUID(), spec: effectiveSpec, revisionDigest: digest(effectiveSpec), generationCounter: 0,
198
+ input: parsedInput.data, flowName, status: "running", flowSpent: {}, steps,
102
199
  events: [{ at: now(), type: "planned" }],
200
+ ...policyFields,
103
201
  // Canonicalize at plan time: a relative root must never re-resolve against a
104
202
  // different process cwd after restart.
105
203
  ...(options.workspaceRoot !== undefined ? { workspaceRoot: resolve(options.workspaceRoot) } : {}),
106
204
  };
107
205
  await this.persist(run);
108
- return this.withRevisionDigest(await this.advance(run, validation.value, validation.contracts), run);
206
+ return this.withRevisionDigest(await this.advance(run, effectiveValidation.value, effectiveValidation.contracts), run);
109
207
  }
110
208
  async flowRunBg(specInput, input, options = {}) {
111
209
  const validation = validateSpec(specInput);
@@ -253,7 +351,9 @@ export class StratumEngine {
253
351
  const usage = { ...reported };
254
352
  delete usage.dispatches;
255
353
  // "settle": the agent already ran, so over-limit usage is still recorded in both ledgers.
256
- const budgetFailure = this.debit(run, step, state, usage, "settle", scope);
354
+ const budgetFailure = hasBudget(usage)
355
+ ? this.settleLegacyReceipt(run, usage, "step_done", telemetry, { scope, step, state }, result.usdSource, result.split)
356
+ : undefined;
257
357
  if (budgetFailure === "flow") {
258
358
  const failure = { attempt, reason: "flow budget exhausted" };
259
359
  state.attempts.push({ attempt, at: now(), failure, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
@@ -329,6 +429,70 @@ export class StratumEngine {
329
429
  await this.persist(run);
330
430
  return this.advance(run, validated.value, validated.contracts, scope);
331
431
  }
432
+ async usageReport(runId, input) {
433
+ return this.withRunLock(runId, async () => {
434
+ if (typeof input === "object" && input !== null && input.usdSource === "legacy") {
435
+ throw new ReceiptValidationError('usdSource "legacy" is reserved for engine-synthesized receipts');
436
+ }
437
+ const run = await this.loadRun(runId);
438
+ const candidate = input;
439
+ if (typeof input !== "object" || input === null || Array.isArray(input)
440
+ || typeof candidate?.dispatchId !== "string" || candidate.dispatchId.length === 0) {
441
+ throw new ReceiptValidationError("dispatchId must be a non-empty string");
442
+ }
443
+ if (candidate.dispatchId.startsWith("legacy:")) {
444
+ throw new ReceiptValidationError('dispatchId prefix "legacy:" is reserved for engine-synthesized receipts');
445
+ }
446
+ if (candidate.dispatchId.startsWith("engine:")) {
447
+ throw new ReceiptValidationError('dispatchId prefix "engine:" is reserved for engine-synthesized receipts');
448
+ }
449
+ const duplicate = findReceipt(run, candidate.dispatchId);
450
+ if (duplicate !== undefined) {
451
+ return { status: "duplicate", runId: run.id, seq: duplicate.seq, ledger: this.ledgerInfo(run) };
452
+ }
453
+ // Validate and allocate against a staging copy so a rejected receipt cannot
454
+ // advance the live counter (important while an active fanout pins the run object).
455
+ const staged = { ...run };
456
+ const receipt = buildReceipt(staged, input);
457
+ const located = receipt.stepId === undefined
458
+ ? undefined
459
+ : this.locateReceiptStep(run, this.validationFor(run).value, receipt.stepId);
460
+ if (receipt.stepId !== undefined && located === undefined) {
461
+ throw new ReceiptValidationError(`receipt step ${JSON.stringify(receipt.stepId)} does not exist`, "invalid_step");
462
+ }
463
+ run.receiptCounter = receipt.seq;
464
+ const wasRunning = run.status === "running";
465
+ const settled = this.settleReceipt(run, receipt, located);
466
+ if (settled.status === "duplicate") {
467
+ return { status: "duplicate", runId: run.id, seq: settled.receipt.seq, ledger: this.ledgerInfo(run) };
468
+ }
469
+ let budget;
470
+ if (settled.budget === "flow") {
471
+ budget = wasRunning ? "flow_exhausted" : "flow_exhausted_after_terminal";
472
+ if (wasRunning) {
473
+ const attempt = located?.item?.attempts.length ?? located?.state.attempts.length ?? 0;
474
+ await this.terminalBudget(run, { attempt: attempt + 1, reason: "flow budget exhausted" });
475
+ }
476
+ else {
477
+ await this.persist(run);
478
+ }
479
+ }
480
+ else {
481
+ if (settled.budget === "subflow")
482
+ budget = "subflow_exhausted";
483
+ if (settled.budget === "task")
484
+ budget = "task_exhausted";
485
+ await this.persist(run);
486
+ }
487
+ return {
488
+ status: "ok",
489
+ runId: run.id,
490
+ seq: receipt.seq,
491
+ ...(budget !== undefined ? { budget } : {}),
492
+ ledger: this.ledgerInfo(run),
493
+ };
494
+ });
495
+ }
332
496
  async commit(runId, label) {
333
497
  this.assertExternalMutationAllowed(runId, "commit");
334
498
  return await this.withRunLock(runId, async () => {
@@ -357,12 +521,39 @@ export class StratumEngine {
357
521
  const run = await this.loadCheckpointRun(runId);
358
522
  this.assertNoForegroundFanout(run, "revert");
359
523
  const normalized = label.trim();
524
+ // Money spent is spent: a revert restores state, never spend. Capture the live
525
+ // cumulative total before the snapshot overwrites it.
526
+ const liveSpentBeforeRevert = { ...run.flowSpent };
360
527
  if (!revertCheckpoint(run, normalized)) {
361
528
  // Insertion order, matching Python (list(state.checkpoints.keys())) and the commit
362
529
  // envelope's `checkpoints` — not sorted, and robust to numeric labels (array, not object).
363
530
  const available = (run.checkpoints ?? []).map((entry) => entry.label);
364
531
  throw new CheckpointOperationError("checkpoint_not_found", `No checkpoint '${normalized}' on flow '${runId}'`, available);
365
532
  }
533
+ const receiptsAtRevert = run.receiptCounter ?? 0;
534
+ const stepsRestored = Object.keys(run.steps);
535
+ // flowSpent is monotonic across reverts: the live pre-revert total already
536
+ // includes every receipt (spine) plus any pre-receipt legacy spend, so it is
537
+ // the correct value in both the receipt-era and the upgraded-mid-run case.
538
+ // The spine is kept as a floor (defense against a corrupted live total); the
539
+ // restored snapshot is never consulted for spend.
540
+ const spine = spineSpent(run);
541
+ const reconciled = {};
542
+ for (const key of BUDGET_KEYS) {
543
+ const value = Math.max(liveSpentBeforeRevert[key] ?? 0, spine[key] ?? 0);
544
+ if (value !== 0)
545
+ reconciled[key] = value;
546
+ }
547
+ run.flowSpent = reconciled;
548
+ const detail = { label: normalized, receiptsAtRevert, stepsRestored };
549
+ this.event(run, "checkpoint_reverted", undefined, detail);
550
+ const checkpointReceiptSeq = receiptsAtRevert + 1;
551
+ (run.receipts ??= []).push(buildReceipt(run, {
552
+ dispatchId: `engine:checkpoint_reverted:${checkpointReceiptSeq}`,
553
+ source: "engine",
554
+ usage: {},
555
+ detail,
556
+ }));
366
557
  this.rotateRestoredIssuances(run);
367
558
  await this.persist(run);
368
559
  return { ...await this.reAdvanceLocked(runId), reverted_to: normalized };
@@ -411,7 +602,10 @@ export class StratumEngine {
411
602
  async flowPoll(runId, cursor = 0) {
412
603
  if (!Number.isInteger(cursor) || cursor < 0)
413
604
  throw new Error("invalid event cursor");
414
- const run = await this.loadRun(runId);
605
+ // Active fanouts pin a mutable run and set terminal status before save()
606
+ // finishes. Observers must see only committed state; otherwise "completed"
607
+ // can race the final write/rename and disagree with a restarted engine.
608
+ const run = await this.store.load(runId);
415
609
  return {
416
610
  runId,
417
611
  status: run.status,
@@ -423,18 +617,19 @@ export class StratumEngine {
423
617
  };
424
618
  }
425
619
  async flowBgPoll(runId, cursor = 0) {
426
- const flow = await this.flowPoll(runId, cursor);
427
620
  const bg = this.bgFlows.get(runId);
428
621
  if (!bg)
429
622
  throw new Error(`background flow ${runId} not found`);
430
- return {
431
- ...flow,
432
- bg: {
433
- status: bg.status,
434
- cancelRequested: bg.cancelRequested,
435
- pendingGates: [...bg.pendingGates],
436
- },
623
+ // Capture driver state before reading disk. A terminal driver status is set
624
+ // only after persistence, so this ordering cannot pair bg.completed with an
625
+ // older running snapshot when the driver finishes during the asynchronous read.
626
+ const driver = {
627
+ status: bg.status,
628
+ cancelRequested: bg.cancelRequested,
629
+ pendingGates: [...bg.pendingGates],
437
630
  };
631
+ const flow = await this.flowPoll(runId, cursor);
632
+ return { ...flow, bg: driver };
438
633
  }
439
634
  async flowCancelBg(runId) {
440
635
  const bg = this.bgFlows.get(runId);
@@ -459,8 +654,20 @@ export class StratumEngine {
459
654
  }
460
655
  return { status: bg.status };
461
656
  }
462
- async gateResolve(runId, stepId, decision, gateToken) {
657
+ async gateResolve(runId, stepId, decision, gateToken, userId) {
463
658
  const response = await this.withRunLock(runId, () => this.gateResolveLocked(runId, stepId, decision, gateToken));
659
+ const resolvedRun = await this.loadRun(runId);
660
+ if (resolvedRun.bundle_id !== undefined) {
661
+ const round = resolvedRun.events.filter((event) => event.type === "gate_resolved" && event.stepId === stepId).length;
662
+ this.firePolicyEvent(buildGateResolutionEvent({
663
+ runId,
664
+ bundleId: resolvedRun.bundle_id,
665
+ stepId,
666
+ round,
667
+ outcome: decision,
668
+ ...(userId !== undefined ? { resolvedByUserId: userId } : {}),
669
+ }));
670
+ }
464
671
  const bg = this.bgFlows.get(runId);
465
672
  if (bg?.status === "paused_gate" && response.status !== "ready" && response.status !== "running") {
466
673
  bg.status = response.status;
@@ -532,7 +739,7 @@ export class StratumEngine {
532
739
  scope.parent.state.sub.rounds = total;
533
740
  else
534
741
  run.rounds = total;
535
- this.resetFrom(scope.flow, scope.steps, target);
742
+ this.resetFrom(run, scope, target);
536
743
  // The target's descendants include this gate; retain its local revision counter.
537
744
  scope.steps[step.id].iterations = gateRounds + 1;
538
745
  await this.persist(run);
@@ -560,7 +767,7 @@ export class StratumEngine {
560
767
  return this.advance(run, validated.value, validated.contracts, scope);
561
768
  }
562
769
  async completeTerminalGate(run, flow, contracts) {
563
- const output = this.resolveFlowOutput({ input: run.input, steps: run.steps, flow });
770
+ const output = this.resolveFlowOutput({ input: run.input, steps: run.steps, flow, flowName: run.flowName });
564
771
  const parsed = contracts[flow.output.contract]?.safeParse(output);
565
772
  if (!parsed?.success)
566
773
  return this.terminalFailure(run, { attempt: 0, reason: parsed?.error.message ?? "flow output contract missing" });
@@ -568,6 +775,7 @@ export class StratumEngine {
568
775
  run.status = "completed";
569
776
  this.event(run, "completed", undefined, { output });
570
777
  await this.persist(run);
778
+ this.emitFlowTerminal(run);
571
779
  return this.response(run);
572
780
  }
573
781
  /** Re-derive a run's response after async fanout/subflow progress without
@@ -737,6 +945,7 @@ export class StratumEngine {
737
945
  run.status = "completed";
738
946
  this.event(run, "completed", undefined, { output });
739
947
  await this.persist(run);
948
+ this.emitFlowTerminal(run);
740
949
  return this.response(run);
741
950
  }
742
951
  return this.failScope(run, spec, contracts, scope, "no runnable steps remain");
@@ -895,6 +1104,74 @@ export class StratumEngine {
895
1104
  break;
896
1105
  }
897
1106
  }
1107
+ if (step.evaluate !== undefined) {
1108
+ const attempt = state.attempts.length + 1;
1109
+ const evaluate = step.evaluate;
1110
+ // Deterministic, single-shot, and atomic: no intermediate `running`
1111
+ // is persisted, so a crash mid-evaluate leaves the step `pending` and
1112
+ // it re-runs on resume. `forceExhausted` terminalizes every failure —
1113
+ // retrying a deterministic evaluator is pointless (backtrack is S3).
1114
+ const evalFail = (reason) => this.failAttempt(run, spec, contracts, scope, step, state, attempt, reason, {}, undefined, undefined, true);
1115
+ if (!this.evaluateRunner) {
1116
+ await evalFail("evaluate: no evaluate runner configured");
1117
+ changed = true;
1118
+ break;
1119
+ }
1120
+ let input;
1121
+ try {
1122
+ input = evaluate.in === undefined ? undefined : this.renderValue(evaluate.in, scope);
1123
+ }
1124
+ catch (error) {
1125
+ await evalFail(`evaluate: input render failed: ${message(error)}`);
1126
+ changed = true;
1127
+ break;
1128
+ }
1129
+ // Sandboxing (workspaceRoot jail) is deferred — see design open question 3.
1130
+ let rawOutcome;
1131
+ try {
1132
+ rawOutcome = await this.evaluateRunner({ command: evaluate.command, input, timeoutMs: evaluate.timeout_ms }, {});
1133
+ }
1134
+ catch (error) {
1135
+ await evalFail(`evaluate: runner threw: ${message(error)}`);
1136
+ changed = true;
1137
+ break;
1138
+ }
1139
+ const envelope = evaluateRunResultSchema.safeParse(rawOutcome);
1140
+ if (!envelope.success) {
1141
+ await evalFail(`evaluate: runner returned a malformed result envelope: ${envelope.error.message}`);
1142
+ changed = true;
1143
+ break;
1144
+ }
1145
+ const outcome = envelope.data;
1146
+ if (!outcome.ok) {
1147
+ const detail = outcome.reason;
1148
+ const reason = outcome.kind === "exit" ? `evaluate: command exited with a non-zero status (${detail})`
1149
+ : outcome.kind === "timeout" ? `evaluate: command timed out (${detail})`
1150
+ : `evaluate: output was not valid JSON (${detail})`;
1151
+ await evalFail(reason);
1152
+ changed = true;
1153
+ break;
1154
+ }
1155
+ const parsed = evaluatorResultSchema.safeParse(outcome.result);
1156
+ if (!parsed.success) {
1157
+ await evalFail(`evaluate: output failed the evaluator-result contract: ${parsed.error.message}`);
1158
+ changed = true;
1159
+ break;
1160
+ }
1161
+ const outError = this.contractError(step, parsed.data, contracts);
1162
+ if (outError) {
1163
+ await evalFail(`evaluate: output failed the ${step.out} contract: ${outError}`);
1164
+ changed = true;
1165
+ break;
1166
+ }
1167
+ state.status = "succeeded";
1168
+ state.output = parsed.data;
1169
+ state.attempts.push({ attempt, at: now(), result: parsed.data });
1170
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, result: parsed.data });
1171
+ await this.persist(run);
1172
+ changed = true;
1173
+ continue;
1174
+ }
898
1175
  if (step.do === undefined) {
899
1176
  await this.failScope(run, spec, contracts, scope, "construct is outside P1 engine scope");
900
1177
  break;
@@ -1362,7 +1639,11 @@ export class StratumEngine {
1362
1639
  const usage = { ...(result.usage ?? {}) };
1363
1640
  const reportedDispatches = usage.dispatches !== undefined;
1364
1641
  delete usage.dispatches;
1365
- const settled = this.debit(run, step, state, usage, "settle");
1642
+ const settled = hasBudget(usage)
1643
+ ? this.settleLegacyReceipt(run, usage, "fanout", result.telemetry, {
1644
+ scope: this.rootScope(run, spec), step, state, item,
1645
+ }, result.usdSource, result.split)
1646
+ : undefined;
1366
1647
  if (hasBudget(usage))
1367
1648
  this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: usage });
1368
1649
  const stageStep = { ...step, do: stage.do, out: stage.out, ensure: stage.ensure, budget: step.budget };
@@ -1484,7 +1765,7 @@ export class StratumEngine {
1484
1765
  }
1485
1766
  /** Evaluates a step's ensure list in order; the first failing predicate wins. */
1486
1767
  async runEnsures(run, step, state, output, scope = this.rootScope(run, this.validationFor(run).value), fanoutItem) {
1487
- for (const predicate of step.ensure ?? []) {
1768
+ for (const [ensureIndex, predicate] of (step.ensure ?? []).entries()) {
1488
1769
  if ("judged" in predicate) {
1489
1770
  const { statement, stakes } = predicate.judged;
1490
1771
  // The runner is an injected seam — validate its outcome; a malformed shape
@@ -1523,12 +1804,24 @@ export class StratumEngine {
1523
1804
  failureReason = `judged predicate failed: ${message(error)}`;
1524
1805
  }
1525
1806
  }
1526
- const usage = outcome?.usage ?? {};
1527
- const budgetFailure = hasBudget(usage) ? this.debit(run, step, state, usage, "settle", scope) : undefined;
1807
+ const usage = { ...(outcome?.usage ?? {}) };
1808
+ const judgedUsageAsReported = { ...usage };
1809
+ // A judge may report `dispatches`; that key is ledger-only (never a receipt)
1810
+ // and keeps its pre-receipt settle semantics.
1811
+ const judgedDispatches = usage.dispatches !== undefined ? { dispatches: usage.dispatches } : undefined;
1812
+ delete usage.dispatches;
1813
+ const receiptItem = fanoutItem === undefined ? undefined : state.fanout?.items[fanoutItem.itemIndex];
1814
+ const dispatchFailure = judgedDispatches !== undefined ? this.debit(run, step, state, judgedDispatches, "settle", scope) : undefined;
1815
+ const costFailure = hasBudget(usage)
1816
+ ? this.settleLegacyReceipt(run, usage, "judged", outcome?.model === undefined ? undefined : {
1817
+ model: outcome.model.length > 0 ? outcome.model : "unknown", durationMs: 0,
1818
+ }, { scope, step, state, ...(receiptItem !== undefined ? { item: receiptItem } : {}) })
1819
+ : undefined;
1820
+ const budgetFailure = worstBudget(dispatchFailure, costFailure);
1528
1821
  // A judged debit inside a fanout item must stay visible per item — the
1529
1822
  // observability contract forbids anonymous ledger movement.
1530
- if (fanoutItem && hasBudget(usage)) {
1531
- this.event(run, "fanout_ledger_debit", step.id, { itemIndex: fanoutItem.itemIndex, amount: usage, source: "judged" });
1823
+ if (fanoutItem && hasBudget(judgedUsageAsReported)) {
1824
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: fanoutItem.itemIndex, amount: judgedUsageAsReported, source: "judged" });
1532
1825
  }
1533
1826
  // Fixed audit payload — every judged evaluation events, failures included.
1534
1827
  this.event(run, "judged", this.scopedId(scope, step.id), {
@@ -1541,6 +1834,7 @@ export class StratumEngine {
1541
1834
  usage: { tokens: usage.tokens ?? 0, usd: usage.usd ?? 0 },
1542
1835
  ...(fanoutItem ? { itemIndex: fanoutItem.itemIndex, stage: fanoutItem.stage } : {}),
1543
1836
  });
1837
+ this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, failureReason === undefined && outcome?.holds === true, "judged");
1544
1838
  if (budgetFailure === "flow")
1545
1839
  return { kind: "flow_budget" };
1546
1840
  if (budgetFailure === "subflow")
@@ -1560,6 +1854,7 @@ export class StratumEngine {
1560
1854
  ? `file_exists(${JSON.stringify(predicate.file_exists)})`
1561
1855
  : `file_contains(${JSON.stringify(predicate.file_contains.path)}, ${JSON.stringify(predicate.file_contains.text)})`;
1562
1856
  const verdict = this.ensurePredicate(expression, run, output, scope, fanoutItem);
1857
+ this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, verdict.holds, predicateType(predicate));
1563
1858
  if (!verdict.holds)
1564
1859
  return { kind: "fail", reason: `ensure ${JSON.stringify(expression)} failed: ${verdict.reason}` };
1565
1860
  }
@@ -1624,6 +1919,90 @@ export class StratumEngine {
1624
1919
  return "task";
1625
1920
  return undefined;
1626
1921
  }
1922
+ settleReceipt(run, receipt, located, explicitAttempt) {
1923
+ const duplicate = findReceipt(run, receipt.dispatchId);
1924
+ if (duplicate !== undefined)
1925
+ return { status: "duplicate", receipt: duplicate };
1926
+ const canonicalStepId = located === undefined ? undefined : this.scopedId(located.scope, located.step.id);
1927
+ if (canonicalStepId !== undefined)
1928
+ receipt.stepId = canonicalStepId;
1929
+ let budget;
1930
+ const executable = located !== undefined && (located.step.do !== undefined || located.step.fanout !== undefined);
1931
+ if (executable) {
1932
+ budget = this.debit(run, located.step, located.state, receipt.amount, "settle", located.scope);
1933
+ }
1934
+ else {
1935
+ const flowLedger = new BudgetLedger(this.flowFor(run, this.validationFor(run).value).budget, run.flowSpent);
1936
+ const subflowLedger = located?.scope.parent
1937
+ ? new BudgetLedger(located.scope.parent.step.budget, located.scope.parent.state.spent)
1938
+ : undefined;
1939
+ const flowOk = flowLedger.canDebit(receipt.amount);
1940
+ const subflowOk = subflowLedger?.canDebit(receipt.amount) ?? true;
1941
+ flowLedger.debit(receipt.amount);
1942
+ subflowLedger?.debit(receipt.amount);
1943
+ Object.assign(run.flowSpent, flowLedger.spent);
1944
+ if (subflowLedger && located?.scope.parent)
1945
+ Object.assign(located.scope.parent.state.spent, subflowLedger.spent);
1946
+ if (!flowOk)
1947
+ budget = "flow";
1948
+ else if (!subflowOk)
1949
+ budget = "subflow";
1950
+ }
1951
+ (run.receipts ??= []).push(receipt);
1952
+ // `attempt` names the attempt the call belongs to. Legacy settles pass it (the
1953
+ // attempt record is pushed after settlement, so length+1 is that attempt);
1954
+ // external receipts get it only while the step is awaiting a result. Gates
1955
+ // and finished steps have no attempt to name.
1956
+ const attempt = explicitAttempt
1957
+ ?? (executable && (located?.item !== undefined
1958
+ ? (located.item.status === "ready" || located.item.status === "running")
1959
+ : located?.state.status === "ready")
1960
+ ? (located?.item !== undefined ? located.item.attempts.length + 1 : located.state.attempts.length + 1)
1961
+ : undefined);
1962
+ const item = located?.item;
1963
+ const detail = {
1964
+ ...(located?.state.epoch !== undefined ? { epoch: located.state.epoch } : {}),
1965
+ ...(attempt !== undefined ? { attempt } : {}),
1966
+ ...(item?.stage !== undefined ? { item: { itemIndex: item.index, stage: item.stage, generation: item.generation } } : {}),
1967
+ };
1968
+ // One detail object serves both the audit event and the receipt row, so the
1969
+ // SmartMemory mirror carries exactly what the event stream carries.
1970
+ const eventDetail = {
1971
+ seq: receipt.seq,
1972
+ dispatchId: receipt.dispatchId,
1973
+ source: receipt.source,
1974
+ amount: { ...receipt.amount },
1975
+ model: receipt.telemetry.model,
1976
+ ...(receipt.telemetry.effort !== undefined ? { effort: receipt.telemetry.effort } : {}),
1977
+ durationMs: receipt.telemetry.durationMs,
1978
+ ...detail,
1979
+ ...(receipt.split !== undefined ? { split: { ...receipt.split } } : {}),
1980
+ ...(receipt.usdSource !== undefined ? { usdSource: receipt.usdSource } : {}),
1981
+ ...(receipt.reportedAt !== undefined ? { reportedAt: receipt.reportedAt } : {}),
1982
+ };
1983
+ receipt.detail = { ...(receipt.detail ?? {}), ...eventDetail };
1984
+ this.event(run, "usage_debit", canonicalStepId, structuredClone(eventDetail));
1985
+ return { status: "ok", ...(budget !== undefined ? { budget } : {}) };
1986
+ }
1987
+ settleLegacyReceipt(run, usage, source, telemetry, located, usdSource, split) {
1988
+ const seq = (run.receiptCounter ?? 0) + 1;
1989
+ const receipt = buildReceipt(run, {
1990
+ dispatchId: `legacy:${seq}`,
1991
+ stepId: this.scopedId(located.scope, located.step.id),
1992
+ source,
1993
+ usage,
1994
+ ...(telemetry !== undefined ? { telemetry } : {}),
1995
+ ...(split !== undefined ? { split } : {}),
1996
+ // A connector that priced the call itself keeps "reported"; only an
1997
+ // unlabelled usd is engine-synthesized "legacy".
1998
+ ...(usage.usd !== undefined ? { usdSource: usdSource ?? "legacy" } : {}),
1999
+ });
2000
+ const attempt = (located.item?.attempts.length ?? located.state.attempts.length) + 1;
2001
+ const settled = this.settleReceipt(run, receipt, located, attempt);
2002
+ if (settled.status === "duplicate")
2003
+ throw new Error(`legacy receipt sequence ${seq} collided`);
2004
+ return settled.budget;
2005
+ }
1627
2006
  unreachableOnFailTarget(step, scope) {
1628
2007
  const routers = scope.flow.steps.filter((candidate) => candidate.on_fail === step.id);
1629
2008
  return routers.length > 0
@@ -1636,7 +2015,8 @@ export class StratumEngine {
1636
2015
  return !routesHere || scope.steps[step.id].routed !== undefined;
1637
2016
  }
1638
2017
  /** Reset a revise target and its ordinary descendants; static validation proved target ancestry. */
1639
- resetFrom(flow, steps, target) {
2018
+ resetFrom(run, scope, target) {
2019
+ const { flow, steps } = scope;
1640
2020
  const descendants = new Set([target]);
1641
2021
  let changed = true;
1642
2022
  while (changed) {
@@ -1657,6 +2037,20 @@ export class StratumEngine {
1657
2037
  }
1658
2038
  }
1659
2039
  }
2040
+ const reset = [...descendants].map((id) => {
2041
+ const fromEpoch = steps[id].epoch ?? 0;
2042
+ return { stepId: this.scopedId(scope, id), fromEpoch, toEpoch: fromEpoch + 1 };
2043
+ });
2044
+ const subflowsDropped = [...descendants].flatMap((id) => steps[id].sub === undefined ? [] : [this.scopedId(scope, id)]);
2045
+ const detail = { reason: "revise", reset, subflowsDropped };
2046
+ this.event(run, "step_reset", this.scopedId(scope, target), detail);
2047
+ const resetReceiptSeq = (run.receiptCounter ?? 0) + 1;
2048
+ (run.receipts ??= []).push(buildReceipt(run, {
2049
+ dispatchId: `engine:step_reset:${resetReceiptSeq}`,
2050
+ source: "engine",
2051
+ usage: {},
2052
+ detail,
2053
+ }));
1660
2054
  const gateIds = new Set(flow.steps.flatMap((step) => step.gate !== undefined ? [step.id] : []));
1661
2055
  for (const id of descendants) {
1662
2056
  const state = steps[id];
@@ -1859,7 +2253,7 @@ export class StratumEngine {
1859
2253
  return parse && !parse.success ? parse.error.message : parse ? undefined : "output contract missing";
1860
2254
  }
1861
2255
  rootScope(run, spec) {
1862
- return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec) };
2256
+ return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec), flowName: run.flowName };
1863
2257
  }
1864
2258
  childScope(spec, parentStep, parentState) {
1865
2259
  if (parentStep.run === undefined || parentState.sub === undefined)
@@ -1871,6 +2265,7 @@ export class StratumEngine {
1871
2265
  input: parentState.sub.input,
1872
2266
  steps: parentState.sub.steps,
1873
2267
  flow,
2268
+ flowName: parentStep.run,
1874
2269
  prefix: parentStep.id,
1875
2270
  parent: { step: parentStep, state: parentState },
1876
2271
  };
@@ -1901,6 +2296,32 @@ export class StratumEngine {
1901
2296
  const state = scope.steps[childId];
1902
2297
  return step && state ? { scope, step, state } : undefined;
1903
2298
  }
2299
+ /** Receipt attribution may arrive after a step or run terminalizes, when the
2300
+ * execution lookup intentionally hides inactive subflow/fanout state. */
2301
+ locateReceiptStep(run, spec, id) {
2302
+ const active = this.locateStep(run, spec, id);
2303
+ if (active !== undefined || !id.includes("/"))
2304
+ return active;
2305
+ const parts = id.split("/");
2306
+ if (parts.length !== 2 || !parts[0] || !parts[1])
2307
+ return undefined;
2308
+ const [parentId, childId] = parts;
2309
+ const root = this.rootScope(run, spec);
2310
+ const parentStep = root.flow.steps.find((candidate) => candidate.id === parentId);
2311
+ const parentState = root.steps[parentId];
2312
+ if (!parentStep || !parentState)
2313
+ return undefined;
2314
+ if (parentStep.fanout?.dispatch === "consumer" && parentState.fanout && /^(0|[1-9][0-9]*)$/.test(childId)) {
2315
+ const item = parentState.fanout.items[Number(childId)];
2316
+ return item && item.index === Number(childId) ? { scope: root, step: parentStep, state: parentState, item } : undefined;
2317
+ }
2318
+ if (parentStep.run === undefined || parentState.sub === undefined)
2319
+ return undefined;
2320
+ const scope = this.childScope(spec, parentStep, parentState);
2321
+ const step = scope.flow.steps.find((candidate) => candidate.id === childId);
2322
+ const state = scope.steps[childId];
2323
+ return step && state ? { scope, step, state } : undefined;
2324
+ }
1904
2325
  collectReady(run, spec) {
1905
2326
  const root = this.rootScope(run, spec);
1906
2327
  const ready = root.flow.steps.flatMap((step) => {
@@ -2045,7 +2466,9 @@ export class StratumEngine {
2045
2466
  try {
2046
2467
  return await this.loadRun(runId);
2047
2468
  }
2048
- catch {
2469
+ catch (error) {
2470
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"))
2471
+ throw error;
2049
2472
  throw new CheckpointOperationError("flow_not_found", `No active flow with id '${runId}'`);
2050
2473
  }
2051
2474
  }
@@ -2155,6 +2578,7 @@ export class StratumEngine {
2155
2578
  run.failure = failure;
2156
2579
  this.event(run, "budget_exhausted", undefined, failure);
2157
2580
  await this.persist(run);
2581
+ this.emitFlowTerminal(run);
2158
2582
  return this.response(run);
2159
2583
  }
2160
2584
  async terminalFailure(run, failure) {
@@ -2162,8 +2586,42 @@ export class StratumEngine {
2162
2586
  run.failure = failure;
2163
2587
  this.event(run, "failed", undefined, failure);
2164
2588
  await this.persist(run);
2589
+ this.emitFlowTerminal(run);
2165
2590
  return this.response(run);
2166
2591
  }
2592
+ recordPolicyVerdict(run, flowName, stepId, ensureIndex, met, predicateTypeValue) {
2593
+ const binding = run.policy_rules?.[policyRuleKey(flowName, stepId)]?.find((candidate) => candidate.ensure_index === ensureIndex);
2594
+ if (binding === undefined)
2595
+ return;
2596
+ (run.policy_verdicts ??= []).push({
2597
+ rule_id: binding.rule_id,
2598
+ source: structuredClone(binding.source),
2599
+ met,
2600
+ predicate_type: predicateTypeValue,
2601
+ });
2602
+ }
2603
+ emitFlowTerminal(run) {
2604
+ if (run.bundle_id === undefined)
2605
+ return;
2606
+ this.firePolicyEvent(buildFlowTerminalEvent({
2607
+ runId: run.id,
2608
+ bundleId: run.bundle_id,
2609
+ outcome: run.status,
2610
+ rulesEvaluated: run.policy_verdicts ?? [],
2611
+ }));
2612
+ }
2613
+ firePolicyEvent(event) {
2614
+ void postPolicyEvent(event).catch((error) => {
2615
+ console.warn(`policy event ${event.event_id} delivery failed: ${message(error)}`);
2616
+ });
2617
+ }
2618
+ triggerLearnEgress(runId) {
2619
+ if (!this.learnEgress.enabled())
2620
+ return;
2621
+ void this.learnEgress.drainRun(runId).catch((error) => {
2622
+ console.warn(`SmartMemory egress drain failed for run ${runId}: ${message(error)}`);
2623
+ });
2624
+ }
2167
2625
  response(run) {
2168
2626
  const ledger = this.ledgerInfo(run);
2169
2627
  if (run.status === "completed")
@@ -2186,7 +2644,13 @@ export class StratumEngine {
2186
2644
  }
2187
2645
  persist(run) {
2188
2646
  const previous = this.persistLocks.get(run.id) ?? Promise.resolve();
2189
- const result = previous.then(() => this.store.save(run));
2647
+ const result = previous
2648
+ .then(() => this.store.save(run))
2649
+ .then(() => {
2650
+ if (this.learnEgress.enabled() && run.receipts?.some((receipt) => receipt.egress === "pending") === true) {
2651
+ this.triggerLearnEgress(run.id);
2652
+ }
2653
+ });
2190
2654
  const tail = result.catch(() => undefined);
2191
2655
  this.persistLocks.set(run.id, tail);
2192
2656
  void tail.then(() => { if (this.persistLocks.get(run.id) === tail)
@@ -2231,6 +2695,8 @@ function stringLeaves(step) {
2231
2695
  // over "${prep.output.items}" must wait for prep, not fail at resolve time.
2232
2696
  if (step.with !== undefined)
2233
2697
  collect(step.with);
2698
+ if (step.evaluate?.in !== undefined)
2699
+ collect(step.evaluate.in);
2234
2700
  if (step.fanout !== undefined) {
2235
2701
  collect(step.fanout.over);
2236
2702
  for (const stage of step.fanout.steps) {
@@ -2298,16 +2764,12 @@ function message(error) {
2298
2764
  }
2299
2765
  }
2300
2766
  function hasBudget(usage) { return Object.keys(usage).length > 0; }
2301
- function validConnectorTelemetry(value) {
2302
- if (value === undefined)
2303
- return true;
2304
- if (typeof value !== "object" || value === null || Array.isArray(value))
2305
- return false;
2306
- if (Object.keys(value).some((key) => !["durationMs", "model", "effort"].includes(key)))
2307
- return false;
2308
- return typeof value.durationMs === "number" && Number.isFinite(value.durationMs) && value.durationMs >= 0
2309
- && typeof value.model === "string" && value.model.length > 0
2310
- && (value.effort === undefined || (typeof value.effort === "string" && value.effort.length > 0));
2767
+ /** Two settle results from one attempt collapse to the most severe ledger breach. */
2768
+ function worstBudget(...results) {
2769
+ for (const level of ["flow", "subflow", "task"])
2770
+ if (results.includes(level))
2771
+ return level;
2772
+ return undefined;
2311
2773
  }
2312
2774
  function telemetryFields(value) {
2313
2775
  return value === undefined ? {} : { durationMs: value.durationMs, model: value.model, ...(value.effort !== undefined ? { effort: value.effort } : {}) };
@@ -2339,13 +2801,17 @@ export const defaultConnector = async ({ agent, prompt, cwd, previousFailure, ou
2339
2801
  });
2340
2802
  if ("status" in result)
2341
2803
  return { failure: "background connector response is not valid for synchronous fanout" };
2804
+ const provenance = {
2805
+ ...(result.usdSource !== undefined ? { usdSource: result.usdSource } : {}),
2806
+ ...(result.split !== undefined ? { split: result.split } : {}),
2807
+ };
2342
2808
  if (outSchema === undefined)
2343
- return { output: result.text, usage: result.usage, telemetry: result.telemetry };
2809
+ return { output: result.text, usage: result.usage, telemetry: result.telemetry, ...provenance };
2344
2810
  try {
2345
- return { output: JSON.parse(stripJsonFences(result.text)), usage: result.usage, telemetry: result.telemetry };
2811
+ return { output: JSON.parse(stripJsonFences(result.text)), usage: result.usage, telemetry: result.telemetry, ...provenance };
2346
2812
  }
2347
2813
  catch {
2348
- return { failure: "connector result must be JSON for a contract-enforced fanout stage", usage: result.usage, telemetry: result.telemetry };
2814
+ return { failure: "connector result must be JSON for a contract-enforced fanout stage", usage: result.usage, telemetry: result.telemetry, ...provenance };
2349
2815
  }
2350
2816
  };
2351
2817
  function stripJsonFences(text) {