@smartmemory/stratum 0.3.4 → 0.4.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.
Files changed (73) 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/stratum.js +3 -1
  6. package/dist/cli/stratum.js.map +1 -1
  7. package/dist/connectors/background.js +53 -9
  8. package/dist/connectors/background.js.map +1 -1
  9. package/dist/connectors/base.js +16 -0
  10. package/dist/connectors/base.js.map +1 -1
  11. package/dist/connectors/cancellation.js +119 -0
  12. package/dist/connectors/cancellation.js.map +1 -0
  13. package/dist/connectors/claude-bg-worker.js +9 -1
  14. package/dist/connectors/claude-bg-worker.js.map +1 -1
  15. package/dist/connectors/claude.js +179 -86
  16. package/dist/connectors/claude.js.map +1 -1
  17. package/dist/connectors/codex.js +219 -62
  18. package/dist/connectors/codex.js.map +1 -1
  19. package/dist/connectors/runner.js +44 -3
  20. package/dist/connectors/runner.js.map +1 -1
  21. package/dist/contracts/events.json +34 -1
  22. package/dist/contracts/guard-signers.allowed +36 -0
  23. package/dist/contracts/mcp-surface.json +1085 -104
  24. package/dist/engine/checkpoint.js +8 -1
  25. package/dist/engine/checkpoint.js.map +1 -1
  26. package/dist/engine/engine.js +403 -42
  27. package/dist/engine/engine.js.map +1 -1
  28. package/dist/engine/ledger.js +12 -0
  29. package/dist/engine/ledger.js.map +1 -1
  30. package/dist/engine/receipts.js +91 -0
  31. package/dist/engine/receipts.js.map +1 -0
  32. package/dist/engine/state.js +51 -1
  33. package/dist/engine/state.js.map +1 -1
  34. package/dist/guard/authorization.js +71 -0
  35. package/dist/guard/authorization.js.map +1 -0
  36. package/dist/guard/descriptors.js +248 -0
  37. package/dist/guard/descriptors.js.map +1 -0
  38. package/dist/guard/errors.js +12 -0
  39. package/dist/guard/errors.js.map +1 -1
  40. package/dist/guard/evidence.js +24 -3
  41. package/dist/guard/evidence.js.map +1 -1
  42. package/dist/guard/sshsig.js +264 -0
  43. package/dist/guard/sshsig.js.map +1 -0
  44. package/dist/guard/store.js +13 -0
  45. package/dist/guard/store.js.map +1 -1
  46. package/dist/guard/transition.js +484 -33
  47. package/dist/guard/transition.js.map +1 -1
  48. package/dist/guard/trust.js +78 -0
  49. package/dist/guard/trust.js.map +1 -0
  50. package/dist/judge/judged.js +1 -1
  51. package/dist/judge/pricing.js +1 -0
  52. package/dist/judge/pricing.js.map +1 -1
  53. package/dist/learn/apply.js +600 -0
  54. package/dist/learn/apply.js.map +1 -0
  55. package/dist/learn/candidate.js +186 -0
  56. package/dist/learn/candidate.js.map +1 -0
  57. package/dist/learn/classify.js +181 -0
  58. package/dist/learn/classify.js.map +1 -0
  59. package/dist/learn/harvest.js +138 -0
  60. package/dist/learn/harvest.js.map +1 -0
  61. package/dist/learn/smartmemory_egress.js +330 -0
  62. package/dist/learn/smartmemory_egress.js.map +1 -0
  63. package/dist/mcp/server.js +150 -22
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/policy/bundle.js +195 -0
  66. package/dist/policy/bundle.js.map +1 -0
  67. package/dist/policy/events.js +51 -0
  68. package/dist/policy/events.js.map +1 -0
  69. package/dist/policy/smartmemory_client.js +332 -0
  70. package/dist/policy/smartmemory_client.js.map +1 -0
  71. package/dist/policy/types.js +2 -0
  72. package/dist/policy/types.js.map +1 -0
  73. package/package.json +3 -2
@@ -8,8 +8,13 @@ import { z } from "zod";
8
8
  import { runAgent } from "../connectors/runner.js";
9
9
  import { extractReferences } from "../ir/refs.js";
