usertrust 1.2.5 → 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.
- package/README.md +9 -9
- package/dist/anomaly/detector.d.ts +17 -0
- package/dist/anomaly/detector.d.ts.map +1 -0
- package/dist/anomaly/detector.js +174 -0
- package/dist/anomaly/detector.js.map +1 -0
- package/dist/anomaly/index.d.ts +8 -0
- package/dist/anomaly/index.d.ts.map +1 -0
- package/dist/anomaly/index.js +8 -0
- package/dist/anomaly/index.js.map +1 -0
- package/dist/anomaly/signals/injection-cascade.d.ts +31 -0
- package/dist/anomaly/signals/injection-cascade.d.ts.map +1 -0
- package/dist/anomaly/signals/injection-cascade.js +46 -0
- package/dist/anomaly/signals/injection-cascade.js.map +1 -0
- package/dist/anomaly/signals/spend-velocity.d.ts +41 -0
- package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -0
- package/dist/anomaly/signals/spend-velocity.js +90 -0
- package/dist/anomaly/signals/spend-velocity.js.map +1 -0
- package/dist/anomaly/signals/token-rate.d.ts +46 -0
- package/dist/anomaly/signals/token-rate.d.ts.map +1 -0
- package/dist/anomaly/signals/token-rate.js +90 -0
- package/dist/anomaly/signals/token-rate.js.map +1 -0
- package/dist/anomaly/types.d.ts +103 -0
- package/dist/anomaly/types.d.ts.map +1 -0
- package/dist/anomaly/types.js +4 -0
- package/dist/anomaly/types.js.map +1 -0
- package/dist/audit/chain.d.ts +9 -0
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +65 -9
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/verify.d.ts +19 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +216 -14
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +9 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +35 -5
- package/dist/cli/skill.js.map +1 -1
- package/dist/cli/snapshot.d.ts.map +1 -1
- package/dist/cli/snapshot.js +20 -2
- package/dist/cli/snapshot.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +20 -11
- package/dist/cli/verify.js.map +1 -1
- package/dist/govern.d.ts +14 -3
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +367 -129
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +155 -36
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ledger/client.d.ts +14 -0
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +56 -0
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/pricing.d.ts.map +1 -1
- package/dist/ledger/pricing.js +8 -2
- package/dist/ledger/pricing.js.map +1 -1
- package/dist/policy/default-rules.d.ts +18 -3
- package/dist/policy/default-rules.d.ts.map +1 -1
- package/dist/policy/default-rules.js +33 -15
- package/dist/policy/default-rules.js.map +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +114 -16
- package/dist/policy/gate.js.map +1 -1
- package/dist/policy/injection.d.ts.map +1 -1
- package/dist/policy/injection.js +25 -16
- package/dist/policy/injection.js.map +1 -1
- package/dist/policy/pii.d.ts +20 -0
- package/dist/policy/pii.d.ts.map +1 -1
- package/dist/policy/pii.js +45 -2
- package/dist/policy/pii.js.map +1 -1
- package/dist/proxy.d.ts +17 -9
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +8 -36
- package/dist/proxy.js.map +1 -1
- package/dist/shared/errors.d.ts +8 -0
- package/dist/shared/errors.d.ts.map +1 -1
- package/dist/shared/errors.js +18 -0
- package/dist/shared/errors.js.map +1 -1
- package/dist/shared/types.d.ts +119 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +37 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts +18 -2
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +101 -17
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts +21 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +35 -8
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/permissions.d.ts +16 -3
- package/dist/supply-chain/permissions.d.ts.map +1 -1
- package/dist/supply-chain/permissions.js +59 -9
- package/dist/supply-chain/permissions.js.map +1 -1
- package/dist/supply-chain/sign.d.ts +5 -2
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +10 -2
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +2 -4
package/dist/govern.js
CHANGED
|
@@ -26,26 +26,27 @@
|
|
|
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";
|
|
43
|
-
import { connectProxy } from "./proxy.js";
|
|
44
44
|
import { CircuitBreakerRegistry } from "./resilience/circuit.js";
|
|
45
45
|
import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
|
|
46
46
|
/** Base URL for receipt verification links (used in proxy mode). */
|
|
47
47
|
const VERIFY_URL_BASE = "https://verify.usertrust.dev";
|
|
48
|
-
import {
|
|
48
|
+
import { createAnomalyDetector } from "./anomaly/detector.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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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,16 +149,27 @@ 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
|
-
|
|
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,
|
|
141
161
|
});
|
|
142
|
-
// 3. Proxy
|
|
143
|
-
let proxyConn = null;
|
|
162
|
+
// 3. AUD-456: Proxy mode removed — throw early with clear error
|
|
144
163
|
if (opts?.proxy) {
|
|
145
|
-
|
|
164
|
+
throw new Error("usertrust: proxy mode is not yet implemented (AUD-456). " +
|
|
165
|
+
"Use dryRun mode for testing, or connect a real TigerBeetle instance for production.");
|
|
146
166
|
}
|
|
167
|
+
// AUD-456: proxyConn is always null now — proxy mode throws above.
|
|
168
|
+
// Cast keeps dead code paths type-safe for future re-enablement.
|
|
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);
|
|
147
173
|
// 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
|
|
148
174
|
// AUD-470: _engine injection only accepted in test environments
|
|
149
175
|
let engine;
|
|
@@ -152,7 +178,9 @@ export async function trust(client, opts) {
|
|
|
152
178
|
}
|
|
153
179
|
else if (!isDryRun && proxyConn == null) {
|
|
154
180
|
try {
|
|
155
|
-
|
|
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));
|
|
156
184
|
}
|
|
157
185
|
catch (err) {
|
|
158
186
|
throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
|
|
@@ -165,11 +193,13 @@ export async function trust(client, opts) {
|
|
|
165
193
|
const kind = detectClientKind(client);
|
|
166
194
|
// 6. Track state
|
|
167
195
|
let destroyed = false;
|
|
168
|
-
let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
|
|
169
196
|
const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
|
|
170
197
|
let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
|
|
171
198
|
let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
|
|
172
199
|
let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
|
|
200
|
+
// Streaming anomaly detector — shared across calls so injection-cascade
|
|
201
|
+
// can track signals across the conversation. Disabled when config.anomaly.enabled=false.
|
|
202
|
+
const anomalyDetector = createAnomalyDetector(config.anomaly, { provider: kind });
|
|
173
203
|
// 7. Two-phase intercept
|
|
174
204
|
async function interceptCall(originalFn, thisArg, args) {
|
|
175
205
|
if (destroyed) {
|
|
@@ -180,15 +210,23 @@ export async function trust(client, opts) {
|
|
|
180
210
|
try {
|
|
181
211
|
const params = (args[0] ?? {});
|
|
182
212
|
const model = params.model ?? "unknown";
|
|
183
|
-
|
|
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);
|
|
184
218
|
// Per-call audit degradation flag (not sticky across calls)
|
|
185
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;
|
|
186
224
|
// a. Circuit breaker check
|
|
187
225
|
const cb = breaker.get(kind);
|
|
188
226
|
cb.allowRequest();
|
|
189
227
|
// b. Estimate cost (before policy, so cost fields are available in context)
|
|
190
228
|
const transferId = trustId("tx");
|
|
191
|
-
const estimatedInputTokens = estimateInputTokens(
|
|
229
|
+
const estimatedInputTokens = estimateInputTokens(promptParts);
|
|
192
230
|
const maxOutputTokens = params.max_tokens ?? 4096;
|
|
193
231
|
const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
|
|
194
232
|
// AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
|
|
@@ -197,30 +235,56 @@ export async function trust(client, opts) {
|
|
|
197
235
|
const releaseBudgetLock = await budgetMutex.acquire();
|
|
198
236
|
// AUD-460: Track the proxy's transferId separately for settle/void
|
|
199
237
|
let proxyTransferId;
|
|
238
|
+
// AUD-468: Track whether the in-flight hold has been released, so
|
|
239
|
+
// the catch handlers below can't double-decrement inFlightHoldTotal
|
|
240
|
+
// (mirrors the AUD-465 guard used in governActionImpl).
|
|
241
|
+
let holdActive = false;
|
|
242
|
+
async function releaseInFlightHold() {
|
|
243
|
+
if (!holdActive)
|
|
244
|
+
return;
|
|
245
|
+
holdActive = false;
|
|
246
|
+
const releaseLock = await budgetMutex.acquire();
|
|
247
|
+
inFlightHoldTotal -= estimatedCost;
|
|
248
|
+
releaseLock();
|
|
249
|
+
}
|
|
200
250
|
try {
|
|
201
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).
|
|
202
258
|
const policyResult = evaluatePolicy(policyRules, {
|
|
259
|
+
...params,
|
|
203
260
|
model,
|
|
204
261
|
tier: config.tier,
|
|
205
262
|
estimated_cost: estimatedCost,
|
|
206
263
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
207
|
-
|
|
264
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
|
|
208
265
|
});
|
|
209
266
|
if (policyResult.decision === "deny") {
|
|
210
267
|
const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
|
|
211
268
|
throw new PolicyDeniedError(reason);
|
|
212
269
|
}
|
|
213
|
-
// d. PII check
|
|
270
|
+
// d. PII check + redact-egress
|
|
214
271
|
if (config.pii !== "off") {
|
|
215
|
-
const piiResult = detectPII(
|
|
272
|
+
const piiResult = detectPII(promptParts);
|
|
216
273
|
if (piiResult.found && config.pii === "block") {
|
|
274
|
+
// block mode: throw BEFORE any egress.
|
|
217
275
|
throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
|
|
218
276
|
}
|
|
219
|
-
|
|
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).
|
|
220
284
|
}
|
|
221
285
|
// d2. Injection detection
|
|
222
286
|
if (config.injection !== "off") {
|
|
223
|
-
const injectionResult = detectInjection(
|
|
287
|
+
const injectionResult = detectInjection(promptParts);
|
|
224
288
|
if (injectionResult.detected) {
|
|
225
289
|
if (config.injection === "block") {
|
|
226
290
|
throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
|
|
@@ -237,6 +301,11 @@ export async function trust(client, opts) {
|
|
|
237
301
|
},
|
|
238
302
|
})
|
|
239
303
|
.catch(() => { });
|
|
304
|
+
// Feed the anomaly detector so cascading injections trip the breaker.
|
|
305
|
+
anomalyDetector.observe({
|
|
306
|
+
kind: "injection",
|
|
307
|
+
patterns: injectionResult.patterns,
|
|
308
|
+
});
|
|
240
309
|
}
|
|
241
310
|
}
|
|
242
311
|
// e. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
|
|
@@ -262,21 +331,32 @@ export async function trust(client, opts) {
|
|
|
262
331
|
});
|
|
263
332
|
}
|
|
264
333
|
catch (holdErr) {
|
|
265
|
-
//
|
|
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.
|
|
266
344
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
267
345
|
}
|
|
268
346
|
}
|
|
269
347
|
// Track in-flight hold cost for accurate budget calculations
|
|
270
348
|
inFlightHoldTotal += estimatedCost;
|
|
349
|
+
holdActive = true; // AUD-468: arm the guard
|
|
271
350
|
}
|
|
272
351
|
finally {
|
|
273
352
|
// AUD-453: Release lock after budget check + hold are complete
|
|
274
353
|
releaseBudgetLock();
|
|
275
354
|
}
|
|
276
|
-
// 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.
|
|
277
357
|
let settled = true;
|
|
278
358
|
try {
|
|
279
|
-
const response = await originalFn.apply(thisArg,
|
|
359
|
+
const response = await originalFn.apply(thisArg, forwardArgs);
|
|
280
360
|
// e2. Streaming detection: if response is an async iterable, wrap with
|
|
281
361
|
// token accumulation. Settlement and audit happen when the stream ends.
|
|
282
362
|
if (response != null &&
|
|
@@ -296,12 +376,22 @@ export async function trust(client, opts) {
|
|
|
296
376
|
usageSource = "estimated";
|
|
297
377
|
}
|
|
298
378
|
// Release in-flight hold and commit budget under mutex
|
|
299
|
-
|
|
379
|
+
// AUD-468: holdActive guard prevents double-release if the
|
|
380
|
+
// outer catch also fires (e.g. from a synchronous exception
|
|
381
|
+
// during stream construction).
|
|
382
|
+
if (holdActive) {
|
|
383
|
+
holdActive = false;
|
|
300
384
|
const releaseLock = await budgetMutex.acquire();
|
|
301
385
|
inFlightHoldTotal -= estimatedCost;
|
|
302
386
|
budgetSpent += streamCost;
|
|
303
387
|
releaseLock();
|
|
304
388
|
}
|
|
389
|
+
else {
|
|
390
|
+
// Hold already released — still need to record the spend.
|
|
391
|
+
const releaseLock = await budgetMutex.acquire();
|
|
392
|
+
budgetSpent += streamCost;
|
|
393
|
+
releaseLock();
|
|
394
|
+
}
|
|
305
395
|
// AUD-457: Persist cumulative spend to disk
|
|
306
396
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
307
397
|
cb.recordSuccess();
|
|
@@ -332,7 +422,8 @@ export async function trust(client, opts) {
|
|
|
332
422
|
}
|
|
333
423
|
else if (engine != null && !isDryRun) {
|
|
334
424
|
try {
|
|
335
|
-
|
|
425
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
426
|
+
await engine.postPendingSpend(transferId, streamCost);
|
|
336
427
|
}
|
|
337
428
|
catch (postErr) {
|
|
338
429
|
settled = false;
|
|
@@ -366,7 +457,7 @@ export async function trust(client, opts) {
|
|
|
366
457
|
chunksDelivered: completion.chunksDelivered,
|
|
367
458
|
};
|
|
368
459
|
if (config.pii === "warn" || config.pii === "redact") {
|
|
369
|
-
const piiResult = redactPII(
|
|
460
|
+
const piiResult = redactPII(promptParts);
|
|
370
461
|
if (piiResult.detection.found) {
|
|
371
462
|
auditEventData.piiDetected = piiResult.detection.types;
|
|
372
463
|
auditEventData.piiPaths = piiResult.detection.paths;
|
|
@@ -382,6 +473,13 @@ export async function trust(client, opts) {
|
|
|
382
473
|
catch {
|
|
383
474
|
callAuditDegraded = true;
|
|
384
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
|
+
}
|
|
385
483
|
const streamReceipt = {
|
|
386
484
|
transferId,
|
|
387
485
|
cost: streamCost,
|
|
@@ -403,11 +501,8 @@ export async function trust(client, opts) {
|
|
|
403
501
|
return streamReceipt;
|
|
404
502
|
}, async (error, partial) => {
|
|
405
503
|
// Release in-flight hold under mutex
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
inFlightHoldTotal -= estimatedCost;
|
|
409
|
-
releaseLock();
|
|
410
|
-
}
|
|
504
|
+
// AUD-468: holdActive guard prevents double-release.
|
|
505
|
+
await releaseInFlightHold();
|
|
411
506
|
cb.recordFailure();
|
|
412
507
|
// Best-effort audit of partial delivery
|
|
413
508
|
audit
|
|
@@ -438,6 +533,41 @@ export async function trust(client, opts) {
|
|
|
438
533
|
engine.voidPendingSpend(transferId).catch(() => { });
|
|
439
534
|
}
|
|
440
535
|
inFlightStreamCount--;
|
|
536
|
+
},
|
|
537
|
+
// onChunk: streaming anomaly detector hook. Observe each chunk;
|
|
538
|
+
// if a signal trips, throw AnomalyError to abort the stream.
|
|
539
|
+
// The thrown error propagates through wrapStream's onError above
|
|
540
|
+
// → existing VOID flow runs → upstream caller sees AnomalyError.
|
|
541
|
+
(obs) => {
|
|
542
|
+
if (!config.anomaly.enabled)
|
|
543
|
+
return;
|
|
544
|
+
anomalyDetector.observe({
|
|
545
|
+
kind: "chunk",
|
|
546
|
+
deltaTokens: obs.deltaTokens,
|
|
547
|
+
cumulativeInputTokens: obs.cumulativeInputTokens,
|
|
548
|
+
cumulativeOutputTokens: obs.cumulativeOutputTokens,
|
|
549
|
+
});
|
|
550
|
+
const verdict = anomalyDetector.check();
|
|
551
|
+
if (verdict.tripped) {
|
|
552
|
+
// Append hash-chained anomaly_detected audit event.
|
|
553
|
+
// Best-effort: do not block the abort if audit fails.
|
|
554
|
+
audit
|
|
555
|
+
.appendEvent({
|
|
556
|
+
kind: "anomaly_detected",
|
|
557
|
+
actor: "local",
|
|
558
|
+
data: {
|
|
559
|
+
anomalyKind: verdict.kind,
|
|
560
|
+
message: verdict.message,
|
|
561
|
+
metric: verdict.metric,
|
|
562
|
+
threshold: verdict.threshold,
|
|
563
|
+
model,
|
|
564
|
+
transferId,
|
|
565
|
+
provider: kind,
|
|
566
|
+
},
|
|
567
|
+
})
|
|
568
|
+
.catch(() => { });
|
|
569
|
+
throw new AnomalyError(verdict.kind, verdict.message, verdict.metric, verdict.threshold);
|
|
570
|
+
}
|
|
441
571
|
});
|
|
442
572
|
// AUD-454: For streaming responses, settlement has NOT happened yet.
|
|
443
573
|
// Set settled: false — the real settlement status will be on
|
|
@@ -475,8 +605,53 @@ export async function trust(client, opts) {
|
|
|
475
605
|
actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
|
|
476
606
|
}
|
|
477
607
|
}
|
|
478
|
-
//
|
|
479
|
-
|
|
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.
|
|
653
|
+
if (holdActive) {
|
|
654
|
+
holdActive = false;
|
|
480
655
|
const releaseLock = await budgetMutex.acquire();
|
|
481
656
|
inFlightHoldTotal -= estimatedCost;
|
|
482
657
|
budgetSpent += actualCost;
|
|
@@ -489,7 +664,8 @@ export async function trust(client, opts) {
|
|
|
489
664
|
// g2. Failure mode 15.1: POST fails after LLM success
|
|
490
665
|
if (engine != null && !isDryRun) {
|
|
491
666
|
try {
|
|
492
|
-
|
|
667
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
668
|
+
await engine.postPendingSpend(transferId, actualCost);
|
|
493
669
|
}
|
|
494
670
|
catch (postErr) {
|
|
495
671
|
// POST failed — LLM call succeeded but settlement is ambiguous
|
|
@@ -537,34 +713,11 @@ export async function trust(client, opts) {
|
|
|
537
713
|
});
|
|
538
714
|
}
|
|
539
715
|
}
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
model,
|
|
546
|
-
cost: actualCost,
|
|
547
|
-
settled,
|
|
548
|
-
transferId,
|
|
549
|
-
};
|
|
550
|
-
if (config.pii === "warn" || config.pii === "redact") {
|
|
551
|
-
const piiResult = redactPII(messages);
|
|
552
|
-
if (piiResult.detection.found) {
|
|
553
|
-
auditData.piiDetected = piiResult.detection.types;
|
|
554
|
-
auditData.piiPaths = piiResult.detection.paths;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
const auditEvent = await audit.appendEvent({
|
|
558
|
-
kind: "llm_call",
|
|
559
|
-
actor: "local",
|
|
560
|
-
data: auditData,
|
|
561
|
-
});
|
|
562
|
-
auditHash = auditEvent.hash;
|
|
563
|
-
}
|
|
564
|
-
catch {
|
|
565
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
566
|
-
callAuditDegraded = true;
|
|
567
|
-
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}`);
|
|
568
721
|
}
|
|
569
722
|
// i. Daily-rotated audit receipt (non-blocking)
|
|
570
723
|
if (config.audit.rotation !== "none") {
|
|
@@ -577,7 +730,7 @@ export async function trust(client, opts) {
|
|
|
577
730
|
}
|
|
578
731
|
// i2. Pattern memory
|
|
579
732
|
if (config.patterns.enabled) {
|
|
580
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
733
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
581
734
|
await recordPattern({
|
|
582
735
|
promptHash,
|
|
583
736
|
model,
|
|
@@ -605,11 +758,10 @@ export async function trust(client, opts) {
|
|
|
605
758
|
}
|
|
606
759
|
catch (err) {
|
|
607
760
|
// Release in-flight hold under mutex (non-streaming failure)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
761
|
+
// AUD-468: Use the guarded release — this is a no-op if the
|
|
762
|
+
// success path already released the hold, preventing
|
|
763
|
+
// inFlightHoldTotal from drifting negative on post-commit throws.
|
|
764
|
+
await releaseInFlightHold();
|
|
613
765
|
// j. Circuit breaker: record failure
|
|
614
766
|
cb.recordFailure();
|
|
615
767
|
// j2. Failure mode 15.2: LLM fails — VOID the pending hold
|
|
@@ -649,7 +801,7 @@ export async function trust(client, opts) {
|
|
|
649
801
|
});
|
|
650
802
|
// l. Pattern memory: record failure
|
|
651
803
|
if (config.patterns.enabled) {
|
|
652
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
804
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
653
805
|
await recordPattern({
|
|
654
806
|
promptHash,
|
|
655
807
|
model,
|
|
@@ -687,13 +839,17 @@ export async function trust(client, opts) {
|
|
|
687
839
|
let proxyTransferId;
|
|
688
840
|
try {
|
|
689
841
|
// c. Policy gate — action fields available in context
|
|
690
|
-
// 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).
|
|
691
846
|
const policyResult = evaluatePolicy(policyRules, {
|
|
692
847
|
...(action.params ?? {}),
|
|
693
848
|
action_kind: action.kind,
|
|
694
849
|
action_name: action.name,
|
|
695
850
|
estimated_cost: action.cost,
|
|
696
851
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
852
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
|
|
697
853
|
tier: config.tier,
|
|
698
854
|
});
|
|
699
855
|
if (policyResult.decision === "deny") {
|
|
@@ -751,6 +907,11 @@ export async function trust(client, opts) {
|
|
|
751
907
|
});
|
|
752
908
|
}
|
|
753
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
|
+
}
|
|
754
915
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
755
916
|
}
|
|
756
917
|
}
|
|
@@ -775,7 +936,52 @@ export async function trust(client, opts) {
|
|
|
775
936
|
}
|
|
776
937
|
try {
|
|
777
938
|
const result = await execute();
|
|
778
|
-
//
|
|
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.
|
|
779
985
|
await releaseHoldAndCommit(action.cost);
|
|
780
986
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
781
987
|
// g. Circuit breaker: record success
|
|
@@ -784,7 +990,8 @@ export async function trust(client, opts) {
|
|
|
784
990
|
let settled = true;
|
|
785
991
|
if (engine != null && !isDryRun) {
|
|
786
992
|
try {
|
|
787
|
-
|
|
993
|
+
// Post the ACTUAL cost (RECON #3).
|
|
994
|
+
await engine.postPendingSpend(transferId, action.cost);
|
|
788
995
|
}
|
|
789
996
|
catch (postErr) {
|
|
790
997
|
settled = false;
|
|
@@ -832,38 +1039,10 @@ export async function trust(client, opts) {
|
|
|
832
1039
|
});
|
|
833
1040
|
}
|
|
834
1041
|
}
|
|
835
|
-
//
|
|
836
|
-
|
|
837
|
-
if (
|
|
838
|
-
|
|
839
|
-
const redacted = redactPII(action.params);
|
|
840
|
-
auditParams = redacted.data;
|
|
841
|
-
}
|
|
842
|
-
else {
|
|
843
|
-
auditParams = action.params;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
// i2. Audit event
|
|
847
|
-
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
848
|
-
let auditHash = syntheticHash;
|
|
849
|
-
try {
|
|
850
|
-
const auditEvent = await audit.appendEvent({
|
|
851
|
-
kind: action.kind,
|
|
852
|
-
actor,
|
|
853
|
-
data: {
|
|
854
|
-
actionName: action.name,
|
|
855
|
-
cost: action.cost,
|
|
856
|
-
settled,
|
|
857
|
-
transferId,
|
|
858
|
-
...(auditParams != null ? { params: auditParams } : {}),
|
|
859
|
-
},
|
|
860
|
-
});
|
|
861
|
-
auditHash = auditEvent.hash;
|
|
862
|
-
}
|
|
863
|
-
catch {
|
|
864
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
865
|
-
callAuditDegraded = true;
|
|
866
|
-
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}`);
|
|
867
1046
|
}
|
|
868
1047
|
// j. Daily-rotated receipt
|
|
869
1048
|
if (config.audit.rotation !== "none") {
|
|
@@ -1002,44 +1181,103 @@ export async function trust(client, opts) {
|
|
|
1002
1181
|
process.on("beforeExit", beforeExitHandler);
|
|
1003
1182
|
return governedClient;
|
|
1004
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
|
+
}
|
|
1005
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
|
+
}
|
|
1006
1217
|
/**
|
|
1007
|
-
* Create a TrustEngine backed by a real TigerBeetle client.
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
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).
|
|
1010
1234
|
*/
|
|
1011
|
-
async function createTBEngine(config) {
|
|
1235
|
+
async function createTBEngine(config, seedBudget) {
|
|
1012
1236
|
const tbAddresses = config.tigerbeetle.addresses;
|
|
1013
1237
|
const tbClusterId = BigInt(config.tigerbeetle.clusterId);
|
|
1014
1238
|
const tbClient = new TrustTBClient({
|
|
1015
1239
|
addresses: tbAddresses,
|
|
1016
1240
|
clusterId: tbClusterId,
|
|
1017
1241
|
});
|
|
1018
|
-
//
|
|
1242
|
+
// Treasury (unconstrained) funds a per-session enforcing holding wallet.
|
|
1019
1243
|
await tbClient.createTreasury();
|
|
1020
|
-
|
|
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);
|
|
1021
1250
|
// Pending transfer ID mapping (trustId string -> TB bigint)
|
|
1022
1251
|
const pendingMap = new Map();
|
|
1023
1252
|
return {
|
|
1024
1253
|
async spendPending(params) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
}
|
|
1036
1272
|
},
|
|
1037
|
-
async postPendingSpend(transferId) {
|
|
1273
|
+
async postPendingSpend(transferId, actualAmount) {
|
|
1038
1274
|
const tbId = pendingMap.get(transferId);
|
|
1039
1275
|
if (tbId === undefined) {
|
|
1040
1276
|
throw new Error(`No pending transfer found for ${transferId}`);
|
|
1041
1277
|
}
|
|
1042
|
-
|
|
1278
|
+
// Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
|
|
1279
|
+
// posts the full pending amount.
|
|
1280
|
+
await tbClient.postTransfer(tbId, actualAmount);
|
|
1043
1281
|
pendingMap.delete(transferId);
|
|
1044
1282
|
},
|
|
1045
1283
|
async voidPendingSpend(transferId) {
|