usertrust 1.3.0 → 1.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 (94) hide show
  1. package/README.md +9 -9
  2. package/dist/anomaly/detector.d.ts.map +1 -1
  3. package/dist/anomaly/detector.js +36 -11
  4. package/dist/anomaly/detector.js.map +1 -1
  5. package/dist/anomaly/signals/spend-velocity.d.ts +39 -12
  6. package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
  7. package/dist/anomaly/signals/spend-velocity.js +66 -43
  8. package/dist/anomaly/signals/spend-velocity.js.map +1 -1
  9. package/dist/anomaly/signals/token-rate.d.ts +33 -13
  10. package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
  11. package/dist/anomaly/signals/token-rate.js +124 -38
  12. package/dist/anomaly/signals/token-rate.js.map +1 -1
  13. package/dist/anomaly/types.d.ts +28 -6
  14. package/dist/anomaly/types.d.ts.map +1 -1
  15. package/dist/audit/chain.d.ts +9 -0
  16. package/dist/audit/chain.d.ts.map +1 -1
  17. package/dist/audit/chain.js +65 -9
  18. package/dist/audit/chain.js.map +1 -1
  19. package/dist/audit/verify.d.ts +19 -0
  20. package/dist/audit/verify.d.ts.map +1 -1
  21. package/dist/audit/verify.js +216 -14
  22. package/dist/audit/verify.js.map +1 -1
  23. package/dist/cli/init.d.ts.map +1 -1
  24. package/dist/cli/init.js +107 -2
  25. package/dist/cli/init.js.map +1 -1
  26. package/dist/cli/main.js +0 -0
  27. package/dist/cli/skill.d.ts.map +1 -1
  28. package/dist/cli/skill.js +35 -5
  29. package/dist/cli/skill.js.map +1 -1
  30. package/dist/cli/snapshot.d.ts.map +1 -1
  31. package/dist/cli/snapshot.js +20 -2
  32. package/dist/cli/snapshot.js.map +1 -1
  33. package/dist/cli/verify.d.ts.map +1 -1
  34. package/dist/cli/verify.js +20 -11
  35. package/dist/cli/verify.js.map +1 -1
  36. package/dist/detect.d.ts +34 -1
  37. package/dist/detect.d.ts.map +1 -1
  38. package/dist/detect.js +124 -0
  39. package/dist/detect.js.map +1 -1
  40. package/dist/govern.d.ts +13 -2
  41. package/dist/govern.d.ts.map +1 -1
  42. package/dist/govern.js +475 -127
  43. package/dist/govern.js.map +1 -1
  44. package/dist/headless.d.ts +41 -2
  45. package/dist/headless.d.ts.map +1 -1
  46. package/dist/headless.js +203 -35
  47. package/dist/headless.js.map +1 -1
  48. package/dist/index.d.ts +4 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/ledger/client.d.ts +14 -0
  53. package/dist/ledger/client.d.ts.map +1 -1
  54. package/dist/ledger/client.js +56 -0
  55. package/dist/ledger/client.js.map +1 -1
  56. package/dist/ledger/pricing.d.ts +54 -0
  57. package/dist/ledger/pricing.d.ts.map +1 -1
  58. package/dist/ledger/pricing.js +134 -8
  59. package/dist/ledger/pricing.js.map +1 -1
  60. package/dist/policy/default-rules.d.ts +18 -3
  61. package/dist/policy/default-rules.d.ts.map +1 -1
  62. package/dist/policy/default-rules.js +33 -15
  63. package/dist/policy/default-rules.js.map +1 -1
  64. package/dist/policy/gate.d.ts.map +1 -1
  65. package/dist/policy/gate.js +114 -16
  66. package/dist/policy/gate.js.map +1 -1
  67. package/dist/policy/injection.d.ts.map +1 -1
  68. package/dist/policy/injection.js +25 -16
  69. package/dist/policy/injection.js.map +1 -1
  70. package/dist/policy/pii.d.ts +20 -0
  71. package/dist/policy/pii.d.ts.map +1 -1
  72. package/dist/policy/pii.js +45 -2
  73. package/dist/policy/pii.js.map +1 -1
  74. package/dist/shared/types.d.ts +191 -0
  75. package/dist/shared/types.d.ts.map +1 -1
  76. package/dist/shared/types.js +67 -6
  77. package/dist/shared/types.js.map +1 -1
  78. package/dist/snapshot/checkpoint.d.ts +18 -2
  79. package/dist/snapshot/checkpoint.d.ts.map +1 -1
  80. package/dist/snapshot/checkpoint.js +101 -17
  81. package/dist/snapshot/checkpoint.js.map +1 -1
  82. package/dist/streaming.d.ts +8 -3
  83. package/dist/streaming.d.ts.map +1 -1
  84. package/dist/streaming.js +75 -31
  85. package/dist/streaming.js.map +1 -1
  86. package/dist/supply-chain/permissions.d.ts +16 -3
  87. package/dist/supply-chain/permissions.d.ts.map +1 -1
  88. package/dist/supply-chain/permissions.js +59 -9
  89. package/dist/supply-chain/permissions.js.map +1 -1
  90. package/dist/supply-chain/sign.d.ts +5 -2
  91. package/dist/supply-chain/sign.d.ts.map +1 -1
  92. package/dist/supply-chain/sign.js +10 -2
  93. package/dist/supply-chain/sign.js.map +1 -1
  94. package/package.json +2 -4
package/dist/govern.js CHANGED
@@ -26,17 +26,18 @@
26
26
  * await client.destroy();
