usertrust 1.3.0 → 1.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 (69) hide show
  1. package/README.md +9 -9
  2. package/dist/audit/chain.d.ts +9 -0
  3. package/dist/audit/chain.d.ts.map +1 -1
  4. package/dist/audit/chain.js +65 -9
  5. package/dist/audit/chain.js.map +1 -1
  6. package/dist/audit/verify.d.ts +19 -0
  7. package/dist/audit/verify.d.ts.map +1 -1
  8. package/dist/audit/verify.js +216 -14
  9. package/dist/audit/verify.js.map +1 -1
  10. package/dist/cli/init.d.ts.map +1 -1
  11. package/dist/cli/init.js +9 -1
  12. package/dist/cli/init.js.map +1 -1
  13. package/dist/cli/skill.d.ts.map +1 -1
  14. package/dist/cli/skill.js +35 -5
  15. package/dist/cli/skill.js.map +1 -1
  16. package/dist/cli/snapshot.d.ts.map +1 -1
  17. package/dist/cli/snapshot.js +20 -2
  18. package/dist/cli/snapshot.js.map +1 -1
  19. package/dist/cli/verify.d.ts.map +1 -1
  20. package/dist/cli/verify.js +20 -11
  21. package/dist/cli/verify.js.map +1 -1
  22. package/dist/govern.d.ts +5 -1
  23. package/dist/govern.d.ts.map +1 -1
  24. package/dist/govern.js +285 -116
  25. package/dist/govern.js.map +1 -1
  26. package/dist/headless.d.ts.map +1 -1
  27. package/dist/headless.js +149 -32
  28. package/dist/headless.js.map +1 -1
  29. package/dist/ledger/client.d.ts +14 -0
  30. package/dist/ledger/client.d.ts.map +1 -1
  31. package/dist/ledger/client.js +56 -0
  32. package/dist/ledger/client.js.map +1 -1
  33. package/dist/ledger/pricing.d.ts.map +1 -1
  34. package/dist/ledger/pricing.js +8 -2
  35. package/dist/ledger/pricing.js.map +1 -1
  36. package/dist/policy/default-rules.d.ts +18 -3
  37. package/dist/policy/default-rules.d.ts.map +1 -1
  38. package/dist/policy/default-rules.js +33 -15
  39. package/dist/policy/default-rules.js.map +1 -1
  40. package/dist/policy/gate.d.ts.map +1 -1
  41. package/dist/policy/gate.js +114 -16
  42. package/dist/policy/gate.js.map +1 -1
  43. package/dist/policy/injection.d.ts.map +1 -1
  44. package/dist/policy/injection.js +25 -16
  45. package/dist/policy/injection.js.map +1 -1
  46. package/dist/policy/pii.d.ts +20 -0
  47. package/dist/policy/pii.d.ts.map +1 -1
  48. package/dist/policy/pii.js +45 -2
  49. package/dist/policy/pii.js.map +1 -1
  50. package/dist/shared/types.d.ts +16 -0
  51. package/dist/shared/types.d.ts.map +1 -1
  52. package/dist/shared/types.js +12 -1
  53. package/dist/shared/types.js.map +1 -1
  54. package/dist/snapshot/checkpoint.d.ts +18 -2
  55. package/dist/snapshot/checkpoint.d.ts.map +1 -1
  56. package/dist/snapshot/checkpoint.js +101 -17
  57. package/dist/snapshot/checkpoint.js.map +1 -1
  58. package/dist/streaming.d.ts.map +1 -1
  59. package/dist/streaming.js +14 -1
  60. package/dist/streaming.js.map +1 -1
  61. package/dist/supply-chain/permissions.d.ts +16 -3
  62. package/dist/supply-chain/permissions.d.ts.map +1 -1
  63. package/dist/supply-chain/permissions.js +59 -9
  64. package/dist/supply-chain/permissions.js.map +1 -1
  65. package/dist/supply-chain/sign.d.ts +5 -2
  66. package/dist/supply-chain/sign.d.ts.map +1 -1
  67. package/dist/supply-chain/sign.js +10 -2
  68. package/dist/supply-chain/sign.js.map +1 -1
  69. 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
36
  import { detectClientKind } from "./detect.js";