10
10
  import { validateSpec } from "../ir/validate.js";
11
- 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";
12
16
  import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
17
+ import { buildReceipt, findReceipt, ReceiptValidationError, spineSpent } from "./receipts.js";
13
18
  import { StateStore } from "./state.js";
14
19
  const execFileAsync = promisify(execFile);
15
20
  /**
@@ -61,12 +66,17 @@ export class SpecValidationError extends Error {
61
66
  this.errors = errors;
62
67
  }
63
68
  }
69
+ export class InputValidationError extends SpecValidationError {
70
+ constructor(errors) { super(errors); this.message = "entry input validation failed"; }
71
+ }
64
72
  export class StratumEngine {
65
73
  store;
66
74
  evaluator;
67
75
  judge;
68
76
  evaluateRunner;
69
77
  connector;
78
+ learnEgress;
79
+ learnEgressStartup;
70
80
  // Serializes load-modify-save per run: plan may hand out several ready steps, so
71
81
  // stepDone/resume can race in-process. The state root is owned by one engine process in v1.
72
82
  runLocks = new Map();
@@ -87,6 +97,16 @@ export class StratumEngine {
87
97
  if (options.evaluateRunner)
88
98
  this.evaluateRunner = options.evaluateRunner;
89
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();
90
110
  }
91
111
  async loadRun(runId) {
92
112
  const active = this.activeRuns.get(runId);
@@ -120,27 +140,70 @@ export class StratumEngine {
120
140
  });
121
141
  return result;
122
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
+ }
123
155
  async plan(specInput, input, options = {}) {
124
156
  const validation = validateSpec(specInput);
125
157
  if (!validation.ok)
126
158
  throw new SpecValidationError(validation.errors);
127
- const flowName = validation.value.flows.entry;
128
- 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];
129
191
  if (!flow)
130
192
  throw new Error("entry flow missing after validation");
131
193
  const steps = Object.create(null);
132
194
  for (const step of flow.steps)
133
195
  steps[step.id] = { status: "pending", attempts: [], spent: {} };
134
196
  const run = {
135
- id: randomUUID(), spec: validation.value, revisionDigest: digest(validation.value), generationCounter: 0,
136
- 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,
137
199
  events: [{ at: now(), type: "planned" }],
200
+ ...policyFields,
138
201
  // Canonicalize at plan time: a relative root must never re-resolve against a
139
202
  // different process cwd after restart.
140
203
  ...(options.workspaceRoot !== undefined ? { workspaceRoot: resolve(options.workspaceRoot) } : {}),
141
204
  };
142
205
  await this.persist(run);
143
- 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);
144
207
  }
145
208
  async flowRunBg(specInput, input, options = {}) {
146
209
  const validation = validateSpec(specInput);
@@ -288,7 +351,9 @@ export class StratumEngine {
288
351
  const usage = { ...reported };
289
352
  delete usage.dispatches;
290
353
  // "settle": the agent already ran, so over-limit usage is still recorded in both ledgers.
291
- 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;
292
357
  if (budgetFailure === "flow") {
293
358
  const failure = { attempt, reason: "flow budget exhausted" };
294
359
  state.attempts.push({ attempt, at: now(), failure, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
@@ -364,6 +429,70 @@ export class StratumEngine {
364
429
  await this.persist(run);
365
430
  return this.advance(run, validated.value, validated.contracts, scope);
366
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
+ }
367
496
  async commit(runId, label) {
368
497
  this.assertExternalMutationAllowed(runId, "commit");
369
498
  return await this.withRunLock(runId, async () => {
@@ -392,12 +521,39 @@ export class StratumEngine {
392
521
  const run = await this.loadCheckpointRun(runId);
393
522
  this.assertNoForegroundFanout(run, "revert");
394
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 };
395
527
  if (!revertCheckpoint(run, normalized)) {
396
528
  // Insertion order, matching Python (list(state.checkpoints.keys())) and the commit
397
529
  // envelope's `checkpoints` — not sorted, and robust to numeric labels (array, not object).
398
530
  const available = (run.checkpoints ?? []).map((entry) => entry.label);
399
531
  throw new CheckpointOperationError("checkpoint_not_found", `No checkpoint '${normalized}' on flow '${runId}'`, available);
400
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
+ }));
401
557
  this.rotateRestoredIssuances(run);
402
558
  await this.persist(run);
403
559
  return { ...await this.reAdvanceLocked(runId), reverted_to: normalized };
@@ -446,7 +602,10 @@ export class StratumEngine {
446
602
  async flowPoll(runId, cursor = 0) {
447
603
  if (!Number.isInteger(cursor) || cursor < 0)
448
604
  throw new Error("invalid event cursor");
449
- 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);
450
609
  return {
451
610
  runId,
452
611
  status: run.status,
@@ -458,18 +617,19 @@ export class StratumEngine {
458
617
  };
459
618
  }
460
619
  async flowBgPoll(runId, cursor = 0) {
461
- const flow = await this.flowPoll(runId, cursor);
462
620
  const bg = this.bgFlows.get(runId);
463
621
  if (!bg)
464
622
  throw new Error(`background flow ${runId} not found`);
465
- return {
466
- ...flow,
467
- bg: {
468
- status: bg.status,
469
- cancelRequested: bg.cancelRequested,
470
- pendingGates: [...bg.pendingGates],
471
- },
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],
472
630
  };
631
+ const flow = await this.flowPoll(runId, cursor);
632
+ return { ...flow, bg: driver };
473
633
  }
474
634
  async flowCancelBg(runId) {
475
635
  const bg = this.bgFlows.get(runId);
@@ -494,8 +654,20 @@ export class StratumEngine {
494
654
  }
495
655
  return { status: bg.status };
496
656
  }
497
- async gateResolve(runId, stepId, decision, gateToken) {
657
+ async gateResolve(runId, stepId, decision, gateToken, userId) {
498
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
+ }
499
671
  const bg = this.bgFlows.get(runId);
500
672
  if (bg?.status === "paused_gate" && response.status !== "ready" && response.status !== "running") {
501
673
  bg.status = response.status;
@@ -567,7 +739,7 @@ export class StratumEngine {
567
739
  scope.parent.state.sub.rounds = total;
568
740
  else
569
741
  run.rounds = total;
570
- this.resetFrom(scope.flow, scope.steps, target);
742
+ this.resetFrom(run, scope, target);
571
743
  // The target's descendants include this gate; retain its local revision counter.
572
744
  scope.steps[step.id].iterations = gateRounds + 1;
573
745
  await this.persist(run);
@@ -595,7 +767,7 @@ export class StratumEngine {
595
767
  return this.advance(run, validated.value, validated.contracts, scope);
596
768
  }
597
769
  async completeTerminalGate(run, flow, contracts) {
598
- 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 });
599
771
  const parsed = contracts[flow.output.contract]?.safeParse(output);
600
772
  if (!parsed?.success)
601
773
  return this.terminalFailure(run, { attempt: 0, reason: parsed?.error.message ?? "flow output contract missing" });
@@ -603,6 +775,7 @@ export class StratumEngine {
603
775
  run.status = "completed";
604
776
  this.event(run, "completed", undefined, { output });
605
777
  await this.persist(run);
778
+ this.emitFlowTerminal(run);
606
779
  return this.response(run);
607
780
  }
608
781
  /** Re-derive a run's response after async fanout/subflow progress without
@@ -772,6 +945,7 @@ export class StratumEngine {
772
945
  run.status = "completed";
773
946
  this.event(run, "completed", undefined, { output });
774
947
  await this.persist(run);
948
+ this.emitFlowTerminal(run);
775
949
  return this.response(run);
776
950
  }
777
951
  return this.failScope(run, spec, contracts, scope, "no runnable steps remain");
@@ -1465,7 +1639,11 @@ export class StratumEngine {
1465
1639
  const usage = { ...(result.usage ?? {}) };
1466
1640
  const reportedDispatches = usage.dispatches !== undefined;
1467
1641
  delete usage.dispatches;
1468
- 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;
1469
1647
  if (hasBudget(usage))
1470
1648
  this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: usage });
1471
1649
  const stageStep = { ...step, do: stage.do, out: stage.out, ensure: stage.ensure, budget: step.budget };
@@ -1587,7 +1765,7 @@ export class StratumEngine {
1587
1765
  }
1588
1766
  /** Evaluates a step's ensure list in order; the first failing predicate wins. */