27
27
  * ```
28
28
  */
29
- import { createHash } from "node:crypto";
29
+ import { createHash, randomUUID } from "node:crypto";
30
30
  import { existsSync, mkdirSync } from "node:fs";
31
- import { readFile, rename, writeFile } from "node:fs/promises";
31
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
+ import { CreateTransferError } from "tigerbeetle-node";
33
34
  import { createAuditWriter } from "./audit/chain.js";
34
35
  import { writeReceipt } from "./audit/rotation.js";
35
- import { detectClientKind } from "./detect.js";
36
- import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
- import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
36
+ import { classifyEndpoint, detectClientKind } from "./detect.js";
37
+ import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
38
+ import { costFromRates, estimateInputTokens, resolveRates, warnUnknownModel, } from "./ledger/pricing.js";
38
39
  import { recordPattern } from "./memory/patterns.js";
39
- import { DEFAULT_RULES } from "./policy/default-rules.js";
40
+ import { DEFAULT_RULES, mergePolicies } from "./policy/default-rules.js";
40
41
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
41
42
  import { detectInjection } from "./policy/injection.js";
42
43
  import { detectPII, redactPII } from "./policy/pii.js";
@@ -45,7 +46,7 @@ import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
45
46
  /** Base URL for receipt verification links (used in proxy mode). */
46
47
  const VERIFY_URL_BASE = "https://verify.usertrust.dev";
47
48
  import { createAnomalyDetector } from "./anomaly/detector.js";
48
- import { AnomalyError, LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.js";
49
+ import { AnomalyError, AuditDegradedError, InsufficientBalanceError, LedgerUnavailableError, PolicyDeniedError, } from "./shared/errors.js";
49
50
  import { trustId } from "./shared/ids.js";
50
51
  import { TrustConfigSchema } from "./shared/types.js";
51
52
  import { createGovernedStream } from "./streaming.js";
@@ -65,6 +66,86 @@ class AsyncMutex {
65
66
  return release;
66
67
  }
67
68
  }
69
+ // ── M2 unknown-model policy (A5) ──
70
+ /** USD value of one usertoken: 1 usertoken = $0.0001 (one basis point of a cent). */
71
+ const USERTOKENS_PER_DOLLAR = 10_000;
72
+ /**
73
+ * Enforce config.unknownModelPolicy at AUTHORIZE time (A5). Only cloud-scope
74
+ * resolutions can be unknown — local scope always resolves to local rates.
75
+ * "deny" throws before any PENDING hold; "warn" logs once per model string per
76
+ * process via the shared warnUnknownModel helper (receipts carry
77
+ * meter.rateSource "fallback" regardless of warn dedup); "fallback" is silent.
78
+ */
79
+ function enforceUnknownModelPolicy(model, resolution, config) {
80
+ if (!resolution.unknown)
81
+ return;
82
+ if (config.unknownModelPolicy === "deny") {
83
+ throw new PolicyDeniedError(`unknown_model: ${model} not in pricing table`);
84
+ }
85
+ if (config.unknownModelPolicy === "warn") {
86
+ warnUnknownModel(model);
87
+ }
88
+ }
89
+ /**
90
+ * A4: build forward-args for a local openai stream call with
91
+ * stream_options.include_usage merged in. The caller's other stream_options
92
+ * fields survive; an explicit include_usage (true OR false) is respected —
93
+ * only a missing/null value injects. Returns null when no injection applies.
94
+ */
95
+ function withInjectedUsageOptions(args) {
96
+ const params = (args[0] ?? {});
97
+ if (params.stream !== true)
98
+ return null;
99
+ const rawOptions = params.stream_options;
100
+ const streamOptions = rawOptions != null && typeof rawOptions === "object"
101
+ ? rawOptions
102
+ : undefined;
103
+ if (streamOptions?.include_usage != null)
104
+ return null;
105
+ return [
106
+ { ...params, stream_options: { ...streamOptions, include_usage: true } },
107
+ ...args.slice(1),
108
+ ];
109
+ }
110
+ /** Message shapes an OpenAI-compat server emits when it rejects an unknown field. */
111
+ const STREAM_OPTIONS_REJECTION_RE = /stream_options|include_usage|unrecognized|unknown.{0,20}(field|argument|parameter|option)/i;
112
+ /**
113
+ * A4 retry heuristic: does `err` plausibly indicate that the server rejected the
114
+ * injected `stream_options.include_usage`? We only retry-without-injection for
115
+ * these — a blanket retry on ANY error would double the provider call on
116
+ * transient failures (ECONNRESET, timeouts, 5xx) and mask the real root cause,
117
+ * so everything else rethrows the ORIGINAL error immediately. Matches the
118
+ * rejection message on the error, its nested `error.message`, or a cheaply
119
+ * reachable `response` body; or an HTTP-shaped 400/422 status on the error
120
+ * object. Tradeoff: a server that rejects the field with an opaque 500 and no
121
+ * telltale text is NOT retried — acceptable, since include_usage is widely
122
+ * supported and the caller still receives the original error.
123
+ */
124
+ function looksLikeStreamOptionsRejection(err) {
125
+ if (err == null || typeof err !== "object") {
126
+ return err instanceof Error && STREAM_OPTIONS_REJECTION_RE.test(err.message);
127
+ }
128
+ const e = err;
129
+ const status = e.status ?? e.statusCode;
130
+ if (status === 400 || status === 422)
131
+ return true;
132
+ const candidates = [e.message, e.body];
133
+ if (err instanceof Error)
134
+ candidates.push(err.message);
135
+ const nested = e.error;
136
+ if (nested != null && typeof nested === "object") {
137
+ candidates.push(nested.message);
138
+ }
139
+ const response = e.response;
140
+ if (typeof response === "string") {
141
+ candidates.push(response);
142
+ }
143
+ else if (response != null && typeof response === "object") {
144
+ const r = response;
145
+ candidates.push(r.data, r.body);
146
+ }
147
+ return candidates.some((c) => typeof c === "string" && STREAM_OPTIONS_REJECTION_RE.test(c));
148
+ }
68
149
  async function loadSpendLedger(vaultBase) {
69
150
  const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
70
151
  try {
@@ -88,22 +169,36 @@ async function loadSpendLedger(vaultBase) {
88
169
  async function persistSpendLedger(vaultBase, budgetSpent) {
89
170
  const dir = join(vaultBase, VAULT_DIR);
90
171
  const ledgerPath = join(dir, "spend-ledger.json");
91
- const tmpPath = join(dir, "spend-ledger.json.tmp");
92
- const data = {
93
- budgetSpent,
94
- updatedAt: new Date().toISOString(),
95
- };
172
+ // AUD-457 hardening (RECON #4): UNIQUE tmp path per write. A fixed
173
+ // `spend-ledger.json.tmp` lets two concurrent writers clobber each other's
174
+ // staging file, so a half-written record can be renamed into place. A pid +
175
+ // uuid suffix isolates every writer's staging file.
176
+ const tmpPath = join(dir, `spend-ledger.json.${process.pid}.${randomUUID()}.tmp`);
96
177
  try {
97
178
  // Ensure vault dir exists
98
179
  if (!existsSync(dir)) {
99
180
  mkdirSync(dir, { recursive: true });
100
181
  }
101
- // Atomic write: write tmp then rename
182
+ // MONOTONIC guard (RECON #4): cumulative spend must never regress on disk.
183
+ // A stale/racing writer carrying a lower budgetSpent must not "un-spend"
184
+ // money that another writer already recorded. Skip the write if the
185
+ // persisted value is already >= ours.
186
+ const existing = await loadSpendLedger(vaultBase);
187
+ if (existing > budgetSpent) {
188
+ return;
189
+ }
190
+ const data = {
191
+ budgetSpent,
192
+ updatedAt: new Date().toISOString(),
193
+ };
194
+ // Atomic write: write UNIQUE tmp then rename over the target.
102
195
  await writeFile(tmpPath, JSON.stringify(data), "utf-8");
103
196
  await rename(tmpPath, ledgerPath);
104
197
  }
105
198
  catch {
106
- // Best-effort — do not fail the LLM call over ledger persistence
199
+ // Best-effort — do not fail the LLM call over ledger persistence. Clean up
200
+ // our unique staging file if the rename never happened.
201
+ await unlink(tmpPath).catch(() => { });
107
202
  }
108
203
  }
109
204
  // ── trust() ──
@@ -124,7 +219,6 @@ export async function trust(client, opts) {
124
219
  budget: opts?.budget ?? DEFAULT_BUDGET,
125
220
  });
126
221
  }
127
- const customRates = config.pricing === "custom" ? config.customRates : undefined;
128
222
  const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
129
223
  // AUD-470: Only accept injected _engine/_audit in test environments.
130
224
  // In production, silently ignore them to prevent governance bypass.
@@ -134,7 +228,12 @@ export async function trust(client, opts) {
134
228
  const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
135
229
  const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
136
230
  const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
137
- const policyRules = loadedRules.length > 0 ? loadedRules : DEFAULT_RULES;
231
+ // P1-CUSTOM-POLICY-REPLACES (RECON #2): platform DEFAULT_RULES are ALWAYS
232
+ // enforced. mergePolicies is a safe concat — a custom policy file can only ADD
233
+ // deny/warn rules, never remove the budget/overshoot/exhausted guarantees. The
234
+ // gate is deny-wins with no "allow" effect, so appended user rules cannot weaken
235
+ // a default deny.
236
+ const policyRules = mergePolicies(DEFAULT_RULES, loadedRules);
138
237
  const breaker = new CircuitBreakerRegistry({
139
238
  failureThreshold: config.circuitBreaker.failureThreshold,
140
239
  resetTimeoutMs: config.circuitBreaker.resetTimeout,
@@ -147,6 +246,9 @@ export async function trust(client, opts) {
147
246
  // AUD-456: proxyConn is always null now — proxy mode throws above.
148
247
  // Cast keeps dead code paths type-safe for future re-enablement.
149
248
  const proxyConn = null;
249
+ // AUD-457: restore cumulative spend from disk BEFORE building the engine so the
250
+ // enforcing holding account can be seeded with the REMAINING budget.
251
+ let budgetSpent = await loadSpendLedger(vaultBase);
150
252
  // 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
151
253
  // AUD-470: _engine injection only accepted in test environments
152
254
  let engine;
@@ -155,7 +257,9 @@ export async function trust(client, opts) {
155
257
  }
156
258
  else if (!isDryRun && proxyConn == null) {
157
259
  try {
158
- engine = await createTBEngine(config);
260
+ // P1-LEDGER-ENFORCE (RECON #3): seed the enforcing holding account with the
261
+ // remaining budget so TigerBeetle atomically REJECTS an over-budget hold.
262
+ engine = await createTBEngine(config, Math.max(0, config.budget - budgetSpent));
159
263
  }
160
264
  catch (err) {
161
265
  throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
@@ -164,18 +268,43 @@ export async function trust(client, opts) {
164
268
  else {
165
269
  engine = null;
166
270
  }
167
- // 5. Detect client kind
271
+ // 5. Detect client kind (transport) + classify the endpoint (settlement
272
+ // regime, M2). classifyEndpoint runs BESIDE detectClientKind — the endpoint
273
+ // class, not the model string, picks local vs cloud metering (A3: this scope
274
+ // is captured once here and used verbatim at every authorize/settle below).
168
275
  const kind = detectClientKind(client);
276
+ const endpoint = classifyEndpoint(client, config, opts?.endpoint);
169
277
  // 6. Track state
170
278
  let destroyed = false;
171
- let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
172
279
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
173
280
  let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
174
281
  let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
175
282
  let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
176
283
  // Streaming anomaly detector — shared across calls so injection-cascade
177
284
  // can track signals across the conversation. Disabled when config.anomaly.enabled=false.
178
- const anomalyDetector = createAnomalyDetector(config.anomaly, { provider: kind });
285
+ // M2 seam fix: the injected costCalculator prices spend-velocity with the
286
+ // SAME scoped rates as settlement, using the observed event's
287
+ // model/endpointClass (the detector is shared while the model varies per
288
+ // call). Denominations differ by design: cloud events → DOLLARS against
289
+ // thresholdDollarsPerMin; local events → nominal usertokens against
290
+ // localThresholdUsertokensPerMin, WITHOUT the per-call >=1 settlement floor.
291
+ // Settlement floors per call; the anomaly signal measures flow — so a
292
+ // default {0,0}-rate local stream contributes 0 and can never false-trip
293
+ // spend_velocity (rejected-merge design note, pinned by Task 3 tests).
294
+ const anomalyDetector = createAnomalyDetector(config.anomaly, {
295
+ provider: kind,
296
+ costCalculator: (calcModel, inputTokens, outputTokens, event) => {
297
+ const scope = event?.endpointClass ?? "cloud";
298
+ const resolution = resolveRates(event?.model ?? calcModel, scope, config);
299
+ if (scope === "local") {
300
+ const inTok = Number.isFinite(inputTokens) && inputTokens > 0 ? inputTokens : 0;
301
+ const outTok = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0;
302
+ return ((inTok / 1000) * resolution.rates.inputPer1k +
303
+ (outTok / 1000) * resolution.rates.outputPer1k);
304
+ }
305
+ return costFromRates(resolution.rates, inputTokens, outputTokens) / USERTOKENS_PER_DOLLAR;
306
+ },
307
+ });
179
308
  // 7. Two-phase intercept
180
309
  async function interceptCall(originalFn, thisArg, args) {
181
310
  if (destroyed) {
@@ -186,17 +315,31 @@ export async function trust(client, opts) {
186
315
  try {
187
316
  const params = (args[0] ?? {});
188
317
  const model = params.model ?? "unknown";
189
- const messages = params.messages ?? [];
318
+ // P3-PROVIDER-BLINDSPOT: normalize the prompt-bearing payload across
319
+ // providers (Anthropic/OpenAI `messages` + `system`, Google `contents`) so
320
+ // PII/injection scanning, token estimation, redaction, and pattern hashing
321
+ // all see the actual prompt — not an empty `messages` array on Google calls.
322
+ const promptParts = extractPromptParts(params, kind);
190
323
  // Per-call audit degradation flag (not sticky across calls)
191
324
  let callAuditDegraded = false;
325
+ // P3-PII-REDACT-EGRESS: `forwardArgs` is what we actually send to the
326
+ // provider. In redact mode it becomes a redacted deep clone so PII never
327
+ // egresses; block mode throws before any egress. Default: forward verbatim.
328
+ let forwardArgs = args;
192
329
  // a. Circuit breaker check
193
330
  const cb = breaker.get(kind);
194
331
  cb.allowRequest();
195
- // b. Estimate cost (before policy, so cost fields are available in context)
332
+ // b. Estimate cost (before policy, so cost fields are available in context).
333
+ // M2: rates resolve within the endpoint scope CAPTURED AT AUTHORIZE (A3) —
334
+ // this one resolution prices the hold, both settlement paths, and the
335
+ // receipt's meter provenance. unknownModelPolicy is enforced here, at
336
+ // authorize time, before any PENDING hold (A5).
196
337
  const transferId = trustId("tx");
197
- const estimatedInputTokens = estimateInputTokens(messages);
338
+ const estimatedInputTokens = estimateInputTokens(promptParts);
198
339
  const maxOutputTokens = params.max_tokens ?? 4096;
199
- const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
340
+ const rateResolution = resolveRates(model, endpoint.class, config);
341
+ enforceUnknownModelPolicy(model, rateResolution, config);
342
+ const estimatedCost = costFromRates(rateResolution.rates, estimatedInputTokens, maxOutputTokens);
200
343
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
201
344
  // This prevents concurrent calls from both passing the budget check
202
345
  // and overshooting the budget.
@@ -217,28 +360,42 @@ export async function trust(client, opts) {
217
360
  }
218
361
  try {
219
362
  // c. Policy gate
363
+ // P1-PARAM-SHADOW: caller `params` are spread FIRST so trusted
364
+ // governance fields (tier/estimated_cost/budget_remaining/
365
+ // budget_remaining_after) CANNOT be shadowed by request-supplied keys.
366
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
367
+ // single-field gate compares against zero to deny a single overshooting
368
+ // call (the `block-budget-overshoot` default rule).
220
369
  const policyResult = evaluatePolicy(policyRules, {
370
+ ...params,
221
371
  model,
222
372
  tier: config.tier,
223
373
  estimated_cost: estimatedCost,
224
374
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
225
- ...params,
375
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
226
376
  });
227
377
  if (policyResult.decision === "deny") {
228
378
  const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
229
379
  throw new PolicyDeniedError(reason);
230
380
  }
231
- // d. PII check
381
+ // d. PII check + redact-egress
232
382
  if (config.pii !== "off") {
233
- const piiResult = detectPII(messages);
383
+ const piiResult = detectPII(promptParts);
234
384
  if (piiResult.found && config.pii === "block") {
385
+ // block mode: throw BEFORE any egress.
235
386
  throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
236
387
  }
237
- // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
388
+ if (config.pii === "redact" && piiResult.found) {
389
+ // redact mode: forward a redacted DEEP CLONE so PII never egresses.
390
+ // redactPII is pure — the caller's original object is never mutated.
391
+ const redactedBody = redactPII(params).data;
392
+ forwardArgs = [redactedBody, ...args.slice(1)];
393
+ }
394
+ // "warn" mode: continue, no transform (audit copy is redacted later).
238
395
  }
239
396
  // d2. Injection detection
240
397
  if (config.injection !== "off") {
241
- const injectionResult = detectInjection(messages);
398
+ const injectionResult = detectInjection(promptParts);
242
399
  if (injectionResult.detected) {
243
400
  if (config.injection === "block") {
244
401
  throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
@@ -285,7 +442,16 @@ export async function trust(client, opts) {
285
442
  });
286
443
  }
287
444
  catch (holdErr) {
288
- // Ledger unreachable do NOT forward to provider
445
+ // P1-LEDGER-ENFORCE: an over-budget reservation is rejected
446
+ // atomically by the ledger. Surface it as a hard budget DENY —
447
+ // NOT as "ledger unavailable" (which would misreport a budget cap
448
+ // as an outage). This throws out of the budget-section try, runs
449
+ // its finally (releases the budget lock), and propagates without
450
+ // a hold to void — exactly mirroring the policy-deny control flow.
451
+ if (holdErr instanceof InsufficientBalanceError) {
452
+ throw holdErr;
453
+ }
454
+ // Genuine ledger outage — do NOT forward to provider.
289
455
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
290
456
  }
291
457
  }
@@ -297,10 +463,41 @@ export async function trust(client, opts) {
297
463
  // AUD-453: Release lock after budget check + hold are complete
298
464
  releaseBudgetLock();
299
465
  }
300
- // e. Forward to original SDK
466
+ // e0. M2 usage injection (A4): for local openai streams, opt in to the
467
+ // server's final usage chunk (Ollama emits /v1 streaming usage ONLY when
468
+ // stream_options.include_usage is set). The merge preserves the caller's
469
+ // other stream_options fields and respects an explicit include_usage.
470
+ // The resulting usage chunk is FORWARDED to the consumer unmodified
471
+ // (transparent middleware).
472
+ let preInjectionArgs = null;
473
+ if (kind === "openai" && endpoint.class === "local" && config.local.injectUsageOptions) {
474
+ const injected = withInjectedUsageOptions(forwardArgs);
475
+ if (injected != null) {
476
+ preInjectionArgs = forwardArgs;
477
+ forwardArgs = injected;
478
+ }
479
+ }
480
+ // e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
481
+ // redacted clone in redact mode, or the original args otherwise.
301
482
  let settled = true;
302
483
  try {
303
- const response = await originalFn.apply(thisArg, args);
484
+ let response;
485
+ try {
486
+ response = await originalFn.apply(thisArg, forwardArgs);
487
+ }
488
+ catch (callErr) {
489
+ // A4: some OpenAI-compat servers reject unknown stream_options. When
490
+ // WE injected them AND the error plausibly says so (message/HTTP-status
491
+ // heuristic), retry ONCE without the injection; the retried stream
492
+ // simply settles on the estimate if no usage tail arrives (A7). Any
493
+ // other error — transient network failures, unrelated 5xx — rethrows
494
+ // the ORIGINAL immediately rather than duplicating compute and masking
495
+ // the root cause.
496
+ if (preInjectionArgs == null || !looksLikeStreamOptionsRejection(callErr)) {
497
+ throw callErr;
498
+ }
499
+ response = await originalFn.apply(thisArg, preInjectionArgs);
500
+ }
304
501
  // e2. Streaming detection: if response is an async iterable, wrap with
305
502
  // token accumulation. Settlement and audit happen when the stream ends.
306
503
  if (response != null &&
@@ -308,11 +505,13 @@ export async function trust(client, opts) {
308
505
  Symbol.asyncIterator in response) {
309
506
  const stream = response;
310
507
  const governedStream = createGovernedStream(stream, kind, async (completion) => {
311
- // Determine cost: use provider usage if reported, else fall back to estimate
508
+ // Determine cost: use provider usage if reported, else fall back to
509
+ // estimate. A3: priced with the rate resolution captured at
510
+ // authorize; A11: costFromRates floors at 1 even for 0/0 usage.
312
511
  let streamCost;
313
512
  let usageSource;
314
513
  if (completion.usageReported) {
315
- streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens, customRates);
514
+ streamCost = costFromRates(rateResolution.rates, completion.usage.inputTokens, completion.usage.outputTokens);
316
515
  usageSource = "provider";
317
516
  }
318
517
  else {
@@ -366,7 +565,8 @@ export async function trust(client, opts) {
366
565
  }
367
566
  else if (engine != null && !isDryRun) {
368
567
  try {
369
- await engine.postPendingSpend(transferId);
568
+ // Post the ACTUAL consumed cost (RECON #3).
569
+ await engine.postPendingSpend(transferId, streamCost);
370
570
  }
371
571
  catch (postErr) {
372
572
  settled = false;
@@ -398,9 +598,13 @@ export async function trust(client, opts) {
398
598
  transferId,
399
599
  usageSource,
400
600
  chunksDelivered: completion.chunksDelivered,
601
+ // M2: metering provenance mirrors the receipt (A3 authorize-time scope).
602
+ endpointClass: endpoint.class,
603
+ costBasis: rateResolution.costBasis,
604
+ rateSource: rateResolution.rateSource,
401
605
  };
402
606
  if (config.pii === "warn" || config.pii === "redact") {
403
- const piiResult = redactPII(messages);
607
+ const piiResult = redactPII(promptParts);
404
608
  if (piiResult.detection.found) {
405
609
  auditEventData.piiDetected = piiResult.detection.types;
406
610
  auditEventData.piiPaths = piiResult.detection.paths;
@@ -416,6 +620,13 @@ export async function trust(client, opts) {
416
620
  catch {
417
621
  callAuditDegraded = true;
418
622
  }
623
+ // P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
624
+ // so the strongest post-delivery signal is to REJECT `.receipt`.
625
+ // Decrement the stream counter first so destroy() never blocks.
626
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
627
+ inFlightStreamCount--;
628
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
629
+ }
419
630
  const streamReceipt = {
420
631
  transferId,
421
632
  cost: streamCost,
@@ -429,6 +640,12 @@ export async function trust(client, opts) {
429
640
  timestamp: new Date().toISOString(),
430
641
  usageSource,
431
642
  chunksDelivered: completion.chunksDelivered,
643
+ // M2 provenance (A6: computeMs omitted — no compute-time source here).
644
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
645
+ meter: {
646
+ costBasis: rateResolution.costBasis,
647
+ rateSource: rateResolution.rateSource,
648
+ },
432
649
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
433
650
  // AUD-456: Flag proxy stub receipts
434
651
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -482,6 +699,10 @@ export async function trust(client, opts) {
482
699
  deltaTokens: obs.deltaTokens,
483
700
  cumulativeInputTokens: obs.cumulativeInputTokens,
484
701
  cumulativeOutputTokens: obs.cumulativeOutputTokens,
702
+ // M2: stamp the per-call scope so the SHARED detector prices
703
+ // this event with the same scoped rates as settlement.
704
+ model,
705
+ endpointClass: endpoint.class,
485
706
  });
486
707
  const verdict = anomalyDetector.check();
487
708
  if (verdict.tripped) {
@@ -520,6 +741,12 @@ export async function trust(client, opts) {
520
741
  model,
521
742
  provider: kind,
522
743
  timestamp: new Date().toISOString(),
744
+ // M2: authorize-time scope, already fixed for the eventual settle (A3).
745
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
746
+ meter: {
747
+ costBasis: rateResolution.costBasis,
748
+ rateSource: rateResolution.rateSource,
749
+ },
523
750
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
524
751
  // AUD-456: Flag proxy stub receipts
525
752
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -527,8 +754,11 @@ export async function trust(client, opts) {
527
754
  inFlightStreamCount++;
528
755
  return { response: governedStream, receipt: estimatedReceipt };
529
756
  }
530
- // f. Compute actual cost from response usage
757
+ // f. Compute actual cost from response usage. A3: priced with the rate
758
+ // resolution captured at authorize. costFromRates clamps NaN/negative
759
+ // counts (A7) and floors at 1 even for 0/0 provider usage (A11).
531
760
  let actualCost = estimatedCost;
761
+ let usageSource = "estimated";
532
762
  if (response != null && typeof response === "object" && "usage" in response) {
533
763
  const usage = response.usage;
534
764
  if (usage != null) {
@@ -538,13 +768,60 @@ export async function trust(client, opts) {
538
768
  const outputTokens = usage.output_tokens ??
539
769
  usage.completion_tokens ??
540
770
  0;
541
- actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
771
+ actualCost = costFromRates(rateResolution.rates, inputTokens, outputTokens);
772
+ usageSource = "provider";
542
773
  }
543
774
  }
544
- // Release in-flight hold and commit budget under mutex
545
- // AUD-468: Mark hold released before the commit so that any
546
- // throw in the post-commit window (audit/persist/etc.) cannot
547
- // double-decrement inFlightHoldTotal via the outer catch.
775
+ // h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
776
+ // defining event is written BEFORE the irreversible budget commit and
777
+ // POST, so a fail-closed deployment never settles an unaudited spend.
778
+ // The event carries settled:true optimistically; a later POST failure
779
+ // appends the settlement_ambiguous correction and flips receipt.settled.
780
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
781
+ let auditHash = syntheticHash;
782
+ let llmAuditFailed = false;
783
+ try {
784
+ const auditData = {
785
+ model,
786
+ cost: actualCost,
787
+ settled: true,
788
+ transferId,
789
+ usageSource,
790
+ // M2: metering provenance mirrors the receipt (A3 authorize-time scope).
791
+ endpointClass: endpoint.class,
792
+ costBasis: rateResolution.costBasis,
793
+ rateSource: rateResolution.rateSource,
794
+ };
795
+ if (config.pii === "warn" || config.pii === "redact") {
796
+ const piiResult = redactPII(promptParts);
797
+ if (piiResult.detection.found) {
798
+ auditData.piiDetected = piiResult.detection.types;
799
+ auditData.piiPaths = piiResult.detection.paths;
800
+ }
801
+ }
802
+ const auditEvent = await audit.appendEvent({
803
+ kind: "llm_call",
804
+ actor: "local",
805
+ data: auditData,
806
+ });
807
+ auditHash = auditEvent.hash;
808
+ }
809
+ catch {
810
+ // Failure mode 15.3: Audit degraded — mark + warn.
811
+ llmAuditFailed = true;
812
+ callAuditDegraded = true;
813
+ process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
814
+ }
815
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed llm_call audit ABORTS the
816
+ // call before any money moves. Throwing here routes to the outer catch,
817
+ // which VOIDs the hold (once) and never POSTs — the internal ledger holds
818
+ // no unaudited spend, and the caller is told the call failed.
819
+ if (config.audit.failClosed && llmAuditFailed) {
820
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
821
+ }
822
+ // Release in-flight hold and commit budget under mutex — money moves only
823
+ // AFTER the spend is audited. AUD-468: mark hold released before the commit
824
+ // so a later throw cannot double-decrement inFlightHoldTotal via the catch.
548
825
  if (holdActive) {
549
826
  holdActive = false;
550
827
  const releaseLock = await budgetMutex.acquire();
@@ -559,7 +836,8 @@ export async function trust(client, opts) {
559
836
  // g2. Failure mode 15.1: POST fails after LLM success
560
837
  if (engine != null && !isDryRun) {
561
838
  try {
562
- await engine.postPendingSpend(transferId);
839
+ // Post the ACTUAL consumed cost (RECON #3).
840
+ await engine.postPendingSpend(transferId, actualCost);
563
841
  }
564
842
  catch (postErr) {
565
843
  // POST failed — LLM call succeeded but settlement is ambiguous
@@ -607,34 +885,11 @@ export async function trust(client, opts) {
607
885
  });
608
886
  }
609
887
  }
610
- // h. Audit event failure mode 15.3: audit write failure
611
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
612
- let auditHash = syntheticHash;
613
- try {
614
- const auditData = {
615
- model,
616
- cost: actualCost,
617
- settled,
618
- transferId,
619
- };
620
- if (config.pii === "warn" || config.pii === "redact") {
621
- const piiResult = redactPII(messages);
622
- if (piiResult.detection.found) {
623
- auditData.piiDetected = piiResult.detection.types;
624
- auditData.piiPaths = piiResult.detection.paths;
625
- }
626
- }
627
- const auditEvent = await audit.appendEvent({
628
- kind: "llm_call",
629
- actor: "local",
630
- data: auditData,
631
- });
632
- auditHash = auditEvent.hash;
633
- }
634
- catch {
635
- // Failure mode 15.3: Audit degraded — do not fail the response
636
- callAuditDegraded = true;
637
- process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
888
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: if ANY audit write during this
889
+ // call degraded the writer (including best-effort advisory writes), refuse
890
+ // to report success under failClosed so the caller cannot silently proceed.
891
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
892
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
638
893
  }
639
894
  // i. Daily-rotated audit receipt (non-blocking)
640
895
  if (config.audit.rotation !== "none") {
@@ -647,7 +902,7 @@ export async function trust(client, opts) {
647
902
  }
648
903
  // i2. Pattern memory
649
904
  if (config.patterns.enabled) {
650
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
905
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
651
906
  await recordPattern({
652
907
  promptHash,
653
908
  model,
@@ -667,6 +922,13 @@ export async function trust(client, opts) {
667
922
  model,
668
923
  provider: kind,
669
924
  timestamp: new Date().toISOString(),
925
+ usageSource,
926
+ // M2 provenance (A6: computeMs omitted — no compute-time source here).
927
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
928
+ meter: {
929
+ costBasis: rateResolution.costBasis,
930
+ rateSource: rateResolution.rateSource,
931
+ },
670
932
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
671
933
  // AUD-456: Flag proxy stub receipts
672
934
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -718,7 +980,7 @@ export async function trust(client, opts) {
718
980
  });
719
981
  // l. Pattern memory: record failure
720
982
  if (config.patterns.enabled) {
721
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
983
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
722
984
  await recordPattern({
723
985
  promptHash,
724
986
  model,
@@ -756,13 +1018,17 @@ export async function trust(client, opts) {
756
1018
  let proxyTransferId;
757
1019
  try {
758
1020
  // c. Policy gate — action fields available in context
759
- // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed
1021
+ // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed.
1022
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
1023
+ // block-budget-overshoot default rule compares against zero (a HARD rule
1024
+ // that fails CLOSED if the governor omits it — so it MUST be supplied).
760
1025
  const policyResult = evaluatePolicy(policyRules, {
761
1026
  ...(action.params ?? {}),
762
1027
  action_kind: action.kind,
763
1028
  action_name: action.name,
764
1029
  estimated_cost: action.cost,
765
1030
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
1031
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
766
1032
  tier: config.tier,
767
1033
  });
768
1034
  if (policyResult.decision === "deny") {
@@ -820,6 +1086,11 @@ export async function trust(client, opts) {
820
1086
  });
821
1087
  }
822
1088
  catch (holdErr) {
1089
+ // P1-LEDGER-ENFORCE: over-budget reservation → hard budget DENY,
1090
+ // not a ledger-outage misreport.
1091
+ if (holdErr instanceof InsufficientBalanceError) {
1092
+ throw holdErr;
1093
+ }
823
1094
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
824
1095
  }
825
1096
  }
@@ -844,7 +1115,52 @@ export async function trust(client, opts) {
844
1115
  }
845
1116
  try {
846
1117
  const result = await execute();
847
- // Release in-flight hold and commit budget under mutex
1118
+ // i. Prepare params for audit redact PII if configured.
1119
+ let auditParams;
1120
+ if (action.params != null) {
1121
+ if (config.pii === "warn" || config.pii === "redact") {
1122
+ const redacted = redactPII(action.params);
1123
+ auditParams = redacted.data;
1124
+ }
1125
+ else {
1126
+ auditParams = action.params;
1127
+ }
1128
+ }
1129
+ // i2. Audit the action FIRST (P3-AUDIT-FAILCLOSED). The settlement-
1130
+ // defining event precedes the budget commit + POST, so a fail-closed
1131
+ // deployment never settles an unaudited action. settled:true is
1132
+ // optimistic; a later POST failure appends settlement_ambiguous.
1133
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
1134
+ let auditHash = syntheticHash;
1135
+ let actionAuditFailed = false;
1136
+ try {
1137
+ const auditEvent = await audit.appendEvent({
1138
+ kind: action.kind,
1139
+ actor,
1140
+ data: {
1141
+ actionName: action.name,
1142
+ cost: action.cost,
1143
+ settled: true,
1144
+ transferId,
1145
+ ...(auditParams != null ? { params: auditParams } : {}),
1146
+ },
1147
+ });
1148
+ auditHash = auditEvent.hash;
1149
+ }
1150
+ catch {
1151
+ // Failure mode 15.3: Audit degraded — mark + warn.
1152
+ actionAuditFailed = true;
1153
+ callAuditDegraded = true;
1154
+ process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
1155
+ }
1156
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed action audit ABORTS the
1157
+ // call before any money moves — the outer catch VOIDs the hold (once) and
1158
+ // never POSTs.
1159
+ if (config.audit.failClosed && actionAuditFailed) {
1160
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
1161
+ }
1162
+ // Release in-flight hold and commit budget under mutex — money moves only
1163
+ // AFTER the action is audited.
848
1164
  await releaseHoldAndCommit(action.cost);
849
1165
  await persistSpendLedger(vaultBase, budgetSpent);
850
1166
  // g. Circuit breaker: record success
@@ -853,7 +1169,8 @@ export async function trust(client, opts) {
853
1169
  let settled = true;
854
1170
  if (engine != null && !isDryRun) {
855
1171
  try {
856
- await engine.postPendingSpend(transferId);
1172
+ // Post the ACTUAL cost (RECON #3).
1173
+ await engine.postPendingSpend(transferId, action.cost);
857
1174
  }
858
1175
  catch (postErr) {
859
1176
  settled = false;
@@ -901,38 +1218,10 @@ export async function trust(client, opts) {
901
1218
  });
902
1219
  }
903
1220
  }
904
- // i. Prepare params for audit redact PII if configured
905
- let auditParams;
906
- if (action.params != null) {
907
- if (config.pii === "warn" || config.pii === "redact") {
908
- const redacted = redactPII(action.params);
909
- auditParams = redacted.data;
910
- }
911
- else {
912
- auditParams = action.params;
913
- }
914
- }
915
- // i2. Audit event
916
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
917
- let auditHash = syntheticHash;
918
- try {
919
- const auditEvent = await audit.appendEvent({
920
- kind: action.kind,
921
- actor,
922
- data: {
923
- actionName: action.name,
924
- cost: action.cost,
925
- settled,
926
- transferId,
927
- ...(auditParams != null ? { params: auditParams } : {}),
928
- },
929
- });
930
- auditHash = auditEvent.hash;
931
- }
932
- catch {
933
- // Failure mode 15.3: Audit degraded — do not fail the response
934
- callAuditDegraded = true;
935
- process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
1221
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: refuse to report success if any
1222
+ // audit write during this call degraded the writer under failClosed.
1223
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
1224
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
936
1225
  }
937
1226
  // j. Daily-rotated receipt
938
1227
  if (config.audit.rotation !== "none") {
@@ -1071,44 +1360,103 @@ export async function trust(client, opts) {
1071
1360
  process.on("beforeExit", beforeExitHandler);
1072
1361
  return governedClient;
1073
1362
  }
1363
+ // ── Provider-aware prompt extraction (P3-PROVIDER-BLINDSPOT) ──
1364
+ /**
1365
+ * Normalize the prompt-bearing payload across providers so PII/injection scanning
1366
+ * and token estimation cover ALL shapes, not just Anthropic/OpenAI `messages`:
1367
+ * - Anthropic/OpenAI: `params.messages` (+ Anthropic top-level `system` string)
1368
+ * - Google: `params.contents` (the prompt lives here, not `messages`)
1369
+ *
1370
+ * Without this, a Google `generateContent({ model, contents })` call has an empty
1371
+ * `messages` array, so every PII/injection scan sees nothing and PII egresses.
1372
+ */
1373
+ function extractPromptParts(params, kind) {
1374
+ if (kind === "google") {
1375
+ const contents = params.contents;
1376
+ if (Array.isArray(contents))
1377
+ return contents;
1378
+ return contents != null ? [contents] : [];
1379
+ }
1380
+ const messages = params.messages;
1381
+ const parts = Array.isArray(messages) ? [...messages] : [];
1382
+ if (typeof params.system === "string") {
1383
+ parts.push({ role: "system", content: params.system });
1384
+ }
1385
+ return parts;
1386
+ }
1074
1387
  // ── TigerBeetle engine factory ──
1388
+ /** TigerBeetle codes that mean "this debit would exceed the account's credits". */
1389
+ function isTBInsufficientBalance(err) {
1390
+ if (!(err instanceof TBTransferError))
1391
+ return false;
1392
+ return (err.code === CreateTransferError.exceeds_credits ||
1393
+ err.code === CreateTransferError.overflows_debits ||
1394
+ err.code === CreateTransferError.overflows_debits_pending);
1395
+ }
1075
1396
  /**
1076
- * Create a TrustEngine backed by a real TigerBeetle client.
1077
- * Uses a simplified two-phase interface: pending transfers are created
1078
- * directly against the TB client using escrow-style debit/credit accounts.
1397
+ * Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
1398
+ *
1399
+ * P1-LEDGER-ENFORCE: the holding account is created with
1400
+ * `debits_must_not_exceed_credits` and FUNDED with `seedBudget` usertokens, so a
1401
+ * pending debit (hold) whose cumulative amount would exceed the remaining budget
1402
+ * is REJECTED atomically by TigerBeetle. That rejection is surfaced as an
1403
+ * {@link InsufficientBalanceError}, which the governor re-throws as a hard budget
1404
+ * DENY (never as a ledger outage). The prior escrow account had no enforcing flag
1405
+ * and no funding, so `spendPending` could never reject an over-budget hold.
1406
+ *
1407
+ * NOTE (cross-domain): RECON #3 designates `createLedgerEngine` /
1408
+ * `createFundedBudgetWallet` (LEDGER-owned, in `ledger/engine.ts`) as the eventual
1409
+ * home for this factory. Those symbols do not yet exist on disk, so this
1410
+ * GOVERN-local factory implements the same funded-enforcing contract using the
1411
+ * existing `TrustTBClient` primitives. When LEDGER ships `createLedgerEngine`,
1412
+ * this factory is a drop-in replacement (identical `TrustEngine` shape).
1079
1413
  */
1080
- async function createTBEngine(config) {
1414
+ async function createTBEngine(config, seedBudget) {
1081
1415
  const tbAddresses = config.tigerbeetle.addresses;
1082
1416
  const tbClusterId = BigInt(config.tigerbeetle.clusterId);
1083
1417
  const tbClient = new TrustTBClient({
1084
1418
  addresses: tbAddresses,
1085
1419
  clusterId: tbClusterId,
1086
1420
  });
1087
- // Initialize treasury and escrow accounts
1421
+ // Treasury (unconstrained) funds a per-session enforcing holding wallet.
1088
1422
  await tbClient.createTreasury();
1089
- await tbClient.ensureEscrowAccount("trust:escrow");
1423
+ const treasury = tbClient.getTreasuryId();
1424
+ // Enforcing holding wallet (debits_must_not_exceed_credits), funded with the
1425
+ // remaining session budget so cumulative pending debits cannot exceed it.
1426
+ // A FRESH account id per session prevents double-funding a deterministic
1427
+ // account across restarts (which would inflate the TB-enforced budget).
1428
+ const holdingId = await tbClient.createFundedBudgetWallet(seedBudget);
1090
1429
  // Pending transfer ID mapping (trustId string -> TB bigint)
1091
1430
  const pendingMap = new Map();
1092
1431
  return {
1093
1432
  async spendPending(params) {
1094
- const treasury = tbClient.getTreasuryId();
1095
- // Use a deterministic escrow account for SDK-local holds
1096
- const escrowId = TrustTBClient.deriveAccountId("trust:escrow");
1097
- const tbTransferId = await tbClient.createPendingTransfer({
1098
- debitAccountId: escrowId,
1099
- creditAccountId: treasury,
1100
- amount: params.amount,
1101
- code: XFER_SPEND,
1102
- });
1103
- pendingMap.set(params.transferId, tbTransferId);
1104
- return { transferId: params.transferId };
1433
+ try {
1434
+ const tbTransferId = await tbClient.createPendingTransfer({
1435
+ debitAccountId: holdingId,
1436
+ creditAccountId: treasury,
1437
+ amount: params.amount,
1438
+ code: XFER_SPEND,
1439
+ });
1440
+ pendingMap.set(params.transferId, tbTransferId);
1441
+ return { transferId: params.transferId };
1442
+ }
1443
+ catch (err) {
1444
+ // Over-budget reservation → TB rejects the pending debit. Surface as a
1445
+ // budget error so the governor reports a hard DENY, not an outage.
1446
+ if (isTBInsufficientBalance(err)) {
1447
+ throw new InsufficientBalanceError("trust:hold", params.amount, seedBudget);
1448
+ }
1449
+ throw err;
1450
+ }
1105
1451
  },
1106
- async postPendingSpend(transferId) {
1452
+ async postPendingSpend(transferId, actualAmount) {
1107
1453
  const tbId = pendingMap.get(transferId);
1108
1454
  if (tbId === undefined) {
1109
1455
  throw new Error(`No pending transfer found for ${transferId}`);
1110
1456
  }
1111
- await tbClient.postTransfer(tbId);
1457
+ // Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
1458
+ // posts the full pending amount.
1459
+ await tbClient.postTransfer(tbId, actualAmount);
1112
1460
  pendingMap.delete(transferId);
1113
1461
  },
1114
1462
  async voidPendingSpend(transferId) {