36
- import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
+ import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
38
  import { estimateCost, estimateInputTokens } 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";
@@ -88,22 +89,36 @@ async function loadSpendLedger(vaultBase) {
88
89
  async function persistSpendLedger(vaultBase, budgetSpent) {
89
90
  const dir = join(vaultBase, VAULT_DIR);
90
91
  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
- };
92
+ // AUD-457 hardening (RECON #4): UNIQUE tmp path per write. A fixed
93
+ // `spend-ledger.json.tmp` lets two concurrent writers clobber each other's
94
+ // staging file, so a half-written record can be renamed into place. A pid +
95
+ // uuid suffix isolates every writer's staging file.
96
+ const tmpPath = join(dir, `spend-ledger.json.${process.pid}.${randomUUID()}.tmp`);
96
97
  try {
97
98
  // Ensure vault dir exists
98
99
  if (!existsSync(dir)) {
99
100
  mkdirSync(dir, { recursive: true });
100
101
  }
101
- // Atomic write: write tmp then rename
102
+ // MONOTONIC guard (RECON #4): cumulative spend must never regress on disk.
103
+ // A stale/racing writer carrying a lower budgetSpent must not "un-spend"
104
+ // money that another writer already recorded. Skip the write if the
105
+ // persisted value is already >= ours.
106
+ const existing = await loadSpendLedger(vaultBase);
107
+ if (existing > budgetSpent) {
108
+ return;
109
+ }
110
+ const data = {
111
+ budgetSpent,
112
+ updatedAt: new Date().toISOString(),
113
+ };
114
+ // Atomic write: write UNIQUE tmp then rename over the target.
102
115
  await writeFile(tmpPath, JSON.stringify(data), "utf-8");
103
116
  await rename(tmpPath, ledgerPath);
104
117
  }
105
118
  catch {
106
- // Best-effort — do not fail the LLM call over ledger persistence
119
+ // Best-effort — do not fail the LLM call over ledger persistence. Clean up
120
+ // our unique staging file if the rename never happened.
121
+ await unlink(tmpPath).catch(() => { });
107
122
  }
108
123
  }
109
124
  // ── trust() ──