1589
1767
  async runEnsures(run, step, state, output, scope = this.rootScope(run, this.validationFor(run).value), fanoutItem) {
1590
- for (const predicate of step.ensure ?? []) {
1768
+ for (const [ensureIndex, predicate] of (step.ensure ?? []).entries()) {
1591
1769
  if ("judged" in predicate) {
1592
1770
  const { statement, stakes } = predicate.judged;
1593
1771
  // The runner is an injected seam — validate its outcome; a malformed shape
@@ -1626,12 +1804,24 @@ export class StratumEngine {
1626
1804
  failureReason = `judged predicate failed: ${message(error)}`;
1627
1805
  }
1628
1806
  }
1629
- const usage = outcome?.usage ?? {};
1630
- 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);
1631
1821
  // A judged debit inside a fanout item must stay visible per item — the
1632
1822
  // observability contract forbids anonymous ledger movement.
1633
- if (fanoutItem && hasBudget(usage)) {
1634
- 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" });
1635
1825
  }
1636
1826
  // Fixed audit payload — every judged evaluation events, failures included.
1637
1827
  this.event(run, "judged", this.scopedId(scope, step.id), {
@@ -1644,6 +1834,7 @@ export class StratumEngine {
1644
1834
  usage: { tokens: usage.tokens ?? 0, usd: usage.usd ?? 0 },
1645
1835
  ...(fanoutItem ? { itemIndex: fanoutItem.itemIndex, stage: fanoutItem.stage } : {}),
1646
1836
  });
1837
+ this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, failureReason === undefined && outcome?.holds === true, "judged");
1647
1838
  if (budgetFailure === "flow")
1648
1839
  return { kind: "flow_budget" };
1649
1840
  if (budgetFailure === "subflow")
@@ -1663,6 +1854,7 @@ export class StratumEngine {
1663
1854
  ? `file_exists(${JSON.stringify(predicate.file_exists)})`
1664
1855
  : `file_contains(${JSON.stringify(predicate.file_contains.path)}, ${JSON.stringify(predicate.file_contains.text)})`;
1665
1856
  const verdict = this.ensurePredicate(expression, run, output, scope, fanoutItem);
1857
+ this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, verdict.holds, predicateType(predicate));
1666
1858
  if (!verdict.holds)
1667
1859
  return { kind: "fail", reason: `ensure ${JSON.stringify(expression)} failed: ${verdict.reason}` };
1668
1860
  }
@@ -1727,6 +1919,90 @@ export class StratumEngine {
1727
1919
  return "task";
1728
1920
  return undefined;
1729
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
+ }
1730
2006
  unreachableOnFailTarget(step, scope) {
1731
2007
  const routers = scope.flow.steps.filter((candidate) => candidate.on_fail === step.id);
1732
2008
  return routers.length > 0
@@ -1739,7 +2015,8 @@ export class StratumEngine {
1739
2015
  return !routesHere || scope.steps[step.id].routed !== undefined;
1740
2016
  }
1741
2017
  /** Reset a revise target and its ordinary descendants; static validation proved target ancestry. */
1742
- resetFrom(flow, steps, target) {
2018
+ resetFrom(run, scope, target) {
2019
+ const { flow, steps } = scope;
1743
2020
  const descendants = new Set([target]);
1744
2021
  let changed = true;
1745
2022
  while (changed) {
@@ -1760,6 +2037,20 @@ export class StratumEngine {
1760
2037
  }
1761
2038
  }
1762
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
+ }));
1763
2054
  const gateIds = new Set(flow.steps.flatMap((step) => step.gate !== undefined ? [step.id] : []));
1764
2055
  for (const id of descendants) {
1765
2056
  const state = steps[id];
@@ -1962,7 +2253,7 @@ export class StratumEngine {
1962
2253
  return parse && !parse.success ? parse.error.message : parse ? undefined : "output contract missing";
1963
2254
  }
1964
2255
  rootScope(run, spec) {
1965
- 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 };
1966
2257
  }
1967
2258
  childScope(spec, parentStep, parentState) {
1968
2259
  if (parentStep.run === undefined || parentState.sub === undefined)
@@ -1974,6 +2265,7 @@ export class StratumEngine {
1974
2265
  input: parentState.sub.input,
1975
2266
  steps: parentState.sub.steps,
1976
2267
  flow,
2268
+ flowName: parentStep.run,
1977
2269
  prefix: parentStep.id,
1978
2270
  parent: { step: parentStep, state: parentState },
1979
2271
  };
@@ -2004,6 +2296,32 @@ export class StratumEngine {
2004
2296
  const state = scope.steps[childId];
2005
2297
  return step && state ? { scope, step, state } : undefined;
2006
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
+ }
2007
2325
  collectReady(run, spec) {
2008
2326
  const root = this.rootScope(run, spec);
2009
2327
  const ready = root.flow.steps.flatMap((step) => {
@@ -2148,7 +2466,9 @@ export class StratumEngine {
2148
2466
  try {
2149
2467
  return await this.loadRun(runId);
2150
2468
  }
2151
- catch {
2469
+ catch (error) {
2470
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"))
2471
+ throw error;
2152
2472
  throw new CheckpointOperationError("flow_not_found", `No active flow with id '${runId}'`);
2153
2473
  }
2154
2474
  }
@@ -2258,6 +2578,7 @@ export class StratumEngine {
2258
2578
  run.failure = failure;
2259
2579
  this.event(run, "budget_exhausted", undefined, failure);
2260
2580
  await this.persist(run);
2581
+ this.emitFlowTerminal(run);
2261
2582
  return this.response(run);
2262
2583
  }
2263
2584
  async terminalFailure(run, failure) {
@@ -2265,8 +2586,42 @@ export class StratumEngine {
2265
2586
  run.failure = failure;
2266
2587
  this.event(run, "failed", undefined, failure);
2267
2588
  await this.persist(run);
2589
+ this.emitFlowTerminal(run);
2268
2590
  return this.response(run);
2269
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
+ }
2270
2625
  response(run) {
2271
2626
  const ledger = this.ledgerInfo(run);
2272
2627
  if (run.status === "completed")
@@ -2289,7 +2644,13 @@ export class StratumEngine {
2289
2644
  }
2290
2645
  persist(run) {
2291
2646
  const previous = this.persistLocks.get(run.id) ?? Promise.resolve();
2292
- 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
+ });
2293
2654
  const tail = result.catch(() => undefined);
2294
2655
  this.persistLocks.set(run.id, tail);
2295
2656
  void tail.then(() => { if (this.persistLocks.get(run.id) === tail)
@@ -2403,16 +2764,12 @@ function message(error) {
2403
2764
  }
2404
2765
  }
2405
2766
  function hasBudget(usage) { return Object.keys(usage).length > 0; }
2406
- function validConnectorTelemetry(value) {
2407
- if (value === undefined)
2408
- return true;
2409
- if (typeof value !== "object" || value === null || Array.isArray(value))
2410
- return false;
2411
- if (Object.keys(value).some((key) => !["durationMs", "model", "effort"].includes(key)))
2412
- return false;
2413
- return typeof value.durationMs === "number" && Number.isFinite(value.durationMs) && value.durationMs >= 0
2414
- && typeof value.model === "string" && value.model.length > 0
2415
- && (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;
2416
2773
  }
2417
2774
  function telemetryFields(value) {
2418
2775
  return value === undefined ? {} : { durationMs: value.durationMs, model: value.model, ...(value.effort !== undefined ? { effort: value.effort } : {}) };
@@ -2444,13 +2801,17 @@ export const defaultConnector = async ({ agent, prompt, cwd, previousFailure, ou
2444
2801
  });
2445
2802
  if ("status" in result)
2446
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
+ };
2447
2808
  if (outSchema === undefined)
2448
- return { output: result.text, usage: result.usage, telemetry: result.telemetry };
2809
+ return { output: result.text, usage: result.usage, telemetry: result.telemetry, ...provenance };
2449
2810
  try {
2450
- 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 };
2451
2812
  }
2452
2813
  catch {
2453
- 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 };
2454
2815
  }
2455
2816
  };
2456
2817
  function stripJsonFences(text) {