usertrust 0.2.2 → 0.2.4

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 (47) hide show
  1. package/dist/audit/canonical.d.ts.map +1 -1
  2. package/dist/audit/canonical.js +11 -0
  3. package/dist/audit/canonical.js.map +1 -1
  4. package/dist/audit/chain.d.ts +1 -1
  5. package/dist/audit/chain.d.ts.map +1 -1
  6. package/dist/audit/chain.js +104 -57
  7. package/dist/audit/chain.js.map +1 -1
  8. package/dist/audit/rotation.d.ts +1 -1
  9. package/dist/audit/rotation.js +1 -1
  10. package/dist/audit/verify.d.ts +1 -0
  11. package/dist/audit/verify.d.ts.map +1 -1
  12. package/dist/audit/verify.js +13 -3
  13. package/dist/audit/verify.js.map +1 -1
  14. package/dist/board/board.d.ts +1 -1
  15. package/dist/board/board.js +1 -1
  16. package/dist/detect.d.ts +19 -2
  17. package/dist/detect.d.ts.map +1 -1
  18. package/dist/detect.js +7 -2
  19. package/dist/detect.js.map +1 -1
  20. package/dist/govern.d.ts +2 -0
  21. package/dist/govern.d.ts.map +1 -1
  22. package/dist/govern.js +378 -260
  23. package/dist/govern.js.map +1 -1
  24. package/dist/ledger/engine.d.ts +1 -1
  25. package/dist/ledger/engine.d.ts.map +1 -1
  26. package/dist/ledger/engine.js +35 -12
  27. package/dist/ledger/engine.js.map +1 -1
  28. package/dist/memory/patterns.d.ts +4 -1
  29. package/dist/memory/patterns.d.ts.map +1 -1
  30. package/dist/memory/patterns.js +46 -14
  31. package/dist/memory/patterns.js.map +1 -1
  32. package/dist/policy/gate.d.ts.map +1 -1
  33. package/dist/policy/gate.js +38 -6
  34. package/dist/policy/gate.js.map +1 -1
  35. package/dist/proxy.d.ts +3 -0
  36. package/dist/proxy.d.ts.map +1 -1
  37. package/dist/proxy.js +14 -0
  38. package/dist/proxy.js.map +1 -1
  39. package/dist/resilience/scope.d.ts +32 -4
  40. package/dist/resilience/scope.d.ts.map +1 -1
  41. package/dist/resilience/scope.js +83 -35
  42. package/dist/resilience/scope.js.map +1 -1
  43. package/dist/shared/errors.d.ts +14 -0
  44. package/dist/shared/errors.d.ts.map +1 -1
  45. package/dist/shared/errors.js +49 -7
  46. package/dist/shared/errors.js.map +1 -1
  47. package/package.json +1 -1