@@ -134,7 +149,12 @@ export async function trust(client, opts) {
134
149
  const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
135
150
  const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
136
151
  const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
137
- const policyRules = loadedRules.length > 0 ? loadedRules : DEFAULT_RULES;
152
+ // P1-CUSTOM-POLICY-REPLACES (RECON #2): platform DEFAULT_RULES are ALWAYS
153
+ // enforced. mergePolicies is a safe concat — a custom policy file can only ADD
154
+ // deny/warn rules, never remove the budget/overshoot/exhausted guarantees. The
155
+ // gate is deny-wins with no "allow" effect, so appended user rules cannot weaken
156
+ // a default deny.
157
+ const policyRules = mergePolicies(DEFAULT_RULES, loadedRules);
138
158
  const breaker = new CircuitBreakerRegistry({
139
159
  failureThreshold: config.circuitBreaker.failureThreshold,
140
160
  resetTimeoutMs: config.circuitBreaker.resetTimeout,
@@ -147,6 +167,9 @@ export async function trust(client, opts) {
147
167
  // AUD-456: proxyConn is always null now — proxy mode throws above.
148
168
  // Cast keeps dead code paths type-safe for future re-enablement.
149
169
  const proxyConn = null;
170
+ // AUD-457: restore cumulative spend from disk BEFORE building the engine so the
171
+ // enforcing holding account can be seeded with the REMAINING budget.
172
+ let budgetSpent = await loadSpendLedger(vaultBase);
150
173
  // 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
151
174
  // AUD-470: _engine injection only accepted in test environments
152
175
  let engine;
@@ -155,7 +178,9 @@ export async function trust(client, opts) {
155
178
  }
156
179
  else if (!isDryRun && proxyConn == null) {
157
180
  try {
158
- engine = await createTBEngine(config);
181
+ // P1-LEDGER-ENFORCE (RECON #3): seed the enforcing holding account with the
182
+ // remaining budget so TigerBeetle atomically REJECTS an over-budget hold.
183
+ engine = await createTBEngine(config, Math.max(0, config.budget - budgetSpent));
159
184
  }
160
185
  catch (err) {
161
186
  throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
@@ -168,7 +193,6 @@ export async function trust(client, opts) {
168
193
  const kind = detectClientKind(client);
169
194
  // 6. Track state
170
195
  let destroyed = false;
171
- let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
172
196
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
173
197
  let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
174
198
  let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
@@ -186,15 +210,23 @@ export async function trust(client, opts) {
186
210
  try {
187
211
  const params = (args[0] ?? {});
188
212
  const model = params.model ?? "unknown";
189
- const messages = params.messages ?? [];
213
+ // P3-PROVIDER-BLINDSPOT: normalize the prompt-bearing payload across
214
+ // providers (Anthropic/OpenAI `messages` + `system`, Google `contents`) so
215
+ // PII/injection scanning, token estimation, redaction, and pattern hashing
216
+ // all see the actual prompt — not an empty `messages` array on Google calls.
217
+ const promptParts = extractPromptParts(params, kind);
190
218
  // Per-call audit degradation flag (not sticky across calls)
191
219
  let callAuditDegraded = false;
220
+ // P3-PII-REDACT-EGRESS: `forwardArgs` is what we actually send to the
221
+ // provider. In redact mode it becomes a redacted deep clone so PII never
222
+ // egresses; block mode throws before any egress. Default: forward verbatim.
223
+ let forwardArgs = args;
192
224
  // a. Circuit breaker check
193
225
  const cb = breaker.get(kind);
194
226
  cb.allowRequest();
195
227
  // b. Estimate cost (before policy, so cost fields are available in context)
196
228
  const transferId = trustId("tx");
197
- const estimatedInputTokens = estimateInputTokens(messages);
229
+ const estimatedInputTokens = estimateInputTokens(promptParts);
198
230
  const maxOutputTokens = params.max_tokens ?? 4096;
199
231
  const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
200
232
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
@@ -217,28 +249,42 @@ export async function trust(client, opts) {
217
249
  }
218
250
  try {
219
251
  // c. Policy gate
252
+ // P1-PARAM-SHADOW: caller `params` are spread FIRST so trusted
253
+ // governance fields (tier/estimated_cost/budget_remaining/
254
+ // budget_remaining_after) CANNOT be shadowed by request-supplied keys.
255
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
256
+ // single-field gate compares against zero to deny a single overshooting
257
+ // call (the `block-budget-overshoot` default rule).
220
258
  const policyResult = evaluatePolicy(policyRules, {
259
+ ...params,
221
260
  model,
222
261
  tier: config.tier,
223
262
  estimated_cost: estimatedCost,
224
263
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
225
- ...params,
264
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
226
265
  });
227
266
  if (policyResult.decision === "deny") {
228
267
  const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
229
268
  throw new PolicyDeniedError(reason);
230
269
  }
231
- // d. PII check
270
+ // d. PII check + redact-egress
232
271
  if (config.pii !== "off") {
233
- const piiResult = detectPII(messages);
272
+ const piiResult = detectPII(promptParts);
234
273
  if (piiResult.found && config.pii === "block") {
274
+ // block mode: throw BEFORE any egress.
235
275
  throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
236
276
  }
237
- // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
277
+ if (config.pii === "redact" && piiResult.found) {
278
+ // redact mode: forward a redacted DEEP CLONE so PII never egresses.
279
+ // redactPII is pure — the caller's original object is never mutated.
280
+ const redactedBody = redactPII(params).data;
281
+ forwardArgs = [redactedBody, ...args.slice(1)];
282
+ }
283
+ // "warn" mode: continue, no transform (audit copy is redacted later).
238
284
  }
239
285
  // d2. Injection detection
240
286
  if (config.injection !== "off") {
241
- const injectionResult = detectInjection(messages);
287
+ const injectionResult = detectInjection(promptParts);
242
288
  if (injectionResult.detected) {
243
289
  if (config.injection === "block") {
244
290
  throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
@@ -285,7 +331,16 @@ export async function trust(client, opts) {
285
331
  });
286
332
  }
287
333
  catch (holdErr) {
288
- // Ledger unreachable do NOT forward to provider
334
+ // P1-LEDGER-ENFORCE: an over-budget reservation is rejected
335
+ // atomically by the ledger. Surface it as a hard budget DENY —
336
+ // NOT as "ledger unavailable" (which would misreport a budget cap
337
+ // as an outage). This throws out of the budget-section try, runs
338
+ // its finally (releases the budget lock), and propagates without
339
+ // a hold to void — exactly mirroring the policy-deny control flow.
340
+ if (holdErr instanceof InsufficientBalanceError) {
341
+ throw holdErr;
342
+ }
343
+ // Genuine ledger outage — do NOT forward to provider.
289
344
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
290
345
  }
291
346
  }
@@ -297,10 +352,11 @@ export async function trust(client, opts) {
297
352
  // AUD-453: Release lock after budget check + hold are complete
298
353
  releaseBudgetLock();
299
354
  }
300
- // e. Forward to original SDK
355
+ // e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
356
+ // redacted clone in redact mode, or the original args otherwise.
301
357
  let settled = true;
302
358
  try {
303
- const response = await originalFn.apply(thisArg, args);
359
+ const response = await originalFn.apply(thisArg, forwardArgs);
304
360
  // e2. Streaming detection: if response is an async iterable, wrap with
305
361
  // token accumulation. Settlement and audit happen when the stream ends.
306
362
  if (response != null &&
@@ -366,7 +422,8 @@ export async function trust(client, opts) {
366
422
  }
367
423
  else if (engine != null && !isDryRun) {
368
424
  try {
369
- await engine.postPendingSpend(transferId);
425
+ // Post the ACTUAL consumed cost (RECON #3).
426
+ await engine.postPendingSpend(transferId, streamCost);
370
427
  }
371
428
  catch (postErr) {
372
429
  settled = false;
@@ -400,7 +457,7 @@ export async function trust(client, opts) {
400
457
  chunksDelivered: completion.chunksDelivered,
401
458
  };
402
459
  if (config.pii === "warn" || config.pii === "redact") {
403
- const piiResult = redactPII(messages);
460
+ const piiResult = redactPII(promptParts);
404
461
  if (piiResult.detection.found) {
405
462
  auditEventData.piiDetected = piiResult.detection.types;
406
463
  auditEventData.piiPaths = piiResult.detection.paths;
@@ -416,6 +473,13 @@ export async function trust(client, opts) {
416
473
  catch {
417
474
  callAuditDegraded = true;
418
475
  }
476
+ // P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
477
+ // so the strongest post-delivery signal is to REJECT `.receipt`.
478
+ // Decrement the stream counter first so destroy() never blocks.
479
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
480
+ inFlightStreamCount--;
481
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
482
+ }
419
483
  const streamReceipt = {
420
484
  transferId,
421
485
  cost: streamCost,
@@ -541,10 +605,51 @@ export async function trust(client, opts) {
541
605
  actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
542
606
  }
543
607
  }
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.
608
+ // h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
609
+ // defining event is written BEFORE the irreversible budget commit and
610
+ // POST, so a fail-closed deployment never settles an unaudited spend.
611
+ // The event carries settled:true optimistically; a later POST failure
612
+ // appends the settlement_ambiguous correction and flips receipt.settled.
613
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
614
+ let auditHash = syntheticHash;
615
+ let llmAuditFailed = false;
616
+ try {
617
+ const auditData = {
618
+ model,
619
+ cost: actualCost,
620
+ settled: true,
621
+ transferId,
622
+ };
623
+ if (config.pii === "warn" || config.pii === "redact") {
624
+ const piiResult = redactPII(promptParts);
625
+ if (piiResult.detection.found) {
626
+ auditData.piiDetected = piiResult.detection.types;
627
+ auditData.piiPaths = piiResult.detection.paths;
628
+ }
629
+ }
630
+ const auditEvent = await audit.appendEvent({
631
+ kind: "llm_call",
632
+ actor: "local",
633
+ data: auditData,
634
+ });
635
+ auditHash = auditEvent.hash;
636
+ }
637
+ catch {
638
+ // Failure mode 15.3: Audit degraded — mark + warn.
639
+ llmAuditFailed = true;
640
+ callAuditDegraded = true;
641
+ process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
642
+ }
643
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed llm_call audit ABORTS the
644
+ // call before any money moves. Throwing here routes to the outer catch,
645
+ // which VOIDs the hold (once) and never POSTs — the internal ledger holds
646
+ // no unaudited spend, and the caller is told the call failed.
647
+ if (config.audit.failClosed && llmAuditFailed) {
648
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
649
+ }
650
+ // Release in-flight hold and commit budget under mutex — money moves only
651
+ // AFTER the spend is audited. AUD-468: mark hold released before the commit
652
+ // so a later throw cannot double-decrement inFlightHoldTotal via the catch.
548
653
  if (holdActive) {
549
654
  holdActive = false;
550
655
  const releaseLock = await budgetMutex.acquire();
@@ -559,7 +664,8 @@ export async function trust(client, opts) {
559
664
  // g2. Failure mode 15.1: POST fails after LLM success
560
665
  if (engine != null && !isDryRun) {
561
666
  try {
562
- await engine.postPendingSpend(transferId);
667
+ // Post the ACTUAL consumed cost (RECON #3).
668
+ await engine.postPendingSpend(transferId, actualCost);
563
669
  }
564
670
  catch (postErr) {
565
671
  // POST failed — LLM call succeeded but settlement is ambiguous
@@ -607,34 +713,11 @@ export async function trust(client, opts) {
607
713
  });
608
714
  }
609
715
  }
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`);
716
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: if ANY audit write during this
717
+ // call degraded the writer (including best-effort advisory writes), refuse
718
+ // to report success under failClosed so the caller cannot silently proceed.
719
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
720
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
638
721
  }
639
722
  // i. Daily-rotated audit receipt (non-blocking)
640
723
  if (config.audit.rotation !== "none") {
@@ -647,7 +730,7 @@ export async function trust(client, opts) {
647
730
  }
648
731
  // i2. Pattern memory
649
732
  if (config.patterns.enabled) {
650
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
733
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
651
734
  await recordPattern({
652
735
  promptHash,
653
736
  model,
@@ -718,7 +801,7 @@ export async function trust(client, opts) {
718
801
  });
719
802
  // l. Pattern memory: record failure
720
803
  if (config.patterns.enabled) {
721
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
804
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
722
805
  await recordPattern({
723
806
  promptHash,
724
807
  model,
@@ -756,13 +839,17 @@ export async function trust(client, opts) {
756
839
  let proxyTransferId;
757
840
  try {
758
841
  // c. Policy gate — action fields available in context
759
- // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed
842
+ // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed.
843
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
844
+ // block-budget-overshoot default rule compares against zero (a HARD rule
845
+ // that fails CLOSED if the governor omits it — so it MUST be supplied).
760
846
  const policyResult = evaluatePolicy(policyRules, {
761
847
  ...(action.params ?? {}),
762
848
  action_kind: action.kind,
763
849
  action_name: action.name,
764
850
  estimated_cost: action.cost,
765
851
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
852
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
766
853
  tier: config.tier,
767
854
  });
768
855
  if (policyResult.decision === "deny") {
@@ -820,6 +907,11 @@ export async function trust(client, opts) {
820
907
  });
821
908
  }
822
909
  catch (holdErr) {
910
+ // P1-LEDGER-ENFORCE: over-budget reservation → hard budget DENY,
911
+ // not a ledger-outage misreport.
912
+ if (holdErr instanceof InsufficientBalanceError) {
913
+ throw holdErr;
914
+ }
823
915
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
824
916
  }
825
917
  }
@@ -844,7 +936,52 @@ export async function trust(client, opts) {
844
936
  }
845
937
  try {
846
938
  const result = await execute();
847
- // Release in-flight hold and commit budget under mutex
939
+ // i. Prepare params for audit redact PII if configured.
940
+ let auditParams;
941
+ if (action.params != null) {
942
+ if (config.pii === "warn" || config.pii === "redact") {
943
+ const redacted = redactPII(action.params);
944
+ auditParams = redacted.data;
945
+ }
946
+ else {
947
+ auditParams = action.params;
948
+ }
949
+ }
950
+ // i2. Audit the action FIRST (P3-AUDIT-FAILCLOSED). The settlement-
951
+ // defining event precedes the budget commit + POST, so a fail-closed
952
+ // deployment never settles an unaudited action. settled:true is
953
+ // optimistic; a later POST failure appends settlement_ambiguous.
954
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
955
+ let auditHash = syntheticHash;
956
+ let actionAuditFailed = false;
957
+ try {
958
+ const auditEvent = await audit.appendEvent({
959
+ kind: action.kind,
960
+ actor,
961
+ data: {
962
+ actionName: action.name,
963
+ cost: action.cost,
964
+ settled: true,
965
+ transferId,
966
+ ...(auditParams != null ? { params: auditParams } : {}),
967
+ },
968
+ });
969
+ auditHash = auditEvent.hash;
970
+ }
971
+ catch {
972
+ // Failure mode 15.3: Audit degraded — mark + warn.
973
+ actionAuditFailed = true;
974
+ callAuditDegraded = true;
975
+ process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
976
+ }
977
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed action audit ABORTS the
978
+ // call before any money moves — the outer catch VOIDs the hold (once) and
979
+ // never POSTs.
980
+ if (config.audit.failClosed && actionAuditFailed) {
981
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
982
+ }
983
+ // Release in-flight hold and commit budget under mutex — money moves only
984
+ // AFTER the action is audited.
848
985
  await releaseHoldAndCommit(action.cost);
849
986
  await persistSpendLedger(vaultBase, budgetSpent);
850
987
  // g. Circuit breaker: record success
@@ -853,7 +990,8 @@ export async function trust(client, opts) {
853
990
  let settled = true;
854
991
  if (engine != null && !isDryRun) {
855
992
  try {
856
- await engine.postPendingSpend(transferId);
993
+ // Post the ACTUAL cost (RECON #3).
994
+ await engine.postPendingSpend(transferId, action.cost);
857
995
  }
858
996
  catch (postErr) {
859
997
  settled = false;
@@ -901,38 +1039,10 @@ export async function trust(client, opts) {
901
1039
  });
902
1040
  }
903
1041
  }
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`);
1042
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: refuse to report success if any
1043
+ // audit write during this call degraded the writer under failClosed.
1044
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
1045
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
936
1046
  }
937
1047
  // j. Daily-rotated receipt
938
1048
  if (config.audit.rotation !== "none") {
@@ -1071,44 +1181,103 @@ export async function trust(client, opts) {
1071
1181
  process.on("beforeExit", beforeExitHandler);
1072
1182
  return governedClient;
1073
1183
  }
1184
+ // ── Provider-aware prompt extraction (P3-PROVIDER-BLINDSPOT) ──
1185
+ /**
1186
+ * Normalize the prompt-bearing payload across providers so PII/injection scanning
1187
+ * and token estimation cover ALL shapes, not just Anthropic/OpenAI `messages`:
1188
+ * - Anthropic/OpenAI: `params.messages` (+ Anthropic top-level `system` string)
1189
+ * - Google: `params.contents` (the prompt lives here, not `messages`)
1190
+ *
1191
+ * Without this, a Google `generateContent({ model, contents })` call has an empty
1192
+ * `messages` array, so every PII/injection scan sees nothing and PII egresses.
1193
+ */
1194
+ function extractPromptParts(params, kind) {
1195
+ if (kind === "google") {
1196
+ const contents = params.contents;
1197
+ if (Array.isArray(contents))
1198
+ return contents;
1199
+ return contents != null ? [contents] : [];
1200
+ }
1201
+ const messages = params.messages;
1202
+ const parts = Array.isArray(messages) ? [...messages] : [];
1203
+ if (typeof params.system === "string") {
1204
+ parts.push({ role: "system", content: params.system });
1205
+ }
1206
+ return parts;
1207
+ }
1074
1208
  // ── TigerBeetle engine factory ──
1209
+ /** TigerBeetle codes that mean "this debit would exceed the account's credits". */
1210
+ function isTBInsufficientBalance(err) {
1211
+ if (!(err instanceof TBTransferError))
1212
+ return false;
1213
+ return (err.code === CreateTransferError.exceeds_credits ||
1214
+ err.code === CreateTransferError.overflows_debits ||
1215
+ err.code === CreateTransferError.overflows_debits_pending);
1216
+ }
1075
1217
  /**
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.
1218
+ * Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
1219
+ *
1220
+ * P1-LEDGER-ENFORCE: the holding account is created with
1221
+ * `debits_must_not_exceed_credits` and FUNDED with `seedBudget` usertokens, so a
1222
+ * pending debit (hold) whose cumulative amount would exceed the remaining budget
1223
+ * is REJECTED atomically by TigerBeetle. That rejection is surfaced as an
1224
+ * {@link InsufficientBalanceError}, which the governor re-throws as a hard budget
1225
+ * DENY (never as a ledger outage). The prior escrow account had no enforcing flag
1226
+ * and no funding, so `spendPending` could never reject an over-budget hold.
1227
+ *
1228
+ * NOTE (cross-domain): RECON #3 designates `createLedgerEngine` /
1229
+ * `createFundedBudgetWallet` (LEDGER-owned, in `ledger/engine.ts`) as the eventual
1230
+ * home for this factory. Those symbols do not yet exist on disk, so this
1231
+ * GOVERN-local factory implements the same funded-enforcing contract using the
1232
+ * existing `TrustTBClient` primitives. When LEDGER ships `createLedgerEngine`,
1233
+ * this factory is a drop-in replacement (identical `TrustEngine` shape).
1079
1234
  */
1080
- async function createTBEngine(config) {
1235
+ async function createTBEngine(config, seedBudget) {
1081
1236
  const tbAddresses = config.tigerbeetle.addresses;
1082
1237
  const tbClusterId = BigInt(config.tigerbeetle.clusterId);
1083
1238
  const tbClient = new TrustTBClient({
1084
1239
  addresses: tbAddresses,
1085
1240
  clusterId: tbClusterId,
1086
1241
  });
1087
- // Initialize treasury and escrow accounts
1242
+ // Treasury (unconstrained) funds a per-session enforcing holding wallet.
1088
1243
  await tbClient.createTreasury();
1089
- await tbClient.ensureEscrowAccount("trust:escrow");
1244
+ const treasury = tbClient.getTreasuryId();
1245
+ // Enforcing holding wallet (debits_must_not_exceed_credits), funded with the
1246
+ // remaining session budget so cumulative pending debits cannot exceed it.
1247
+ // A FRESH account id per session prevents double-funding a deterministic
1248
+ // account across restarts (which would inflate the TB-enforced budget).
1249
+ const holdingId = await tbClient.createFundedBudgetWallet(seedBudget);
1090
1250
  // Pending transfer ID mapping (trustId string -> TB bigint)
1091
1251
  const pendingMap = new Map();
1092
1252
  return {
1093
1253
  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 };
1254
+ try {
1255
+ const tbTransferId = await tbClient.createPendingTransfer({
1256
+ debitAccountId: holdingId,
1257
+ creditAccountId: treasury,
1258
+ amount: params.amount,
1259
+ code: XFER_SPEND,
1260
+ });
1261
+ pendingMap.set(params.transferId, tbTransferId);
1262
+ return { transferId: params.transferId };
1263
+ }
1264
+ catch (err) {
1265
+ // Over-budget reservation → TB rejects the pending debit. Surface as a
1266
+ // budget error so the governor reports a hard DENY, not an outage.
1267
+ if (isTBInsufficientBalance(err)) {
1268
+ throw new InsufficientBalanceError("trust:hold", params.amount, seedBudget);
1269
+ }
1270
+ throw err;
1271
+ }
1105
1272
  },
1106
- async postPendingSpend(transferId) {
1273
+ async postPendingSpend(transferId, actualAmount) {
1107
1274
  const tbId = pendingMap.get(transferId);
1108
1275
  if (tbId === undefined) {
1109
1276
  throw new Error(`No pending transfer found for ${transferId}`);
1110
1277
  }
1111
- await tbClient.postTransfer(tbId);
1278
+ // Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
1279
+ // posts the full pending amount.
1280
+ await tbClient.postTransfer(tbId, actualAmount);
1112
1281
  pendingMap.delete(transferId);
1113
1282
  },
1114
1283
  async voidPendingSpend(transferId) {