package/dist/govern.js CHANGED
@@ -27,8 +27,8 @@
27
27
  * ```
28
28
  */
29
29
  import { createHash } from "node:crypto";
30
- import { existsSync } from "node:fs";
31
- import { readFile } from "node:fs/promises";
30
+ import { existsSync, mkdirSync } from "node:fs";
31
+ import { readFile, rename, writeFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
33
  import { createAuditWriter } from "./audit/chain.js";
34
34
  import { writeReceipt } from "./audit/rotation.js";
@@ -36,16 +36,71 @@ import { detectClientKind } from "./detect.js";
36
36
  import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
37
  import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
38
38
  import { recordPattern } from "./memory/patterns.js";
39
- import { DecayRateCalculator } from "./policy/decay.js";
40
39
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
41
40
  import { detectPII } from "./policy/pii.js";
42
41
  import { connectProxy } from "./proxy.js";
43
42
  import { CircuitBreakerRegistry } from "./resilience/circuit.js";
44
43
  import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
44
+ /** Base URL for receipt verification links (used in proxy mode). */
45
+ const VERIFY_URL_BASE = "https://verify.usertrust.dev";
45
46
  import { LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.js";
46
47
  import { trustId } from "./shared/ids.js";
47
48
  import { TrustConfigSchema } from "./shared/types.js";
48
49
  import { createGovernedStream } from "./streaming.js";
50
+ // ── AUD-453: Async mutex for budget atomicity ──
51
+ // Prevents concurrent interceptCall invocations from racing through
52
+ // the budget-check + PENDING hold sequence.
53
+ class AsyncMutex {
54
+ queue = Promise.resolve();
55
+ async acquire() {
56
+ let release;
57
+ const next = new Promise((resolve) => {
58
+ release = resolve;
59
+ });
60
+ const prev = this.queue;
61
+ this.queue = next;
62
+ await prev;
63
+ return release;
64
+ }
65
+ }
66
+ async function loadSpendLedger(vaultBase) {
67
+ const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
68
+ try {
69
+ const raw = await readFile(ledgerPath, "utf-8");
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed != null &&
72
+ typeof parsed === "object" &&
73
+ "budgetSpent" in parsed &&
74
+ typeof parsed.budgetSpent === "number") {
75
+ return parsed.budgetSpent;
76
+ }
77
+ }
78
+ catch {
79
+ // No ledger file or corrupt — start from zero
80
+ }
81
+ return 0;
82
+ }
83
+ async function persistSpendLedger(vaultBase, budgetSpent) {
84
+ const dir = join(vaultBase, VAULT_DIR);
85
+ const ledgerPath = join(dir, "spend-ledger.json");
86
+ const tmpPath = join(dir, "spend-ledger.json.tmp");
87
+ const data = {
88
+ budgetSpent,
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ try {
92
+ // Ensure vault dir exists
93
+ if (!existsSync(dir)) {
94
+ mkdirSync(dir, { recursive: true });
95
+ }
96
+ // Atomic write: write tmp then rename
97
+ await writeFile(tmpPath, JSON.stringify(data), "utf-8");
98
+ await rename(tmpPath, ledgerPath);
99
+ }
100
+ catch {
101
+ // Best-effort — do not fail the LLM call over ledger persistence
102
+ }
103
+ }
49
104
  // ── trust() ──
50
105
  export async function trust(client, opts) {
51
106
  // 1. Load config
@@ -65,26 +120,27 @@ export async function trust(client, opts) {
65
120
  });
66
121
  }
67
122
  const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
123
+ // AUD-470: Only accept injected _engine/_audit in test environments.
124
+ // In production, silently ignore them to prevent governance bypass.
125
+ const isTestEnv = process.env.USERTRUST_TEST === "1" || process.env.NODE_ENV === "test";
68
126
  // 2. Initialise subsystems
69
127
  const vaultPath = vaultBase;
70
- const audit = opts?._audit ?? createAuditWriter(vaultPath);
128
+ const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
71
129
  const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
72
130
  const policyRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
73
131
  const breaker = new CircuitBreakerRegistry({
74
132
  failureThreshold: config.circuitBreaker.failureThreshold,
75
133
  resetTimeoutMs: config.circuitBreaker.resetTimeout,
76
134
  });
77
- // Decay-weighted budget calculator (1-hour half-life)
78
- const decayCalc = new DecayRateCalculator({ halfLifeMs: 3_600_000 });
79
- const spendHistory = [];
80
135
  // 3. Proxy connection (if proxy mode)
81
136
  let proxyConn = null;
82
137
  if (opts?.proxy) {
83
138
  proxyConn = connectProxy(opts.proxy, opts.key);
84
139
  }
85
140
  // 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
141
+ // AUD-470: _engine injection only accepted in test environments
86
142
  let engine;
87
- if (opts?._engine !== undefined) {
143
+ if (isTestEnv && opts?._engine !== undefined) {
88
144
  engine = opts._engine;
89
145
  }
90
146
  else if (!isDryRun && proxyConn == null) {
@@ -102,306 +158,342 @@ export async function trust(client, opts) {
102
158
  const kind = detectClientKind(client);
103
159
  // 6. Track state
104
160
  let destroyed = false;
105
- let budgetSpent = 0;
161
+ let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
162
+ const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
163
+ let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
106
164
  // 7. Two-phase intercept
107
165
  async function interceptCall(originalFn, thisArg, args) {
108
166
  if (destroyed) {
109
167
  throw new Error("TrustedClient has been destroyed");
110
168
  }
111
- const params = (args[0] ?? {});
112
- const model = params.model ?? "unknown";
113
- const messages = params.messages ?? [];
114
- // Per-call audit degradation flag (not sticky across calls)
115
- let callAuditDegraded = false;
116
- // a. Circuit breaker check
117
- const cb = breaker.get(kind);
118
- cb.allowRequest();
119
- // b. Estimate cost (before policy, so cost fields are available in context)
120
- const transferId = trustId("tx");
121
- const estimatedInputTokens = estimateInputTokens(messages);
122
- const maxOutputTokens = params.max_tokens ?? 4096;
123
- const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens);
124
- // c. Policy gate
125
- const policyResult = evaluatePolicy(policyRules, {
126
- model,
127
- tier: config.tier,
128
- estimated_cost: estimatedCost,
129
- budget_remaining: config.budget - budgetSpent,
130
- ...params,
131
- });
132
- if (policyResult.decision === "deny") {
133
- const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
134
- throw new PolicyDeniedError(reason);
135
- }
136
- // d. PII check
137
- if (config.pii !== "off") {
138
- const piiResult = detectPII(messages);
139
- if (piiResult.found && config.pii === "block") {
140
- throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
141
- }
142
- // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
143
- }
144
- // d2. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
145
- if (proxyConn != null && !isDryRun) {
169
+ // AUD-462: Track in-flight calls so destroy() can wait for them
170
+ inFlightCount++;
171
+ try {
172
+ const params = (args[0] ?? {});
173
+ const model = params.model ?? "unknown";
174
+ const messages = params.messages ?? [];
175
+ // Per-call audit degradation flag (not sticky across calls)
176
+ let callAuditDegraded = false;
177
+ // a. Circuit breaker check
178
+ const cb = breaker.get(kind);
179
+ cb.allowRequest();
180
+ // b. Estimate cost (before policy, so cost fields are available in context)
181
+ const transferId = trustId("tx");
182
+ const estimatedInputTokens = estimateInputTokens(messages);
183
+ const maxOutputTokens = params.max_tokens ?? 4096;
184
+ const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens);
185
+ // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
186
+ // This prevents concurrent calls from both passing the budget check
187
+ // and overshooting the budget.
188
+ const releaseBudgetLock = await budgetMutex.acquire();
189
+ // AUD-460: Track the proxy's transferId separately for settle/void
190
+ let proxyTransferId;
146
191
  try {
147
- await proxyConn.spend({
192
+ // c. Policy gate
193
+ const policyResult = evaluatePolicy(policyRules, {
148
194
  model,
149
- estimatedCost,
150
- actor: "local",
195
+ tier: config.tier,
196
+ estimated_cost: estimatedCost,
197
+ budget_remaining: config.budget - budgetSpent,
198
+ ...params,
151
199
  });
200
+ if (policyResult.decision === "deny") {
201
+ const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
202
+ throw new PolicyDeniedError(reason);
203
+ }
204
+ // d. PII check
205
+ if (config.pii !== "off") {
206
+ const piiResult = detectPII(messages);
207
+ if (piiResult.found && config.pii === "block") {
208
+ throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
209
+ }
210
+ // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
211
+ }
212
+ // d2. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
213
+ if (proxyConn != null && !isDryRun) {
214
+ try {
215
+ // AUD-460: Capture the proxy's returned transferId
216
+ const proxyResult = await proxyConn.spend({
217
+ model,
218
+ estimatedCost,
219
+ actor: "local",
220
+ });
221
+ proxyTransferId = proxyResult.transferId;
222
+ }
223
+ catch (holdErr) {
224
+ throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
225
+ }
226
+ }
227
+ else if (engine != null && !isDryRun) {
228
+ try {
229
+ await engine.spendPending({
230
+ transferId,
231
+ amount: estimatedCost,
232
+ });
233
+ }
234
+ catch (holdErr) {
235
+ // Ledger unreachable — do NOT forward to provider
236
+ throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
237
+ }
238
+ }
152
239
  }
153
- catch (holdErr) {
154
- throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
240
+ finally {
241
+ // AUD-453: Release lock after budget check + hold are complete
242
+ releaseBudgetLock();
155
243
  }
156
- }
157
- else if (engine != null && !isDryRun) {
244
+ // e. Forward to original SDK
245
+ let settled = true;
158
246
  try {
159
- await engine.spendPending({
160
- transferId,
161
- amount: estimatedCost,
162
- });
163
- }
164
- catch (holdErr) {
165
- // Ledger unreachable — do NOT forward to provider
166
- throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
167
- }
168
- }
169
- // e. Forward to original SDK
170
- let settled = true;
171
- try {
172
- const response = await originalFn.apply(thisArg, args);
173
- // e2. Streaming detection: if response is an async iterable, wrap with
174
- // token accumulation. Settlement and audit happen when the stream ends.
175
- if (response != null &&
176
- typeof response === "object" &&
177
- Symbol.asyncIterator in response) {
178
- const stream = response;
179
- const governedStream = createGovernedStream(stream, kind, async (usage) => {
180
- const streamCost = estimateCost(model, usage.inputTokens, usage.outputTokens);
181
- budgetSpent += streamCost;
182
- cb.recordSuccess();
183
- if (proxyConn != null && !isDryRun) {
184
- try {
185
- await proxyConn.settle(transferId, streamCost);
247
+ const response = await originalFn.apply(thisArg, args);
248
+ // e2. Streaming detection: if response is an async iterable, wrap with
249
+ // token accumulation. Settlement and audit happen when the stream ends.
250
+ if (response != null &&
251
+ typeof response === "object" &&
252
+ Symbol.asyncIterator in response) {
253
+ const stream = response;
254
+ const governedStream = createGovernedStream(stream, kind, async (usage) => {
255
+ const streamCost = estimateCost(model, usage.inputTokens, usage.outputTokens);
256
+ budgetSpent += streamCost;
257
+ // AUD-457: Persist cumulative spend to disk
258
+ await persistSpendLedger(vaultBase, budgetSpent);
259
+ cb.recordSuccess();
260
+ if (proxyConn != null && !isDryRun) {
261
+ try {
262
+ // AUD-460: Use the proxy's transferId for settlement
263
+ await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
264
+ }
265
+ catch {
266
+ settled = false;
267
+ }
186
268
  }
187
- catch {
188
- settled = false;
269
+ else if (engine != null && !isDryRun) {
270
+ try {
271
+ await engine.postPendingSpend(transferId);
272
+ }
273
+ catch {
274
+ settled = false;
275
+ }
189
276
  }
190
- }
191
- else if (engine != null && !isDryRun) {
277
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
278
+ let auditHash = syntheticHash;
192
279
  try {
193
- await engine.postPendingSpend(transferId);
280
+ const auditEvent = await audit.appendEvent({
281
+ kind: "llm_call",
282
+ actor: "local",
283
+ data: { model, cost: streamCost, settled, transferId },
284
+ });
285
+ auditHash = auditEvent.hash;
194
286
  }
195
287
  catch {
196
- settled = false;
288
+ callAuditDegraded = true;
197
289
  }
198
- }
199
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
200
- let auditHash = syntheticHash;
201
- try {
202
- const auditEvent = await audit.appendEvent({
203
- kind: "llm_call",
204
- actor: "local",
205
- data: { model, cost: streamCost, settled, transferId },
206
- });
207
- auditHash = auditEvent.hash;
208
- }
209
- catch {
210
- callAuditDegraded = true;
211
- }
212
- return {
290
+ return {
291
+ transferId,
292
+ cost: streamCost,
293
+ budgetRemaining: config.budget - budgetSpent,
294
+ auditHash,
295
+ chainPath: join(VAULT_DIR, "audit"),
296
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
297
+ settled,
298
+ model,
299
+ provider: kind,
300
+ timestamp: new Date().toISOString(),
301
+ ...(callAuditDegraded ? { auditDegraded: true } : {}),
302
+ // AUD-456: Flag proxy stub receipts
303
+ ...(proxyConn != null ? { proxyStub: true } : {}),
304
+ };
305
+ }, (error) => {
306
+ cb.recordFailure();
307
+ if (proxyConn != null && !isDryRun) {
308
+ // AUD-460: Use the proxy's transferId for void
309
+ proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
310
+ }
311
+ else if (engine != null && !isDryRun) {
312
+ engine.voidPendingSpend(transferId).catch(() => { });
313
+ }
314
+ });
315
+ // AUD-454: For streaming responses, settlement has NOT happened yet.
316
+ // Set settled: false — the real settlement status will be on
317
+ // governedStream.receipt after the stream is fully consumed.
318
+ const auditHash = createHash("sha256").update(transferId).digest("hex");
319
+ const estimatedReceipt = {
213
320
  transferId,
214
- cost: streamCost,
321
+ cost: estimatedCost,
215
322
  budgetRemaining: config.budget - budgetSpent,
216
323
  auditHash,
217
324
  chainPath: join(VAULT_DIR, "audit"),
218
- receiptUrl: opts?.proxy != null ? `https://verify.usertools.dev/${transferId}` : null,
219
- settled,
325
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
326
+ settled: false, // AUD-454: not settled yet — stream hasn't been consumed
220
327
  model,
221
328
  provider: kind,
222
329
  timestamp: new Date().toISOString(),
223
330
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
331
+ // AUD-456: Flag proxy stub receipts
332
+ ...(proxyConn != null ? { proxyStub: true } : {}),
224
333
  };
225
- }, (error) => {
226
- cb.recordFailure();
227
- if (proxyConn != null && !isDryRun) {
228
- proxyConn.void(transferId).catch(() => { });
334
+ return { response: governedStream, receipt: estimatedReceipt };
335
+ }
336
+ // f. Compute actual cost from response usage
337
+ let actualCost = estimatedCost;
338
+ if (response != null && typeof response === "object" && "usage" in response) {
339
+ const usage = response.usage;
340
+ if (usage != null) {
341
+ const inputTokens = usage.input_tokens ??
342
+ usage.prompt_tokens ??
343
+ estimatedInputTokens;
344
+ const outputTokens = usage.output_tokens ??
345
+ usage.completion_tokens ??
346
+ 0;
347
+ actualCost = estimateCost(model, inputTokens, outputTokens);
229
348
  }
230
- else if (engine != null && !isDryRun) {
231
- engine.voidPendingSpend(transferId).catch(() => { });
349
+ }
350
+ // Track budget (cumulative)
351
+ budgetSpent += actualCost;
352
+ // AUD-457: Persist cumulative spend to disk
353
+ await persistSpendLedger(vaultBase, budgetSpent);
354
+ // g. Circuit breaker: record success
355
+ cb.recordSuccess();
356
+ // g2. Failure mode 15.1: POST fails after LLM success
357
+ if (engine != null && !isDryRun) {
358
+ try {
359
+ await engine.postPendingSpend(transferId);
360
+ }
361
+ catch (postErr) {
362
+ // POST failed — LLM call succeeded but settlement is ambiguous
363
+ settled = false;
364
+ await audit
365
+ .appendEvent({
366
+ kind: "settlement_ambiguous",
367
+ actor: "local",
368
+ data: {
369
+ model,
370
+ cost: actualCost,
371
+ transferId,
372
+ error: postErr instanceof Error ? postErr.message : String(postErr),
373
+ },
374
+ })
375
+ .catch(() => {
376
+ // Audit also degraded — nothing more we can do
377
+ callAuditDegraded = true;
378
+ });
232
379
  }
233
- });
234
- // For streaming responses, return the wrapped stream with an
235
- // estimated trust receipt. The actual receipt (with real
236
- // token counts) is available via governedStream.receipt
237
- // after the stream is fully consumed.
238
- const auditHash = createHash("sha256").update(transferId).digest("hex");
239
- const estimatedReceipt = {
240
- transferId,
241
- cost: estimatedCost,
242
- budgetRemaining: config.budget - budgetSpent,
243
- auditHash,
244
- chainPath: join(VAULT_DIR, "audit"),
245
- receiptUrl: opts?.proxy != null ? `https://verify.usertools.dev/${transferId}` : null,
246
- settled,
247
- model,
248
- provider: kind,
249
- timestamp: new Date().toISOString(),
250
- ...(callAuditDegraded ? { auditDegraded: true } : {}),
251
- };
252
- return { response: governedStream, receipt: estimatedReceipt };
253
- }
254
- // f. Compute actual cost from response usage
255
- let actualCost = estimatedCost;
256
- if (response != null && typeof response === "object" && "usage" in response) {
257
- const usage = response.usage;
258
- if (usage != null) {
259
- const inputTokens = usage.input_tokens ??
260
- usage.prompt_tokens ??
261
- estimatedInputTokens;
262
- const outputTokens = usage.output_tokens ??
263
- usage.completion_tokens ??
264
- 0;
265
- actualCost = estimateCost(model, inputTokens, outputTokens);
266
380
  }
267
- }
268
- // Track budget (cumulative and decay-weighted)
269
- budgetSpent += actualCost;
270
- spendHistory.push({ ts: Date.now(), value: actualCost });
271
- // g. Circuit breaker: record success
272
- cb.recordSuccess();
273
- // g2. Failure mode 15.1: POST fails after LLM success
274
- if (engine != null && !isDryRun) {
275
- try {
276
- await engine.postPendingSpend(transferId);
381
+ // g3. Proxy settlement
382
+ if (proxyConn != null && !isDryRun) {
383
+ try {
384
+ // AUD-460: Use the proxy's transferId for settlement
385
+ await proxyConn.settle(proxyTransferId ?? transferId, actualCost);
386
+ }
387
+ catch {
388
+ settled = false;
389
+ }
277
390
  }
278
- catch (postErr) {
279
- // POST failed — LLM call succeeded but settlement is ambiguous
280
- settled = false;
281
- await audit
282
- .appendEvent({
283
- kind: "settlement_ambiguous",
391
+ // h. Audit event — failure mode 15.3: audit write failure
392
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
393
+ let auditHash = syntheticHash;
394
+ try {
395
+ const auditEvent = await audit.appendEvent({
396
+ kind: "llm_call",
284
397
  actor: "local",
285
398
  data: {
286
399
  model,
287
400
  cost: actualCost,
401
+ settled,
288
402
  transferId,
289
- error: postErr instanceof Error ? postErr.message : String(postErr),
290
403
  },
291
- })
292
- .catch(() => {
293
- // Audit also degraded — nothing more we can do
294
- callAuditDegraded = true;
295
404
  });
296
- }
297
- }
298
- // g3. Proxy settlement
299
- if (proxyConn != null && !isDryRun) {
300
- try {
301
- await proxyConn.settle(transferId, actualCost);
405
+ auditHash = auditEvent.hash;
302
406
  }
303
407
  catch {
304
- settled = false;
408
+ // Failure mode 15.3: Audit degraded — do not fail the response
409
+ callAuditDegraded = true;
410
+ process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
305
411
  }
306
- }
307
- // h. Audit event — failure mode 15.3: audit write failure
308
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
309
- let auditHash = syntheticHash;
310
- try {
311
- const auditEvent = await audit.appendEvent({
312
- kind: "llm_call",
313
- actor: "local",
314
- data: {
412
+ // i. Daily-rotated audit receipt (non-blocking)
413
+ if (config.audit.rotation !== "none") {
414
+ writeReceipt(vaultPath, {
415
+ kind: "llm_call",
416
+ subsystem: "trust",
417
+ actor: "local",
418
+ data: { model, cost: actualCost, settled, transferId },
419
+ }, config.audit.indexLimit);
420
+ }
421
+ // i2. Pattern memory
422
+ if (config.patterns.enabled) {
423
+ const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
424
+ await recordPattern({
425
+ promptHash,
315
426
  model,
316
427
  cost: actualCost,
317
- settled,
318
- transferId,
319
- },
320
- });
321
- auditHash = auditEvent.hash;
322
- }
323
- catch {
324
- // Failure mode 15.3: Audit degraded — do not fail the response
325
- callAuditDegraded = true;
326
- process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
327
- }
328
- // i. Daily-rotated audit receipt (non-blocking)
329
- if (config.audit.rotation !== "none") {
330
- writeReceipt(vaultPath, {
331
- kind: "llm_call",
332
- subsystem: "trust",
333
- actor: "local",
334
- data: { model, cost: actualCost, settled, transferId },
335
- }, config.audit.indexLimit);
336
- }
337
- // i2. Pattern memory
338
- if (config.patterns.enabled) {
339
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
340
- await recordPattern({
341
- promptHash,
342
- model,
428
+ success: true,
429
+ }).catch(() => { });
430
+ }
431
+ const budgetRemaining = config.budget - budgetSpent;
432
+ const receipt = {
433
+ transferId,
343
434
  cost: actualCost,
344
- success: true,
345
- }).catch(() => { });
435
+ budgetRemaining,
436
+ auditHash,
437
+ chainPath: join(VAULT_DIR, "audit"),
438
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
439
+ settled,
440
+ model,
441
+ provider: kind,
442
+ timestamp: new Date().toISOString(),
443
+ ...(callAuditDegraded ? { auditDegraded: true } : {}),
444
+ // AUD-456: Flag proxy stub receipts
445
+ ...(proxyConn != null ? { proxyStub: true } : {}),
446
+ };
447
+ return { response, receipt };
346
448
  }
347
- const budgetRemaining = config.budget - budgetSpent;
348
- const receipt = {
349
- transferId,
350
- cost: actualCost,
351
- budgetRemaining,
352
- auditHash,
353
- chainPath: join(VAULT_DIR, "audit"),
354
- receiptUrl: opts?.proxy != null ? `https://verify.usertools.dev/${transferId}` : null,
355
- settled,
356
- model,
357
- provider: kind,
358
- timestamp: new Date().toISOString(),
359
- ...(callAuditDegraded ? { auditDegraded: true } : {}),
360
- };
361
- return { response, receipt };
362
- }
363
- catch (err) {
364
- // j. Circuit breaker: record failure
365
- cb.recordFailure();
366
- // j2. Failure mode 15.2: LLM fails — VOID the pending hold
367
- if (engine != null && !isDryRun) {
368
- try {
369
- await engine.voidPendingSpend(transferId);
370
- }
371
- catch {
372
- // Best-effort void — log and continue
449
+ catch (err) {
450
+ // j. Circuit breaker: record failure
451
+ cb.recordFailure();
452
+ // j2. Failure mode 15.2: LLM fails — VOID the pending hold
453
+ if (engine != null && !isDryRun) {
454
+ try {
455
+ await engine.voidPendingSpend(transferId);
456
+ }
457
+ catch {
458
+ // Best-effort void — log and continue
459
+ }
373
460
  }
374
- }
375
- // j3. Proxy void
376
- if (proxyConn != null && !isDryRun) {
377
- try {
378
- await proxyConn.void(transferId);
461
+ // j3. Proxy void
462
+ if (proxyConn != null && !isDryRun) {
463
+ try {
464
+ // AUD-460: Use the proxy's transferId for void
465
+ await proxyConn.void(proxyTransferId ?? transferId);
466
+ }
467
+ catch {
468
+ // Best-effort void
469
+ }
379
470
  }
380
- catch {
381
- // Best-effort void
471
+ // k. Audit the failure
472
+ await audit
473
+ .appendEvent({
474
+ kind: "llm_call_failed",
475
+ actor: "local",
476
+ data: { model, error: String(err), transferId },
477
+ })
478
+ .catch(() => {
479
+ callAuditDegraded = true;
480
+ });
481
+ // l. Pattern memory: record failure
482
+ if (config.patterns.enabled) {
483
+ const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
484
+ await recordPattern({
485
+ promptHash,
486
+ model,
487
+ cost: 0,
488
+ success: false,
489
+ }).catch(() => { });
382
490
  }
491
+ throw err;
383
492
  }
384
- // k. Audit the failure
385
- await audit
386
- .appendEvent({
387
- kind: "llm_call_failed",
388
- actor: "local",
389
- data: { model, error: String(err), transferId },
390
- })
391
- .catch(() => {
392
- callAuditDegraded = true;
393
- });
394
- // l. Pattern memory: record failure
395
- if (config.patterns.enabled) {
396
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
397
- await recordPattern({
398
- promptHash,
399
- model,
400
- cost: 0,
401
- success: false,
402
- }).catch(() => { });
403
- }
404
- throw err;
493
+ }
494
+ finally {
495
+ // AUD-462: Decrement in-flight count so destroy() knows when it's safe
496
+ inFlightCount--;
405
497
  }
406
498
  }
407
499
  // 8. Safety net: clean up on process exit if destroy() was never called
@@ -412,11 +504,23 @@ export async function trust(client, opts) {
412
504
  if (destroyed)
413
505
  return;
414
506
  destroyed = true;
507
+ // AUD-462: Wait up to 5 seconds for in-flight calls to complete.
508
+ // After the deadline, proceed with teardown anyway.
509
+ const deadline = Date.now() + 5_000;
510
+ while (inFlightCount > 0 && Date.now() < deadline) {
511
+ await new Promise((r) => setTimeout(r, 50));
512
+ }
415
513
  // Remove beforeExit safety net
416
514
  if (beforeExitHandler != null) {
417
515
  process.removeListener("beforeExit", beforeExitHandler);
418
516
  beforeExitHandler = null;
419
517
  }
518
+ // AUD-461: Void any remaining pending transfers (best-effort).
519
+ // TigerBeetle auto-voids pending transfers after 300s, but
520
+ // explicit voiding releases holds immediately.
521
+ if (engine != null && typeof engine.voidAllPending === "function") {
522
+ await engine.voidAllPending();
523
+ }
420
524
  // Flush audit writes
421
525
  await audit.flush();
422
526
  // Release audit lock
@@ -496,6 +600,20 @@ async function createTBEngine(config) {
496
600
  await tbClient.voidTransfer(tbId);
497
601
  pendingMap.delete(transferId);
498
602
  },
603
+ // AUD-461: Void all remaining pending transfers on destroy.
604
+ // Best-effort — TigerBeetle auto-voids after 300s regardless.
605
+ async voidAllPending() {
606
+ const entries = [...pendingMap.entries()];
607
+ for (const [trustIdKey, tbTransferId] of entries) {
608
+ try {
609
+ await tbClient.voidTransfer(tbTransferId);
610
+ }
611
+ catch {
612
+ // Best-effort — ignore individual void failures
613
+ }
614
+ pendingMap.delete(trustIdKey);
615
+ }
616
+ },
499
617
  destroy() {
500
618
  tbClient.destroy();
501
619
  },