thumbgate 1.31.0 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1600 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Fail-closed purchase control plane.
6
+ *
7
+ * Financial lifecycle calls derive their requester from an authenticated
8
+ * runtime principal. Caller arguments cannot select an identity. A reservation
9
+ * is consumed exactly once at the final pre-tool allow boundary, after every
10
+ * other gate passes and before the economic action runs. Ledger events form a
11
+ * global hash chain so deletion and reordering are detectable during
12
+ * reconciliation and before any new authorization.
13
+ */
14
+
15
+ const crypto = require('node:crypto');
16
+ const { spawnSync } = require('node:child_process');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { getFeedbackPaths } = require('./feedback-paths');
20
+ const { withFileLedgerLock } = require('./file-ledger-lock');
21
+ const {
22
+ getEscalation,
23
+ getVerifiedApproval,
24
+ requestEscalation,
25
+ } = require('./human-escalation');
26
+
27
+ const LEDGER_FILE = 'financial-control-ledger.jsonl';
28
+ const LEDGER_HEAD_FILE = 'financial-control-ledger.head.json';
29
+ const LEDGER_JOURNAL_FILE = 'financial-control-ledger.journal.json';
30
+ const LEDGER_HEAD_SCHEMA = 'financial-ledger-head-v2';
31
+ const LEDGER_JOURNAL_SCHEMA = 'financial-ledger-journal-v2';
32
+ const LEDGER_ANCHOR_SCHEMA = 'financial-ledger-anchor-v1';
33
+ const DEFAULT_TTL_MS = 60 * 60 * 1000;
34
+ const MAX_TTL_MS = 24 * 60 * 60 * 1000;
35
+ const DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1000;
36
+ const SETTLEMENT_STATUSES = new Set(['committed', 'released']);
37
+ const CONTROL_PLANE_TOOLS = new Set([
38
+ 'create_purchase_requisition',
39
+ 'list_purchase_requisitions',
40
+ 'reserve_purchase_requisition',
41
+ 'settle_purchase_requisition',
42
+ 'reconcile_purchase_ledger',
43
+ ]);
44
+
45
+ // Keep these expressions deliberately small and auditable. The second group
46
+ // covers provider-native noun-first CLI forms such as `subscriptions create`.
47
+ const ECONOMIC_ACTION_PATTERNS = [
48
+ /\badd\s+(?:a\s+)?(?:credit\s+)?card\b/i,
49
+ /\badd\s+(?:a\s+)?payment\s+method\b/i,
50
+ /\b(?:buy|purchase|subscribe|top-?up)\b/i,
51
+ /\bcharge\s+(?:me\s+)?(?:\$|usd\s*)\d/i,
52
+ /\bupgrade\b.{0,40}\b(?:pro|premium|business|enterprise)\b/i,
53
+ /\b(?:activate|cancel|change|create|update|upgrade)\s+(?:a\s+)?(?:\w+\s+){0,3}(?:plan|subscription|tier|seat)\b/i,
54
+ /\b(?:activate|cancel|change|create|update|upgrade)\s+(?:a\s+)?(?:\w+\s+){0,3}(?:checkout|payment\s+method)\b/i,
55
+ /\b(?:confirm|complete|open|start)\s+(?:the\s+)?checkout\b/i,
56
+ /\bcheckout\s+(?:flow|page|session)\b/i,
57
+ /\b(?:issue|send)\s+(?:a\s+)?(?:invoice|payout|refund)\b/i,
58
+ /\b(?:re)?send\s+(?:\w+\s+){0,3}invoice\b/i,
59
+ /\b(?:make|send)\s+(?:a\s+)?(?:payment|transfer|wire)\b/i,
60
+ /\bpay\s+(?:an?\s+)?invoice\b/i,
61
+ /\bpaid\s+trial\b/i,
62
+ /\brenew\s+(?:a\s+)?subscription\b/i,
63
+ /\btransfer\s+(?:dollars?|funds|money|usd)\b/i,
64
+ /\bupgrade\s+(?:a\s+)?plan\b/i,
65
+ /\bcharges?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
66
+ /\bcheckout[\s_-]*sessions?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
67
+ /\binvoices?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
68
+ /\bpayment[\s_-]*intents?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
69
+ /\bpayment[\s_-]*methods?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
70
+ /\bpayouts?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
71
+ /\brefunds?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
72
+ /\bsubscriptions?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
73
+ /\btop[\s_-]*ups?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
74
+ /\btransfers?\s+(?:attach|cancel|capture|confirm|create|detach|finalize|pay|refund|send|update)\b/i,
75
+ /\b(?:attach|cancel|capture|confirm|create|detach)\s+(?:a\s+)?(?:charge|invoice|payment|payment\s+method)\b/i,
76
+ /\b(?:finalize|pay|refund|send|update)\s+(?:a\s+)?(?:charge|invoice|payment|payment\s+method)\b/i,
77
+ /\b(?:attach|cancel|capture|confirm|create|detach)\s+(?:a\s+)?(?:payout|refund|subscription|top-?up|transfer)\b/i,
78
+ /\b(?:finalize|pay|refund|send|update)\s+(?:a\s+)?(?:payout|refund|subscription|top-?up|transfer)\b/i,
79
+ ];
80
+
81
+ const SCREEN_TOOL_PATTERN = /(?:browser|computer|playwright|puppeteer|selenium|click|tap|press)/i;
82
+ const SCREEN_MUTATION_PATTERN = /(?:click|double[_ -]?click|tap|press|select|submit|confirm|activate)/i;
83
+ const SCREEN_OBSERVATION_PATTERN = /(?:screenshot|snapshot)/i;
84
+
85
+ // A process principal cannot be selected through a tool call. Operators that
86
+ // need an approved purchase to survive separate MCP/hook processes must set a
87
+ // unique THUMBGATE_RUNTIME_PRINCIPAL_ID in the trusted host configuration.
88
+ const RUNTIME_PRINCIPAL = Object.freeze({
89
+ id: String(process.env.THUMBGATE_RUNTIME_PRINCIPAL_ID || '').trim()
90
+ || `runtime_${crypto.randomUUID()}`,
91
+ kind: 'agent',
92
+ });
93
+
94
+ function getRuntimePrincipal() {
95
+ return { ...RUNTIME_PRINCIPAL };
96
+ }
97
+
98
+ function getLedgerPath(options = {}) {
99
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, LEDGER_FILE);
100
+ }
101
+
102
+ function getLedgerHeadPath(options = {}) {
103
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.head.json`;
104
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, LEDGER_HEAD_FILE);
105
+ }
106
+
107
+ function getLedgerJournalPath(options = {}) {
108
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.journal.json`;
109
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, LEDGER_JOURNAL_FILE);
110
+ }
111
+
112
+ function financialLedgerId(options = {}) {
113
+ const configured = optionalString(options.financialLedgerId);
114
+ if (configured) return configured;
115
+ return `ledger_${crypto.createHash('sha256').update(getLedgerPath(options)).digest('hex')}`;
116
+ }
117
+
118
+ /**
119
+ * Return the operator-owned monotonic anchor store. The store is deliberately
120
+ * injected by the trusted host instead of selected through tool input. Its
121
+ * state must live outside the agent-writable device filesystem (for example a
122
+ * remote compare-and-set service or a hardware-backed host daemon).
123
+ *
124
+ * A file-backed implementation is available only behind an explicit test-only
125
+ * switch. It exercises crash recovery but is not rollback-resistant and must
126
+ * never be treated as a production control.
127
+ */
128
+ function financialLedgerAnchorStore(options = {}) {
129
+ const store = options.financialLedgerAnchorStore;
130
+ if (store && typeof store.read === 'function' && typeof store.compareAndSet === 'function') {
131
+ return store;
132
+ }
133
+ const remoteStore = remoteFinancialLedgerAnchorStore(options);
134
+ if (remoteStore) return remoteStore;
135
+ const testPath = optionalString(process.env.THUMBGATE_TEST_ONLY_FINANCIAL_ANCHOR_FILE);
136
+ if (testPath && process.env.THUMBGATE_ALLOW_UNTRUSTED_FILE_ANCHOR_FOR_TESTS === '1') {
137
+ return testOnlyFileAnchorStore(path.resolve(testPath));
138
+ }
139
+ throw financialError('rollback-resistant financial ledger anchor is required');
140
+ }
141
+
142
+ /**
143
+ * Resolve production financial-control configuration from the trusted host.
144
+ * Tool input is deliberately ignored: an agent cannot select the endpoint,
145
+ * bearer credential, or transport used to protect the monotonic checkpoint.
146
+ *
147
+ * The remote service contract is one authenticated POST endpoint accepting:
148
+ * { operation: "read", ledgerId }
149
+ * { operation: "compareAndSet", ledgerId, expected, next }
150
+ * It must perform compare-and-set atomically and reject sequence rollback.
151
+ */
152
+ function getFinancialControlRuntimeOptions(options = {}) {
153
+ if (options.financialLedgerAnchorStore) return { ...options };
154
+ const remoteStore = remoteFinancialLedgerAnchorStore(options);
155
+ return remoteStore
156
+ ? { ...options, financialLedgerAnchorStore: remoteStore }
157
+ : { ...options };
158
+ }
159
+
160
+ function remoteFinancialLedgerAnchorStore(options = {}) {
161
+ const url = optionalString(options.financialLedgerAnchorUrl)
162
+ || optionalString(process.env.THUMBGATE_FINANCIAL_ANCHOR_URL);
163
+ if (!url) return null;
164
+ const token = optionalString(options.financialLedgerAnchorToken)
165
+ || optionalString(process.env.THUMBGATE_FINANCIAL_ANCHOR_TOKEN);
166
+ if (!token) throw financialError('THUMBGATE_FINANCIAL_ANCHOR_TOKEN is required for the remote financial anchor');
167
+ assertTrustedAnchorUrl(url, options);
168
+ const request = typeof options.financialLedgerAnchorRequest === 'function'
169
+ ? options.financialLedgerAnchorRequest
170
+ : requestRemoteFinancialAnchor;
171
+ const invoke = (payload) => request({ url, token, payload, timeoutMs: options.financialLedgerAnchorTimeoutMs });
172
+ return {
173
+ read({ ledgerId }) {
174
+ const response = invoke({ operation: 'read', ledgerId });
175
+ if (response?.ok !== true || !Object.hasOwn(response, 'anchor')) {
176
+ throw financialError('remote financial anchor returned an invalid read response');
177
+ }
178
+ return response.anchor;
179
+ },
180
+ compareAndSet({ ledgerId, expected, next }) {
181
+ const response = invoke({ operation: 'compareAndSet', ledgerId, expected, next });
182
+ if (response?.ok !== true || typeof response.applied !== 'boolean') {
183
+ throw financialError('remote financial anchor returned an invalid compare-and-set response');
184
+ }
185
+ return response.applied;
186
+ },
187
+ };
188
+ }
189
+
190
+ function assertTrustedAnchorUrl(value, options = {}) {
191
+ let url;
192
+ try {
193
+ url = new URL(value);
194
+ } catch {
195
+ throw financialError('THUMBGATE_FINANCIAL_ANCHOR_URL must be an absolute URL');
196
+ }
197
+ const allowHttpForTests = options.allowHttpFinancialAnchorForTests === true
198
+ || process.env.THUMBGATE_ALLOW_HTTP_FINANCIAL_ANCHOR_FOR_TESTS === '1';
199
+ if (url.protocol !== 'https:' && !(allowHttpForTests && url.protocol === 'http:')) {
200
+ throw financialError('remote financial anchor requires HTTPS');
201
+ }
202
+ }
203
+
204
+ function requestRemoteFinancialAnchor({ url, token, payload, timeoutMs }) {
205
+ const childSource = [
206
+ "'use strict';",
207
+ "const fs = require('node:fs');",
208
+ "const payload = fs.readFileSync(0, 'utf8');",
209
+ "const controller = new AbortController();",
210
+ "const timer = setTimeout(() => controller.abort(), Number(process.env.THUMBGATE_ANCHOR_TIMEOUT_MS));",
211
+ "fetch(process.env.THUMBGATE_ANCHOR_URL, {",
212
+ " method: 'POST',",
213
+ " headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.THUMBGATE_ANCHOR_TOKEN}` },",
214
+ " body: payload,",
215
+ " signal: controller.signal,",
216
+ "}).then(async (response) => {",
217
+ " const body = await response.text();",
218
+ " process.stdout.write(JSON.stringify({ status: response.status, ok: response.ok, body }));",
219
+ "}).catch((error) => {",
220
+ " process.stderr.write(error.message);",
221
+ " process.exitCode = 1;",
222
+ "}).finally(() => clearTimeout(timer));",
223
+ ].join('\n');
224
+ const timeout = normalizeAnchorTimeout(timeoutMs);
225
+ const result = spawnSync(process.execPath, ['-e', childSource], {
226
+ input: `${JSON.stringify(payload)}\n`,
227
+ encoding: 'utf8',
228
+ timeout: timeout + 1_000,
229
+ maxBuffer: 1024 * 1024,
230
+ env: {
231
+ ...process.env,
232
+ THUMBGATE_ANCHOR_URL: url,
233
+ THUMBGATE_ANCHOR_TOKEN: token,
234
+ THUMBGATE_ANCHOR_TIMEOUT_MS: String(timeout),
235
+ },
236
+ });
237
+ if (result.error || result.status !== 0) {
238
+ const detail = optionalString(result.stderr) || result.error?.message || `exit ${result.status}`;
239
+ throw financialError(`remote financial anchor request failed: ${detail}`);
240
+ }
241
+ let envelope;
242
+ try {
243
+ envelope = JSON.parse(result.stdout);
244
+ } catch {
245
+ throw financialError('remote financial anchor returned a malformed transport response');
246
+ }
247
+ if (!envelope.ok) {
248
+ throw financialError(`remote financial anchor rejected the request with HTTP ${envelope.status}`);
249
+ }
250
+ try {
251
+ return JSON.parse(envelope.body);
252
+ } catch {
253
+ throw financialError('remote financial anchor returned malformed JSON');
254
+ }
255
+ }
256
+
257
+ function normalizeAnchorTimeout(value) {
258
+ const configured = Number(value ?? process.env.THUMBGATE_FINANCIAL_ANCHOR_TIMEOUT_MS ?? 5_000);
259
+ if (!Number.isFinite(configured) || configured < 250 || configured > 30_000) {
260
+ throw financialError('financial anchor timeout must be between 250 and 30000 milliseconds');
261
+ }
262
+ return Math.floor(configured);
263
+ }
264
+
265
+ function testOnlyFileAnchorStore(anchorPath) {
266
+ return {
267
+ read() {
268
+ try {
269
+ return JSON.parse(fs.readFileSync(anchorPath, 'utf8'));
270
+ } catch (error) {
271
+ if (error.code === 'ENOENT') return null;
272
+ throw error;
273
+ }
274
+ },
275
+ compareAndSet({ expected, next }) {
276
+ let current = null;
277
+ try {
278
+ current = JSON.parse(fs.readFileSync(anchorPath, 'utf8'));
279
+ } catch (error) {
280
+ if (error.code !== 'ENOENT') throw error;
281
+ }
282
+ if (!sameHead(current, expected)) return false;
283
+ writeAtomicJson(anchorPath, next);
284
+ return true;
285
+ },
286
+ };
287
+ }
288
+
289
+ function readFinancialLedgerAnchor(options = {}) {
290
+ const anchor = financialLedgerAnchorStore(options).read({
291
+ ledgerId: financialLedgerId(options),
292
+ });
293
+ if (anchor === null || anchor === undefined) return null;
294
+ if (anchor.schemaVersion !== LEDGER_ANCHOR_SCHEMA
295
+ || !Number.isInteger(anchor.sequence)
296
+ || anchor.sequence < 1
297
+ || !/^[a-f0-9]{64}$/i.test(String(anchor.eventHash || ''))) {
298
+ throw financialError('rollback-resistant financial ledger anchor is malformed');
299
+ }
300
+ return anchor;
301
+ }
302
+
303
+ function advanceFinancialLedgerAnchor(previousHead, event, options = {}) {
304
+ const store = financialLedgerAnchorStore(options);
305
+ const ledgerId = financialLedgerId(options);
306
+ const next = {
307
+ schemaVersion: LEDGER_ANCHOR_SCHEMA,
308
+ sequence: event.sequence,
309
+ eventHash: event.eventHash,
310
+ };
311
+ const current = readFinancialLedgerAnchor(options);
312
+ if (sameHead(current, next)) return next;
313
+ if (!sameHead(current, previousHead)) {
314
+ throw financialError('rollback-resistant financial ledger anchor rejected a stale checkpoint');
315
+ }
316
+ const advanced = store.compareAndSet({
317
+ ledgerId,
318
+ expected: previousHead
319
+ ? { schemaVersion: LEDGER_ANCHOR_SCHEMA, sequence: previousHead.sequence, eventHash: previousHead.eventHash }
320
+ : null,
321
+ next,
322
+ });
323
+ if (advanced !== true || !sameHead(readFinancialLedgerAnchor(options), next)) {
324
+ throw financialError('rollback-resistant financial ledger anchor compare-and-set failed');
325
+ }
326
+ return next;
327
+ }
328
+
329
+ function detectEconomicAction(toolName, toolInput = {}) {
330
+ const normalizedToolName = String(toolName || '').trim();
331
+ if ([...CONTROL_PLANE_TOOLS].some((name) => (
332
+ normalizedToolName === name || normalizedToolName.endsWith(`__${name}`)
333
+ ))) return false;
334
+ const metadata = objectValue(toolInput.metadata);
335
+ const financialControl = objectValue(toolInput.financialControl || toolInput.financial_control);
336
+ if (toolInput.economicAction === true || metadata.economicAction === true) return true;
337
+ if (financialControl.requisitionId || financialControl.reservationId) return true;
338
+ // Native browser/computer-use locators do not reveal what a click will do.
339
+ // Treat them as economic until the exact screen mutation is independently
340
+ // approved; caller-supplied prose must never downgrade a blind click.
341
+ if (detectOpaqueScreenMutation(normalizedToolName, toolInput)) return true;
342
+ const command = shellEconomicText(normalizedToolName, toolInput.command || toolInput.cmd);
343
+ const screenMutationProse = isDeclaredScreenMutation(normalizedToolName, toolInput)
344
+ ? [toolInput.goal, toolInput.description, toolInput.prompt, metadata.context]
345
+ : [];
346
+ const combined = [
347
+ normalizedToolName.replace(/[_-]+/g, ' '),
348
+ command,
349
+ toolInput.action,
350
+ toolInput.operation,
351
+ ...screenMutationProse,
352
+ ].map((value) => String(value || '')).join(' ');
353
+ return ECONOMIC_ACTION_PATTERNS.some((pattern) => pattern.test(combined));
354
+ }
355
+
356
+ function isDeclaredScreenMutation(toolName, toolInput = {}) {
357
+ if (!SCREEN_TOOL_PATTERN.test(String(toolName || ''))) return false;
358
+ const input = objectValue(toolInput);
359
+ const declaredOperation = [input.action, input.operation, input.type]
360
+ .map((value) => String(value || ''))
361
+ .join(' ');
362
+ return SCREEN_MUTATION_PATTERN.test(toolName) || SCREEN_MUTATION_PATTERN.test(declaredOperation);
363
+ }
364
+
365
+ function detectOpaqueScreenMutation(toolName, toolInput = {}) {
366
+ const normalizedToolName = String(toolName || '').trim();
367
+ const input = objectValue(toolInput);
368
+ const declaredOperation = [input.action, input.operation, input.type]
369
+ .map((value) => String(value || ''))
370
+ .join(' ');
371
+ const toolDeclaresMutation = SCREEN_MUTATION_PATTERN.test(normalizedToolName);
372
+ const declaredMutation = SCREEN_MUTATION_PATTERN.test(declaredOperation);
373
+ const toolDeclaresObservation = SCREEN_OBSERVATION_PATTERN.test(normalizedToolName);
374
+ const declaredObservation = SCREEN_OBSERVATION_PATTERN.test(declaredOperation);
375
+ if (toolDeclaresObservation || (!toolDeclaresMutation && !declaredMutation && declaredObservation)) {
376
+ return false;
377
+ }
378
+ const hasCoordinate = Object.hasOwn(input, 'coordinate') || Object.hasOwn(input, 'coordinates')
379
+ || (Object.hasOwn(input, 'x') && Object.hasOwn(input, 'y'));
380
+ const hasOpaqueLocator = hasCoordinate
381
+ || Object.hasOwn(input, 'selector')
382
+ || Object.hasOwn(input, 'element')
383
+ || Object.hasOwn(input, 'ref')
384
+ || Object.hasOwn(input, 'elementId')
385
+ || Object.hasOwn(input, 'element_id')
386
+ || Object.hasOwn(input, 'nodeId')
387
+ || Object.hasOwn(input, 'node_id')
388
+ || (Object.hasOwn(input, 'ref_id') && Object.hasOwn(input, 'id'));
389
+ if (!hasOpaqueLocator || !SCREEN_TOOL_PATTERN.test(normalizedToolName)) return false;
390
+ return toolDeclaresMutation || declaredMutation
391
+ || /(?:browser|computer|playwright|puppeteer|selenium)/i.test(normalizedToolName);
392
+ }
393
+
394
+ function shellEconomicText(toolName, rawCommand) {
395
+ if (!/^(?:bash|shell|terminal|execute_command|exec_command)$/i.test(toolName)) {
396
+ return rawCommand;
397
+ }
398
+ const command = String(rawCommand || '');
399
+ return command
400
+ .split(/(?:&&|\|\||[;\n]|(?<!\|)\|(?!\|))/)
401
+ .map((segment) => {
402
+ const clean = segment.trim().replace(/^(?:sudo\s+)?(?:env\s+)?(?:[A-Za-z_]\w*=[^\s]+\s+)*/, '');
403
+ const executable = /^([\w./-]+)/.exec(clean)?.[1]?.split('/').at(-1)?.toLowerCase();
404
+ if (['rg', 'grep', 'git', 'cat', 'head', 'tail', 'less', 'more', 'echo', 'printf'].includes(executable)) return '';
405
+ return clean;
406
+ })
407
+ .join(' ');
408
+ }
409
+
410
+ function createPurchaseRequisition(input = {}, options = {}) {
411
+ rejectCallerSelectedRequester(input);
412
+ const now = options.now || new Date();
413
+ const requester = authenticatedPrincipal(options);
414
+ const taskId = requiredString(input.taskId, 'taskId');
415
+ const vendor = requiredString(input.vendor, 'vendor');
416
+ const purpose = requiredString(input.purpose, 'purpose');
417
+ const sourceMessageId = requiredString(input.sourceMessageId, 'sourceMessageId');
418
+ const amountUsd = positiveMoney(input.amountUsd, 'amountUsd');
419
+ const approvedAction = buildActionAuthorization(input.toolName, input.toolInput, amountUsd);
420
+ if (!detectEconomicAction(approvedAction.toolName, objectValue(input.toolInput))) {
421
+ throw financialError('purchase requisitions must bind an exact economic tool action');
422
+ }
423
+ const evidence = stringArray(input.evidence);
424
+ if (evidence.length === 0) throw financialError('evidence must contain at least one item');
425
+ const ttlMs = boundedTtl(input.ttlMs, DEFAULT_TTL_MS);
426
+ const idempotencyKey = requiredString(input.idempotencyKey || `${taskId}:${sourceMessageId}`, 'idempotencyKey');
427
+ const requestIntent = {
428
+ idempotencyKey,
429
+ taskId,
430
+ requester,
431
+ vendor,
432
+ purpose,
433
+ sourceMessageId,
434
+ amountUsd,
435
+ approvedToolName: approvedAction.toolName,
436
+ actionFingerprint: approvedAction.fingerprint,
437
+ evidence,
438
+ };
439
+ // The financial and escalation ledgers are separate durable resources. A
440
+ // retry after the escalation append but before the financial append must
441
+ // reproduce the same identity and signed digest instead of stranding the
442
+ // already-recorded approval behind an idempotency conflict.
443
+ const requisitionId = input.requisitionId || stableRequisitionId(requestIntent);
444
+ const approvalContextDigest = requestComparableHash({ requisitionId, ...requestIntent });
445
+ return withLedgerLock(options, () => {
446
+ const ledger = readLedger(options);
447
+ const chain = validateLedgerChain(ledger.events, ledger.malformedRows, ledger.head, options);
448
+ if (!chain.ok) throw financialError('financial ledger integrity verification failed');
449
+ const existing = requisitionsFromEvents(ledger.events, options)
450
+ .find((entry) => entry.idempotencyKey === idempotencyKey);
451
+ if (existing) {
452
+ if (requestIntentHash(existing) !== requestIntentHash(requestIntent)
453
+ || (input.requisitionId && input.requisitionId !== existing.requisitionId)) {
454
+ const error = financialError(`conflicting requisition for idempotency key '${idempotencyKey}'`);
455
+ error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
456
+ throw error;
457
+ }
458
+ return { recorded: false, duplicate: true, requisition: existing };
459
+ }
460
+ const escalationResult = requestEscalation({
461
+ taskId,
462
+ reason: `Purchase approval required: $${amountUsd.toFixed(2)} USD to ${vendor} for ${purpose}; exact action ${approvedAction.fingerprint}`,
463
+ severity: 'critical',
464
+ requester,
465
+ evidence: [
466
+ ...evidence,
467
+ `sourceMessageId:${sourceMessageId}`,
468
+ `requisitionId:${requisitionId}`,
469
+ ],
470
+ approvalContextDigest,
471
+ ttlMs,
472
+ idempotencyKey: `purchase:${idempotencyKey}`,
473
+ }, options);
474
+ const recorded = appendEventUnlocked({
475
+ schemaVersion: 'financial-control-v2',
476
+ eventType: 'requested',
477
+ status: 'pending_approval',
478
+ requisitionId,
479
+ escalationId: escalationResult.escalation.escalationId,
480
+ idempotencyKey,
481
+ taskId,
482
+ requester,
483
+ vendor,
484
+ purpose,
485
+ sourceMessageId,
486
+ amountUsd,
487
+ approvedToolName: approvedAction.toolName,
488
+ actionFingerprint: approvedAction.fingerprint,
489
+ approvalContextDigest,
490
+ currency: 'USD',
491
+ evidence,
492
+ requestedAt: now.toISOString(),
493
+ // Cross-ledger retries must retain the deadline of the original human
494
+ // escalation. Refreshing the financial deadline here would let a retry
495
+ // resurrect an approval whose signed request already expired.
496
+ expiresAt: escalationResult.escalation.expiresAt,
497
+ }, options, ledger.events);
498
+ return {
499
+ recorded: true,
500
+ duplicate: false,
501
+ requisition: projectRequisitionFromEvents([...ledger.events, recorded], requisitionId, options),
502
+ };
503
+ });
504
+ }
505
+
506
+ function reservePurchaseRequisition(input = {}, options = {}) {
507
+ rejectCallerSelectedRequester(input);
508
+ const now = options.now || new Date();
509
+ const requester = authenticatedPrincipal(options);
510
+ const requisitionId = requiredString(input.requisitionId, 'requisitionId');
511
+ return withLedgerLock(options, () => {
512
+ const ledger = readLedger(options);
513
+ assertLedgerHealthyData(ledger, options);
514
+ const requisition = projectRequisitionFromEvents(ledger.events, requisitionId, options);
515
+ if (!requisition) throw financialError(`unknown requisition '${requisitionId}'`);
516
+ assertPrincipalOwnsRequisition(requester, requisition);
517
+ const escalation = verifyPurchaseApprovalBinding(requisition, options);
518
+ if (Date.parse(requisition.expiresAt) <= now.getTime()) {
519
+ throw financialError(`requisition '${requisitionId}' is expired`);
520
+ }
521
+ if (['reserved', 'authorized', 'committed'].includes(requisition.status)) {
522
+ const requestedKey = optionalString(input.idempotencyKey);
523
+ if (requestedKey && requestedKey === requisition.reservationIdempotencyKey) {
524
+ return { recorded: false, duplicate: true, requisition };
525
+ }
526
+ throw financialError(`requisition '${requisitionId}' is already ${requisition.status}`);
527
+ }
528
+ if (requisition.status === 'released') {
529
+ throw financialError(`requisition '${requisitionId}' was released and cannot be reused`);
530
+ }
531
+ if (!requisition.actionFingerprint || !requisition.approvedToolName) {
532
+ throw financialError(`requisition '${requisitionId}' predates exact-action authorization and cannot be reserved`);
533
+ }
534
+
535
+ const amountUsd = positiveMoney(input.amountUsd ?? requisition.amountUsd, 'amountUsd');
536
+ if (amountUsd > requisition.amountUsd) {
537
+ throw financialError(`reservation $${amountUsd.toFixed(2)} exceeds approved amount $${requisition.amountUsd.toFixed(2)}`);
538
+ }
539
+ assertScopeMatches(input, requisition);
540
+ const ttlMs = boundedTtl(input.ttlMs, DEFAULT_RESERVATION_TTL_MS);
541
+ const recorded = appendEventUnlocked({
542
+ schemaVersion: 'financial-control-v2',
543
+ eventType: 'reserved',
544
+ status: 'reserved',
545
+ requisitionId,
546
+ reservationId: input.reservationId || `res_${crypto.randomUUID()}`,
547
+ reservationIdempotencyKey: requiredString(input.idempotencyKey || `${requisitionId}:reserve`, 'idempotencyKey'),
548
+ requester,
549
+ approvedBy: escalation.actor,
550
+ approvalReason: escalation.reason,
551
+ vendor: requisition.vendor,
552
+ purpose: requisition.purpose,
553
+ sourceMessageId: requisition.sourceMessageId,
554
+ approvedToolName: requisition.approvedToolName,
555
+ actionFingerprint: requisition.actionFingerprint,
556
+ amountUsd,
557
+ currency: 'USD',
558
+ reservedAt: now.toISOString(),
559
+ reservationExpiresAt: new Date(now.getTime() + ttlMs).toISOString(),
560
+ }, options, ledger.events);
561
+ return {
562
+ recorded: true,
563
+ duplicate: false,
564
+ requisition: projectRequisitionFromEvents([...ledger.events, recorded], requisitionId, options),
565
+ };
566
+ });
567
+ }
568
+
569
+ function settlePurchaseRequisition(input = {}, options = {}) {
570
+ rejectCallerSelectedRequester(input);
571
+ const now = options.now || new Date();
572
+ const requester = authenticatedPrincipal(options);
573
+ const requisitionId = requiredString(input.requisitionId, 'requisitionId');
574
+ const reservationId = requiredString(input.reservationId, 'reservationId');
575
+ const status = requiredString(input.status, 'status');
576
+ if (!SETTLEMENT_STATUSES.has(status)) {
577
+ throw financialError('status must be committed or released');
578
+ }
579
+ return withLedgerLock(options, () => {
580
+ const ledger = readLedger(options);
581
+ assertLedgerHealthyData(ledger, options);
582
+ const requisition = projectRequisitionFromEvents(ledger.events, requisitionId, options);
583
+ if (!requisition) throw financialError(`unknown requisition '${requisitionId}'`);
584
+ const allowedStates = status === 'committed' ? ['authorized'] : ['reserved', 'authorized'];
585
+ if (!allowedStates.includes(requisition.status)) {
586
+ throw financialError(`requisition '${requisitionId}' is ${requisition.status}, not ${allowedStates.join(' or ')}`);
587
+ }
588
+ if (reservationId !== requisition.reservationId) {
589
+ throw financialError('reservationId does not match the active reservation');
590
+ }
591
+ assertPrincipalOwnsRequisition(requester, requisition);
592
+ verifyPurchaseApprovalBinding(requisition, options);
593
+ assertReservationBoundToApproval(requisition);
594
+
595
+ const base = {
596
+ schemaVersion: 'financial-control-v2',
597
+ eventType: status,
598
+ status,
599
+ requisitionId,
600
+ reservationId,
601
+ requester,
602
+ settledAt: now.toISOString(),
603
+ };
604
+ if (status === 'committed') {
605
+ const actualAmountUsd = nonNegativeMoney(input.actualAmountUsd, 'actualAmountUsd');
606
+ if (actualAmountUsd > requisition.reservedAmountUsd) {
607
+ throw financialError(`actual amount $${actualAmountUsd.toFixed(2)} exceeds reservation $${requisition.reservedAmountUsd.toFixed(2)}`);
608
+ }
609
+ const evidence = stringArray(input.evidence);
610
+ if (evidence.length === 0) throw financialError('committed spend requires receipt evidence');
611
+ Object.assign(base, { actualAmountUsd, currency: 'USD', evidence });
612
+ } else {
613
+ base.reason = requiredString(input.reason, 'reason');
614
+ }
615
+ const recorded = appendEventUnlocked(base, options, ledger.events);
616
+ return {
617
+ recorded: true,
618
+ requisition: projectRequisitionFromEvents([...ledger.events, recorded], requisitionId, options),
619
+ };
620
+ });
621
+ }
622
+
623
+ function listPurchaseRequisitions(options = {}) {
624
+ const events = readEvents(options);
625
+ return requisitionsFromEvents(events, options);
626
+ }
627
+
628
+ function requisitionsFromEvents(events, options = {}) {
629
+ const ids = [...new Set(events.map((event) => event.requisitionId).filter(Boolean))];
630
+ return ids
631
+ .map((id) => projectRequisitionFromEvents(events, id, options))
632
+ .filter(Boolean)
633
+ .sort((left, right) => Date.parse(right.requestedAt) - Date.parse(left.requestedAt));
634
+ }
635
+
636
+ function projectRequisition(requisitionId, options = {}) {
637
+ return projectRequisitionFromEvents(readEvents(options), requisitionId, options);
638
+ }
639
+
640
+ function projectRequisitionFromEvents(allEvents, requisitionId, options = {}) {
641
+ const events = allEvents.filter((event) => event.requisitionId === requisitionId);
642
+ const requested = events.find((event) => event.eventType === 'requested');
643
+ if (!requested) return null;
644
+ const now = options.now || new Date();
645
+ const state = { ...requested, timeline: events };
646
+ applyEscalationState(state, requested, now, options);
647
+ for (const event of events.slice(1)) applyFinancialEvent(state, event, now);
648
+ return state;
649
+ }
650
+
651
+ function applyEscalationState(state, requested, now, options) {
652
+ const escalation = getEscalation(requested.escalationId, options);
653
+ if (escalation?.status === 'approved') {
654
+ Object.assign(state, {
655
+ status: 'approved',
656
+ approvedBy: escalation.actor,
657
+ approvalReason: escalation.reason,
658
+ approvedAt: escalation.decidedAt,
659
+ });
660
+ } else if (escalation?.status === 'rejected') {
661
+ state.status = 'rejected';
662
+ } else if (escalation?.status === 'cancelled') {
663
+ state.status = 'cancelled';
664
+ } else if (Date.parse(requested.expiresAt) <= now.getTime() || escalation?.status === 'expired') {
665
+ state.status = 'expired';
666
+ }
667
+ }
668
+
669
+ function applyFinancialEvent(state, event, now) {
670
+ if (event.eventType === 'reserved') {
671
+ Object.assign(state, {
672
+ status: Date.parse(event.reservationExpiresAt) <= now.getTime() ? 'reservation_expired' : 'reserved',
673
+ reservationId: event.reservationId,
674
+ reservationIdempotencyKey: event.reservationIdempotencyKey,
675
+ reservedAmountUsd: event.amountUsd,
676
+ reservedAt: event.reservedAt,
677
+ reservationExpiresAt: event.reservationExpiresAt,
678
+ });
679
+ } else if (event.eventType === 'authorized') {
680
+ Object.assign(state, {
681
+ status: 'authorized',
682
+ authorizationId: event.authorizationId,
683
+ actionId: event.actionId,
684
+ authorizedAt: event.authorizedAt,
685
+ authorizedAmountUsd: event.estimatedCostUsd,
686
+ });
687
+ } else if (event.eventType === 'committed') {
688
+ Object.assign(state, {
689
+ status: 'committed',
690
+ actualAmountUsd: event.actualAmountUsd,
691
+ settlementEvidence: event.evidence,
692
+ settledAt: event.settledAt,
693
+ });
694
+ } else if (event.eventType === 'released') {
695
+ Object.assign(state, {
696
+ status: 'released',
697
+ releaseReason: event.reason,
698
+ settledAt: event.settledAt,
699
+ });
700
+ }
701
+ }
702
+
703
+ function reconcilePurchaseLedger(options = {}) {
704
+ return withLedgerLock(options, () => reconcilePurchaseLedgerUnlocked(options));
705
+ }
706
+
707
+ function reconcilePurchaseLedgerUnlocked(options = {}) {
708
+ const ledger = readLedger(options);
709
+ const chain = validateLedgerChain(ledger.events, ledger.malformedRows, ledger.head, options);
710
+ const requisitions = listPurchaseRequisitions(options);
711
+ const staleReservations = requisitions
712
+ .filter((entry) => entry.status === 'reservation_expired')
713
+ .map((entry) => entry.requisitionId);
714
+ const totals = requisitions.reduce((acc, entry) => {
715
+ if (entry.status === 'reserved') acc.reservedUsd += entry.reservedAmountUsd || 0;
716
+ if (entry.status === 'authorized') acc.authorizedUsd += entry.authorizedAmountUsd || 0;
717
+ if (entry.status === 'committed') acc.committedUsd += entry.actualAmountUsd || 0;
718
+ if (entry.approvedBy) acc.approvedUsd += entry.amountUsd || 0;
719
+ return acc;
720
+ }, { approvedUsd: 0, reservedUsd: 0, authorizedUsd: 0, committedUsd: 0 });
721
+ return {
722
+ schemaVersion: 'financial-reconciliation-v2',
723
+ generatedAt: (options.now || new Date()).toISOString(),
724
+ ok: chain.ok && staleReservations.length === 0,
725
+ eventCount: ledger.events.length,
726
+ requisitionCount: requisitions.length,
727
+ totals: mapMoney(totals),
728
+ statusCounts: requisitions.reduce((acc, entry) => {
729
+ acc[entry.status] = (acc[entry.status] || 0) + 1;
730
+ return acc;
731
+ }, {}),
732
+ staleReservations,
733
+ malformedRows: ledger.malformedRows,
734
+ invalidEventHashes: chain.invalidEventHashes,
735
+ invalidChainLinks: chain.invalidChainLinks,
736
+ ledgerHeadMismatches: chain.ledgerHeadMismatches,
737
+ };
738
+ }
739
+
740
+ function evaluateFinancialControl(input = {}, options = {}) {
741
+ const actionProfile = objectValue(input.actionProfile);
742
+ const economicAction = actionProfile.economicAction === true
743
+ || detectEconomicAction(input.toolName, input.toolInput);
744
+ if (!economicAction) return allowNonEconomicAction();
745
+
746
+ const result = financialEvaluationContext(input, options);
747
+ validateBudget(result);
748
+ validateAttachedControl(result);
749
+ validateRequisition(result, options);
750
+ validateCostControl(result);
751
+ consumeReservationIfAuthorized(result, input, options);
752
+ return buildFinancialDecision(result);
753
+ }
754
+
755
+ function allowNonEconomicAction() {
756
+ return { mode: 'allow', economicAction: false, reasons: [], reasonCodes: [] };
757
+ }
758
+
759
+ function financialEvaluationContext(input, options) {
760
+ const toolInput = objectValue(input.toolInput);
761
+ const control = objectValue(toolInput.financialControl || toolInput.financial_control || input.financialControl);
762
+ return {
763
+ input,
764
+ toolInput,
765
+ control,
766
+ budget: objectValue(input.costControl?.budget || input.budget),
767
+ estimatedCostUsd: finiteNumber(input.costControl?.usage?.estimatedCostUsd, 0),
768
+ requisitionId: optionalString(control.requisitionId),
769
+ reservationId: optionalString(control.reservationId),
770
+ actionId: optionalString(control.actionId),
771
+ actionBinding: buildActionAuthorization(input.toolName, toolInput, finiteNumber(input.costControl?.usage?.estimatedCostUsd, 0)),
772
+ principal: authenticatedPrincipal(options),
773
+ requisition: null,
774
+ authorization: null,
775
+ reasons: [],
776
+ reasonCodes: [],
777
+ };
778
+ }
779
+
780
+ function addBlock(result, code, message) {
781
+ result.reasonCodes.push(code);
782
+ result.reasons.push(message);
783
+ }
784
+
785
+ function validateBudget(result) {
786
+ const { budget, estimatedCostUsd } = result;
787
+ const hasCostBudget = budget.hasMaxCostUsdPerAction === true || budget.hasRemainingCostUsd === true;
788
+ if (!hasCostBudget) addBlock(result, 'missing_financial_budget', 'Economic actions require an explicit USD budget.');
789
+ if ((budget.hasMaxCostUsdPerAction && budget.maxCostUsdPerAction === 0)
790
+ || (budget.hasRemainingCostUsd && budget.remainingCostUsd === 0)) {
791
+ addBlock(result, 'zero_spend_budget', 'Configured USD budget is $0.00; spending is prohibited.');
792
+ }
793
+ if (estimatedCostUsd <= 0) {
794
+ addBlock(result, 'missing_cost_estimate', 'Economic actions require a positive, explicit cost estimate before approval.');
795
+ }
796
+ }
797
+
798
+ function validateAttachedControl(result) {
799
+ if (!result.requisitionId) {
800
+ addBlock(result, 'missing_purchase_requisition', 'No purchase requisition is attached to this action.');
801
+ }
802
+ if (!result.reservationId) {
803
+ addBlock(result, 'missing_budget_reservation', 'No approved budget reservation is attached to this action.');
804
+ }
805
+ if (!result.actionId) {
806
+ addBlock(result, 'missing_action_id', 'A unique actionId is required to consume a financial reservation.');
807
+ }
808
+ }
809
+
810
+ function reconciliationHasIntegrityFailure(reconciliation) {
811
+ return !reconciliation.ok && (
812
+ reconciliation.invalidEventHashes.length > 0
813
+ || reconciliation.invalidChainLinks.length > 0
814
+ || reconciliation.ledgerHeadMismatches.length > 0
815
+ || reconciliation.malformedRows.length > 0
816
+ );
817
+ }
818
+
819
+ function blockLedgerIntegrityFailures(result, reconciliation) {
820
+ const anchorUnavailable = reconciliation.ledgerHeadMismatches.some(
821
+ (entry) => optionalString(entry.rollbackResistantAnchorError)
822
+ );
823
+ const independentTampering = reconciliation.invalidEventHashes.length > 0
824
+ || reconciliation.invalidChainLinks.length > 0
825
+ || reconciliation.malformedRows.length > 0
826
+ || reconciliation.ledgerHeadMismatches.some(
827
+ (entry) => !optionalString(entry.rollbackResistantAnchorError)
828
+ );
829
+ if (independentTampering) {
830
+ addBlock(result, 'financial_ledger_tampered', 'Financial ledger integrity verification failed.');
831
+ }
832
+ if (anchorUnavailable) {
833
+ addBlock(
834
+ result,
835
+ 'financial_ledger_anchor_unavailable',
836
+ 'Rollback-resistant financial ledger anchor is unavailable; financial actions fail closed.'
837
+ );
838
+ }
839
+ }
840
+
841
+ function validateRequisitionBindings(result, requisition, options) {
842
+ try {
843
+ verifyPurchaseApprovalBinding(requisition, options);
844
+ } catch (error) {
845
+ addBlock(result, 'financial_approval_binding_invalid', error.message);
846
+ }
847
+ try {
848
+ assertReservationBoundToApproval(requisition);
849
+ } catch (error) {
850
+ addBlock(result, 'reservation_not_bound_to_approval', error.message);
851
+ }
852
+ }
853
+
854
+ function validateRequisitionConstraints(result, requisition) {
855
+ if (requisition.status !== 'reserved') {
856
+ addBlock(result, 'requisition_not_reserved', `Purchase requisition '${result.requisitionId}' is ${requisition.status}, not reserved.`);
857
+ }
858
+ if (result.reservationId && requisition.reservationId !== result.reservationId) {
859
+ addBlock(result, 'reservation_mismatch', 'Attached reservation does not match the requisition ledger.');
860
+ }
861
+ if (!sameIdentity(result.principal, requisition.requester)) {
862
+ addBlock(result, 'runtime_principal_mismatch', 'Authenticated runtime principal does not own this purchase requisition.');
863
+ }
864
+ if (!requisition.approvedBy || sameIdentity(requisition.requester, requisition.approvedBy)) {
865
+ addBlock(result, 'independent_approval_missing', 'An independently authenticated human approval is required.');
866
+ }
867
+ if (result.estimatedCostUsd > (requisition.reservedAmountUsd || 0)) {
868
+ addBlock(result, 'reservation_amount_exceeded', `Estimated cost $${result.estimatedCostUsd.toFixed(2)} exceeds the reserved amount.`);
869
+ }
870
+ if (!requisition.actionFingerprint
871
+ || result.actionBinding.fingerprint !== requisition.actionFingerprint) {
872
+ addBlock(result, 'financial_action_mismatch', 'Actual tool action or USD amount does not match the independently approved action fingerprint.');
873
+ }
874
+ if (normalizeToolName(result.actionBinding.toolName) !== normalizeToolName(requisition.approvedToolName)) {
875
+ addBlock(result, 'financial_tool_mismatch', 'Actual financial tool does not match the approved tool.');
876
+ }
877
+ }
878
+
879
+ function validateRequisition(result, options) {
880
+ const reconciliation = reconcilePurchaseLedger(options);
881
+ if (reconciliationHasIntegrityFailure(reconciliation)) {
882
+ blockLedgerIntegrityFailures(result, reconciliation);
883
+ return;
884
+ }
885
+ if (!result.requisitionId) return;
886
+ const requisition = projectRequisition(result.requisitionId, options);
887
+ result.requisition = requisition;
888
+ if (!requisition) {
889
+ addBlock(result, 'unknown_purchase_requisition', `Purchase requisition '${result.requisitionId}' does not exist.`);
890
+ return;
891
+ }
892
+ validateRequisitionBindings(result, requisition, options);
893
+ validateRequisitionConstraints(result, requisition);
894
+ validateScope(result, requisition);
895
+ }
896
+
897
+ function validateScope(result, requisition) {
898
+ for (const field of ['vendor', 'purpose', 'sourceMessageId']) {
899
+ const supplied = optionalString(result.control[field]);
900
+ if (!supplied || normalizeScope(supplied) !== normalizeScope(requisition[field])) {
901
+ addBlock(result, `${field}_mismatch`, `${field} must exactly match the approved requisition scope.`);
902
+ }
903
+ }
904
+ }
905
+
906
+ function validateCostControl(result) {
907
+ if (result.input.costControl?.mode !== 'block') return;
908
+ const reasons = result.input.costControl.reasons;
909
+ addBlock(
910
+ result,
911
+ 'cost_control_block',
912
+ Array.isArray(reasons) && reasons.length > 0
913
+ ? reasons.join(' ')
914
+ : 'Configured cost control blocked this action.'
915
+ );
916
+ }
917
+
918
+ function consumeReservationIfAuthorized(result, input, options) {
919
+ if (result.reasonCodes.length > 0 || options.consumeReservation !== true) return;
920
+ try {
921
+ result.authorization = consumeReservation({
922
+ requisitionId: result.requisitionId,
923
+ reservationId: result.reservationId,
924
+ actionId: result.actionId,
925
+ estimatedCostUsd: result.estimatedCostUsd,
926
+ principal: result.principal,
927
+ toolName: input.toolName,
928
+ actionFingerprint: result.actionBinding.fingerprint,
929
+ vendor: result.control.vendor,
930
+ purpose: result.control.purpose,
931
+ sourceMessageId: result.control.sourceMessageId,
932
+ }, options);
933
+ result.requisition = result.authorization;
934
+ } catch (error) {
935
+ addBlock(result, 'reservation_consumption_failed', error.message);
936
+ }
937
+ }
938
+
939
+ function consumeReservation(input, options) {
940
+ return withLedgerLock(options, () => {
941
+ const ledger = readLedger(options);
942
+ const chain = validateLedgerChain(ledger.events, ledger.malformedRows, ledger.head, options);
943
+ if (!chain.ok) throw financialError('financial ledger integrity check failed before authorization');
944
+ const current = projectRequisitionFromEvents(ledger.events, input.requisitionId, options);
945
+ if (current?.status !== 'reserved') {
946
+ throw financialError(`purchase requisition '${input.requisitionId}' is not available for single-use authorization`);
947
+ }
948
+ if (current.reservationId !== input.reservationId) {
949
+ throw financialError('reservation changed before authorization');
950
+ }
951
+ assertPrincipalOwnsRequisition(input.principal, current);
952
+ verifyPurchaseApprovalBinding(current, options);
953
+ assertReservationBoundToApproval(current);
954
+ if (input.estimatedCostUsd > current.reservedAmountUsd) {
955
+ throw financialError('estimated cost exceeds the signed purchase reservation');
956
+ }
957
+ if (input.actionFingerprint !== current.actionFingerprint) {
958
+ throw financialError('financial action changed before authorization');
959
+ }
960
+ if (normalizeToolName(input.toolName) !== normalizeToolName(current.approvedToolName)) {
961
+ throw financialError('financial tool changed before authorization');
962
+ }
963
+ assertScopeMatches(input, current);
964
+ appendEventUnlocked({
965
+ schemaVersion: 'financial-control-v2',
966
+ eventType: 'authorized',
967
+ status: 'authorized',
968
+ requisitionId: input.requisitionId,
969
+ reservationId: input.reservationId,
970
+ authorizationId: `auth_${crypto.randomUUID()}`,
971
+ actionId: input.actionId,
972
+ requester: input.principal,
973
+ toolName: String(input.toolName || 'unknown'),
974
+ actionFingerprint: input.actionFingerprint,
975
+ estimatedCostUsd: input.estimatedCostUsd,
976
+ currency: 'USD',
977
+ authorizedAt: (options.now || new Date()).toISOString(),
978
+ }, options, ledger.events);
979
+ return projectRequisition(input.requisitionId, options);
980
+ });
981
+ }
982
+
983
+ function buildFinancialDecision(result) {
984
+ const requisition = result.requisition;
985
+ return {
986
+ mode: result.reasonCodes.length > 0 ? 'block' : 'allow',
987
+ economicAction: true,
988
+ deterministic: true,
989
+ reasons: [...new Set(result.reasons)],
990
+ reasonCodes: [...new Set(result.reasonCodes)],
991
+ authorization: requisition ? {
992
+ requisitionId: requisition.requisitionId,
993
+ reservationId: requisition.reservationId || null,
994
+ authorizationId: requisition.authorizationId || null,
995
+ actionId: requisition.actionId || result.actionId || null,
996
+ status: requisition.status,
997
+ vendor: requisition.vendor,
998
+ purpose: requisition.purpose,
999
+ sourceMessageId: requisition.sourceMessageId,
1000
+ approvedAmountUsd: requisition.amountUsd,
1001
+ reservedAmountUsd: requisition.reservedAmountUsd || 0,
1002
+ approvedBy: requisition.approvedBy || null,
1003
+ actionFingerprint: requisition.actionFingerprint || null,
1004
+ approvedToolName: requisition.approvedToolName || null,
1005
+ } : null,
1006
+ };
1007
+ }
1008
+
1009
+ function assertScopeMatches(input, requisition) {
1010
+ for (const field of ['vendor', 'purpose', 'sourceMessageId']) {
1011
+ const value = requiredString(input[field] ?? requisition[field], field);
1012
+ if (normalizeScope(value) !== normalizeScope(requisition[field])) {
1013
+ throw financialError(`${field} does not match the approved requisition`);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ function readLedger(options = {}) {
1019
+ const inputPath = options.inputPath ? path.resolve(options.inputPath) : getLedgerPath(options);
1020
+ let raw;
1021
+ try {
1022
+ raw = fs.readFileSync(inputPath, 'utf8');
1023
+ } catch (error) {
1024
+ if (error.code !== 'ENOENT') {
1025
+ throw financialError(`cannot read financial ledger: ${error.message}`);
1026
+ }
1027
+ return { events: [], malformedRows: [], head: readLedgerHead(options) };
1028
+ }
1029
+ const events = [];
1030
+ const malformedRows = [];
1031
+ raw.split('\n').forEach((line, index) => {
1032
+ if (!line.trim()) return;
1033
+ try {
1034
+ events.push(JSON.parse(line));
1035
+ } catch {
1036
+ malformedRows.push(index + 1);
1037
+ }
1038
+ });
1039
+ return { events, malformedRows, head: readLedgerHead(options) };
1040
+ }
1041
+
1042
+ function readLedgerHead(options = {}) {
1043
+ try {
1044
+ return JSON.parse(fs.readFileSync(getLedgerHeadPath(options), 'utf8'));
1045
+ } catch (error) {
1046
+ if (error.code === 'ENOENT') return null;
1047
+ return { malformed: true };
1048
+ }
1049
+ }
1050
+
1051
+ function readEvents(options = {}) {
1052
+ return readLedger(options).events;
1053
+ }
1054
+
1055
+ function appendEventUnlocked(event, options, existingEvents) {
1056
+ const outputPath = getLedgerPath(options);
1057
+ const previous = existingEvents.at(-1) || null;
1058
+ const previousHead = previous
1059
+ ? { sequence: previous.sequence, eventHash: previous.eventHash }
1060
+ : null;
1061
+ // Resolve and verify the rollback-resistant witness before touching local
1062
+ // durable state. Missing production witness configuration therefore fails
1063
+ // closed without leaving a half-written financial transaction behind.
1064
+ const currentAnchor = readFinancialLedgerAnchor(options);
1065
+ if (!sameHead(currentAnchor, previousHead)) {
1066
+ throw financialError('rollback-resistant financial ledger anchor does not match the current ledger');
1067
+ }
1068
+ const chained = {
1069
+ ...event,
1070
+ sequence: existingEvents.length + 1,
1071
+ previousEventHash: previous?.eventHash || null,
1072
+ };
1073
+ chained.eventHash = hashEvent(chained);
1074
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1075
+ const journalPath = getLedgerJournalPath(options);
1076
+ const journal = {
1077
+ schemaVersion: LEDGER_JOURNAL_SCHEMA,
1078
+ previousHead,
1079
+ event: chained,
1080
+ };
1081
+ journal.auth = signIntegrityRecord(journal, ledgerIntegrityKey(options));
1082
+ writeAtomicJson(journalPath, journal);
1083
+ const ledgerFd = fs.openSync(outputPath, 'a', 0o600);
1084
+ try {
1085
+ fs.writeSync(ledgerFd, `${JSON.stringify(chained)}\n`, null, 'utf8');
1086
+ fs.fsyncSync(ledgerFd);
1087
+ } finally {
1088
+ fs.closeSync(ledgerFd);
1089
+ }
1090
+ fsyncDirectoryFor(outputPath);
1091
+ writeLedgerHeadFile(chained, options);
1092
+ advanceFinancialLedgerAnchor(previousHead, chained, options);
1093
+ removeDurableFile(journalPath);
1094
+ return chained;
1095
+ }
1096
+
1097
+ function writeLedgerHeadFile(event, options) {
1098
+ const headPath = getLedgerHeadPath(options);
1099
+ const temporaryPath = `${headPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1100
+ const head = {
1101
+ schemaVersion: LEDGER_HEAD_SCHEMA,
1102
+ sequence: event.sequence,
1103
+ eventHash: event.eventHash,
1104
+ };
1105
+ head.auth = signIntegrityRecord(head, ledgerIntegrityKey(options));
1106
+ writeAtomicJson(headPath, head, temporaryPath);
1107
+ }
1108
+
1109
+ function withLedgerLock(options, callback) {
1110
+ return withFileLedgerLock(`${getLedgerPath(options)}.lock`, callback, {
1111
+ now: options.now,
1112
+ lockStaleMs: options.lockStaleMs,
1113
+ errorFactory: (message) => financialError(message),
1114
+ beforeCallback: () => recoverLedgerTransaction(options),
1115
+ });
1116
+ }
1117
+
1118
+ function readLedgerJournal(journalPath) {
1119
+ try {
1120
+ return JSON.parse(fs.readFileSync(journalPath, 'utf8'));
1121
+ } catch (error) {
1122
+ if (error.code === 'ENOENT') return null;
1123
+ throw financialError(`cannot recover financial ledger journal: ${error.message}`);
1124
+ }
1125
+ }
1126
+
1127
+ function verifiedJournalEvent(journal, integrityKey) {
1128
+ const event = journal?.event;
1129
+ if (journal?.schemaVersion !== LEDGER_JOURNAL_SCHEMA
1130
+ || !event
1131
+ || event.eventHash !== hashEvent(event)
1132
+ || !verifyIntegrityRecord(journal, integrityKey)) {
1133
+ throw financialError('financial ledger journal integrity verification failed');
1134
+ }
1135
+ return event;
1136
+ }
1137
+
1138
+ function recoverAppendedLedgerEvent({
1139
+ ledger,
1140
+ event,
1141
+ previousHead,
1142
+ currentAnchor,
1143
+ headAtPrevious,
1144
+ headAtEvent,
1145
+ journalPath,
1146
+ options,
1147
+ }) {
1148
+ const preceding = ledger.events.at(-2) || null;
1149
+ const expectedPrevious = preceding
1150
+ ? { sequence: preceding.sequence, eventHash: preceding.eventHash }
1151
+ : null;
1152
+ const chain = validateLedgerChain(
1153
+ ledger.events,
1154
+ ledger.malformedRows,
1155
+ null,
1156
+ { ...options, skipLedgerHead: true }
1157
+ );
1158
+ const anchorAtPrevious = sameHead(currentAnchor, previousHead);
1159
+ const anchorAtEvent = sameHead(currentAnchor, { sequence: event.sequence, eventHash: event.eventHash });
1160
+ if (!sameHead(previousHead, expectedPrevious)
1161
+ || !chain.ok
1162
+ || (!headAtPrevious && !headAtEvent)
1163
+ || (!anchorAtPrevious && !anchorAtEvent)) {
1164
+ throw financialError('financial ledger journal does not match the recoverable append');
1165
+ }
1166
+ if (!headAtEvent) writeLedgerHeadFile(event, options);
1167
+ if (!anchorAtEvent) advanceFinancialLedgerAnchor(previousHead, event, options);
1168
+ removeDurableFile(journalPath);
1169
+ }
1170
+
1171
+ function canDiscardPreparedLedgerEvent({ ledger, event, previousHead, currentAnchor, headAtPrevious, options }) {
1172
+ const currentChain = validateLedgerChain(ledger.events, ledger.malformedRows, ledger.head, options);
1173
+ return currentChain.ok
1174
+ && headAtPrevious
1175
+ && sameHead(currentAnchor, previousHead)
1176
+ && event.sequence === ledger.events.length + 1;
1177
+ }
1178
+
1179
+ function recoverLedgerTransaction(options = {}) {
1180
+ const journalPath = getLedgerJournalPath(options);
1181
+ const journal = readLedgerJournal(journalPath);
1182
+ if (!journal) return;
1183
+ const integrityKey = ledgerIntegrityKey(options);
1184
+ const event = verifiedJournalEvent(journal, integrityKey);
1185
+ const ledger = readLedger(options);
1186
+ const currentLast = ledger.events.at(-1) || null;
1187
+ const previousHead = journal.previousHead;
1188
+ const currentHead = ledger.head;
1189
+ const currentAnchor = readFinancialLedgerAnchor(options);
1190
+ const eventAlreadyAppended = currentLast?.sequence === event.sequence
1191
+ && currentLast?.eventHash === event.eventHash;
1192
+ const headAtPrevious = sameHead(currentHead, previousHead);
1193
+ const headAtEvent = sameHead(currentHead, { sequence: event.sequence, eventHash: event.eventHash });
1194
+
1195
+ if (eventAlreadyAppended) {
1196
+ recoverAppendedLedgerEvent({
1197
+ ledger,
1198
+ event,
1199
+ previousHead,
1200
+ currentAnchor,
1201
+ headAtPrevious,
1202
+ headAtEvent,
1203
+ journalPath,
1204
+ options,
1205
+ });
1206
+ return;
1207
+ }
1208
+
1209
+ if (canDiscardPreparedLedgerEvent({
1210
+ ledger,
1211
+ event,
1212
+ previousHead,
1213
+ currentAnchor,
1214
+ headAtPrevious,
1215
+ options,
1216
+ })) {
1217
+ // The crash happened before the event append. The caller never received a
1218
+ // success response, so discard the prepared transaction instead of
1219
+ // executing it during recovery.
1220
+ removeDurableFile(journalPath);
1221
+ return;
1222
+ }
1223
+ throw financialError('financial ledger journal cannot be reconciled safely');
1224
+ }
1225
+
1226
+ function sameHead(left, right) {
1227
+ if (!left && !right) return true;
1228
+ return left?.sequence === right?.sequence && left?.eventHash === right?.eventHash;
1229
+ }
1230
+
1231
+ function writeAtomicJson(targetPath, value, temporaryPath = null) {
1232
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1233
+ const temporary = temporaryPath || `${targetPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1234
+ const fd = fs.openSync(temporary, 'w', 0o600);
1235
+ try {
1236
+ fs.writeSync(fd, `${JSON.stringify(value)}\n`, null, 'utf8');
1237
+ fs.fsyncSync(fd);
1238
+ } finally {
1239
+ fs.closeSync(fd);
1240
+ }
1241
+ fs.renameSync(temporary, targetPath);
1242
+ fsyncDirectoryFor(targetPath);
1243
+ }
1244
+
1245
+ function removeDurableFile(targetPath) {
1246
+ fs.unlinkSync(targetPath);
1247
+ fsyncDirectoryFor(targetPath);
1248
+ }
1249
+
1250
+ function fsyncDirectoryFor(targetPath) {
1251
+ const directoryFd = fs.openSync(path.dirname(targetPath), 'r');
1252
+ try {
1253
+ fs.fsyncSync(directoryFd);
1254
+ } finally {
1255
+ fs.closeSync(directoryFd);
1256
+ }
1257
+ }
1258
+
1259
+ function validateLedgerChain(events, malformedRows = [], ledgerHead = null, options = {}) {
1260
+ const invalidEventHashes = [];
1261
+ const invalidChainLinks = [];
1262
+ let previousHash = null;
1263
+ events.forEach((event, index) => {
1264
+ const sequence = index + 1;
1265
+ if (event.eventHash !== hashEvent(event)) {
1266
+ invalidEventHashes.push({ sequence, requisitionId: event.requisitionId, eventType: event.eventType });
1267
+ }
1268
+ if (event.sequence !== sequence || event.previousEventHash !== previousHash) {
1269
+ invalidChainLinks.push({
1270
+ sequence,
1271
+ recordedSequence: event.sequence,
1272
+ expectedPreviousEventHash: previousHash,
1273
+ recordedPreviousEventHash: event.previousEventHash,
1274
+ });
1275
+ }
1276
+ previousHash = event.eventHash || null;
1277
+ });
1278
+ const ledgerHeadMismatches = options.skipLedgerHead
1279
+ ? []
1280
+ : validateLedgerHead(events, ledgerHead, options);
1281
+ return {
1282
+ ok: malformedRows.length === 0
1283
+ && invalidEventHashes.length === 0
1284
+ && invalidChainLinks.length === 0
1285
+ && ledgerHeadMismatches.length === 0,
1286
+ invalidEventHashes,
1287
+ invalidChainLinks,
1288
+ ledgerHeadMismatches,
1289
+ };
1290
+ }
1291
+
1292
+ function validateLedgerHead(events, ledgerHead, options = {}) {
1293
+ const expected = events.at(-1) || { sequence: 0, eventHash: null };
1294
+ const key = optionalString(
1295
+ options.financialLedgerIntegrityKey
1296
+ || options.approvalVerificationKey
1297
+ || options.approvalSigningKey
1298
+ || process.env.THUMBGATE_FINANCIAL_LEDGER_KEY
1299
+ || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
1300
+ );
1301
+ let anchor = null;
1302
+ let anchorError = null;
1303
+ try {
1304
+ anchor = readFinancialLedgerAnchor(options);
1305
+ } catch (error) {
1306
+ anchorError = error.message;
1307
+ }
1308
+ if (events.length === 0 && ledgerHead === null && anchor === null && !anchorError) return [];
1309
+ if (key
1310
+ && ledgerHead?.schemaVersion === LEDGER_HEAD_SCHEMA
1311
+ && ledgerHead.sequence === expected.sequence
1312
+ && ledgerHead.eventHash === expected.eventHash
1313
+ && verifyIntegrityRecord(ledgerHead, key)
1314
+ && sameHead(anchor, expected)) return [];
1315
+ return [{
1316
+ recordedSequence: ledgerHead?.sequence ?? null,
1317
+ expectedSequence: expected.sequence,
1318
+ recordedEventHash: ledgerHead?.eventHash ?? null,
1319
+ expectedEventHash: expected.eventHash,
1320
+ authenticated: Boolean(key && verifyIntegrityRecord(ledgerHead, key)),
1321
+ rollbackResistantAnchorSequence: anchor?.sequence ?? null,
1322
+ rollbackResistantAnchorEventHash: anchor?.eventHash ?? null,
1323
+ rollbackResistantAnchorError: anchorError,
1324
+ }];
1325
+ }
1326
+
1327
+ function assertLedgerHealthyData(ledger, options = {}) {
1328
+ if (!validateLedgerChain(ledger.events, ledger.malformedRows, ledger.head, options).ok) {
1329
+ throw financialError('financial ledger integrity verification failed');
1330
+ }
1331
+ }
1332
+
1333
+ function ledgerIntegrityKey(options = {}) {
1334
+ const key = optionalString(
1335
+ options.financialLedgerIntegrityKey
1336
+ || options.approvalVerificationKey
1337
+ || options.approvalSigningKey
1338
+ || process.env.THUMBGATE_FINANCIAL_LEDGER_KEY
1339
+ || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
1340
+ );
1341
+ if (!key) {
1342
+ throw financialError('financial ledger integrity key is required');
1343
+ }
1344
+ return key;
1345
+ }
1346
+
1347
+ function signIntegrityRecord(record, key) {
1348
+ return {
1349
+ algorithm: 'hmac-sha256',
1350
+ keyId: crypto.createHash('sha256').update(key).digest('hex').slice(0, 16),
1351
+ signature: crypto.createHmac('sha256', key).update(integrityPayload(record)).digest('hex'),
1352
+ };
1353
+ }
1354
+
1355
+ function verifyIntegrityRecord(record, key) {
1356
+ const auth = record?.auth;
1357
+ if (!record || auth?.algorithm !== 'hmac-sha256') return false;
1358
+ const expectedKeyId = crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
1359
+ if (auth.keyId !== expectedKeyId || !/^[a-f0-9]{64}$/i.test(String(auth.signature || ''))) return false;
1360
+ const expected = crypto.createHmac('sha256', key).update(integrityPayload(record)).digest();
1361
+ const actual = Buffer.from(auth.signature, 'hex');
1362
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
1363
+ }
1364
+
1365
+ function integrityPayload(record) {
1366
+ const copy = { ...record };
1367
+ delete copy.auth;
1368
+ return stableStringify(copy);
1369
+ }
1370
+
1371
+ function hashEvent(event) {
1372
+ const copy = { ...event };
1373
+ delete copy.eventHash;
1374
+ return crypto.createHash('sha256').update(stableStringify(copy)).digest('hex');
1375
+ }
1376
+
1377
+ function requestComparableHash(event) {
1378
+ const comparable = {
1379
+ requisitionId: event.requisitionId,
1380
+ ...requestIntentComparable(event),
1381
+ };
1382
+ return crypto.createHash('sha256').update(stableStringify(comparable)).digest('hex');
1383
+ }
1384
+
1385
+ function requestIntentHash(event) {
1386
+ return crypto.createHash('sha256').update(stableStringify(requestIntentComparable(event))).digest('hex');
1387
+ }
1388
+
1389
+ function stableRequisitionId(requestIntent) {
1390
+ return `req_${requestIntentHash(requestIntent).slice(0, 32)}`;
1391
+ }
1392
+
1393
+ function requestIntentComparable(event) {
1394
+ return {
1395
+ idempotencyKey: event.idempotencyKey,
1396
+ taskId: event.taskId,
1397
+ requester: event.requester,
1398
+ vendor: event.vendor,
1399
+ purpose: event.purpose,
1400
+ sourceMessageId: event.sourceMessageId,
1401
+ amountUsd: event.amountUsd,
1402
+ approvedToolName: event.approvedToolName,
1403
+ actionFingerprint: event.actionFingerprint,
1404
+ evidence: event.evidence,
1405
+ };
1406
+ }
1407
+
1408
+ function stableStringify(value) {
1409
+ if (!value || typeof value !== 'object') return JSON.stringify(value);
1410
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
1411
+ const keys = Object.keys(value).sort((left, right) => left.localeCompare(right));
1412
+ const properties = keys.map((key) => [JSON.stringify(key), stableStringify(value[key])].join(':'));
1413
+ return ['{', properties.join(','), '}'].join('');
1414
+ }
1415
+
1416
+ function buildActionAuthorization(toolName, toolInput, amountUsd) {
1417
+ const normalizedToolName = normalizeToolName(requiredString(toolName, 'toolName'));
1418
+ const exactToolInput = objectValue(toolInput);
1419
+ if (Object.keys(exactToolInput).length === 0) throw financialError('toolInput for the exact economic action is required');
1420
+ const payload = {
1421
+ schemaVersion: 'financial-action-authorization-v1',
1422
+ toolName: normalizedToolName,
1423
+ toolInput: stripControlMetadata(exactToolInput),
1424
+ amountUsd: roundMoney(Math.max(0, finiteNumber(amountUsd, 0))),
1425
+ };
1426
+ return {
1427
+ toolName: normalizedToolName,
1428
+ fingerprint: crypto.createHash('sha256').update(stableStringify(payload)).digest('hex'),
1429
+ };
1430
+ }
1431
+
1432
+ function stripControlMetadata(value, depth = 0) {
1433
+ if (Array.isArray(value)) return value.map((entry) => stripControlMetadata(entry, depth + 1));
1434
+ if (!value || typeof value !== 'object') return value;
1435
+ // Only the explicitly namespaced ThumbGate authorization envelope is
1436
+ // transport metadata. Unnamespaced provider fields such as budget and usage
1437
+ // are economic inputs even at the tool_input root and must remain inside the
1438
+ // signed fingerprint. Hook cost telemetry belongs in costControl, not here.
1439
+ const excluded = depth === 0
1440
+ ? new Set(['financialControl', 'financial_control'])
1441
+ : new Set();
1442
+ return Object.fromEntries(Object.entries(value)
1443
+ .filter(([key]) => !excluded.has(key))
1444
+ .map(([key, entry]) => [key, stripControlMetadata(entry, depth + 1)]));
1445
+ }
1446
+
1447
+ function verifyPurchaseApprovalBinding(requisition, options) {
1448
+ let escalation;
1449
+ try {
1450
+ escalation = getVerifiedApproval(requisition.escalationId, options);
1451
+ } catch (error) {
1452
+ throw financialError(`requisition '${requisition.requisitionId}' approval verification failed: ${error.message}`);
1453
+ }
1454
+ if (escalation?.status !== 'approved') {
1455
+ throw financialError(`requisition '${requisition.requisitionId}' does not have independent human approval`);
1456
+ }
1457
+ const expectedApprovalContextDigest = requestComparableHash(requisition);
1458
+ if (!requisition.approvalContextDigest
1459
+ || requisition.approvalContextDigest !== expectedApprovalContextDigest
1460
+ || escalation.approvalContextDigest !== expectedApprovalContextDigest) {
1461
+ throw financialError(`requisition '${requisition.requisitionId}' approval is not bound to its exact purchase request`);
1462
+ }
1463
+ if (sameIdentity(requisition.requester, escalation.actor)) {
1464
+ throw financialError('requester cannot approve their own requisition');
1465
+ }
1466
+ return escalation;
1467
+ }
1468
+
1469
+ function assertReservationBoundToApproval(requisition) {
1470
+ if (!['reserved', 'authorized', 'committed'].includes(requisition.status)) return;
1471
+ const reservation = requisition.timeline.findLast((event) => event.eventType === 'reserved');
1472
+ if (!reservation) throw financialError('reserved purchase has no reservation event');
1473
+ if (positiveMoney(reservation.amountUsd, 'reserved amount') > requisition.amountUsd) {
1474
+ throw financialError('reserved amount exceeds the signed purchase request');
1475
+ }
1476
+ if (reservation.actionFingerprint !== requisition.actionFingerprint
1477
+ || normalizeToolName(reservation.approvedToolName) !== normalizeToolName(requisition.approvedToolName)) {
1478
+ throw financialError('reservation action does not match the signed purchase request');
1479
+ }
1480
+ assertScopeMatches(reservation, requisition);
1481
+ if (!sameIdentity(reservation.requester, requisition.requester)) {
1482
+ throw financialError('reservation requester does not match the signed purchase request');
1483
+ }
1484
+ }
1485
+
1486
+ function normalizeToolName(value) {
1487
+ return String(value || '').trim().toLowerCase();
1488
+ }
1489
+
1490
+ function rejectCallerSelectedRequester(input) {
1491
+ if (Object.hasOwn(input, 'requester')) {
1492
+ throw financialError('requester is derived from the authenticated runtime and must not be supplied by the caller');
1493
+ }
1494
+ }
1495
+
1496
+ function authenticatedPrincipal(options) {
1497
+ return requiredIdentity(options.authenticatedPrincipal || RUNTIME_PRINCIPAL, 'authenticatedPrincipal');
1498
+ }
1499
+
1500
+ function assertPrincipalOwnsRequisition(principal, requisition) {
1501
+ if (!sameIdentity(principal, requisition.requester)) {
1502
+ throw financialError('authenticated runtime principal does not own this purchase requisition');
1503
+ }
1504
+ }
1505
+
1506
+ function requiredIdentity(value, field) {
1507
+ if (!value || typeof value !== 'object') throw financialError(`${field} identity is required`);
1508
+ const identity = {
1509
+ id: requiredString(value.id, `${field}.id`),
1510
+ kind: requiredString(value.kind, `${field}.kind`),
1511
+ };
1512
+ if (!['agent', 'service', 'human'].includes(identity.kind)) {
1513
+ throw financialError(`${field}.kind must be agent, service, or human`);
1514
+ }
1515
+ const displayName = optionalString(value.displayName);
1516
+ if (displayName) identity.displayName = displayName;
1517
+ return identity;
1518
+ }
1519
+
1520
+ function sameIdentity(left, right) {
1521
+ return left?.id === right?.id && left?.kind === right?.kind;
1522
+ }
1523
+
1524
+ function positiveMoney(value, field) {
1525
+ const amount = finiteNumber(value, Number.NaN);
1526
+ if (!Number.isFinite(amount) || amount <= 0) throw financialError(`${field} must be greater than zero`);
1527
+ return roundMoney(amount);
1528
+ }
1529
+
1530
+ function nonNegativeMoney(value, field) {
1531
+ const amount = finiteNumber(value, Number.NaN);
1532
+ if (!Number.isFinite(amount) || amount < 0) throw financialError(`${field} must be zero or greater`);
1533
+ return roundMoney(amount);
1534
+ }
1535
+
1536
+ function roundMoney(value) {
1537
+ return Number(Number(value).toFixed(2));
1538
+ }
1539
+
1540
+ function mapMoney(value) {
1541
+ return Object.fromEntries(Object.entries(value).map(([key, amount]) => [key, roundMoney(amount)]));
1542
+ }
1543
+
1544
+ function boundedTtl(value, fallback) {
1545
+ return Math.min(MAX_TTL_MS, Math.max(1, finiteNumber(value, fallback)));
1546
+ }
1547
+
1548
+ function requiredString(value, field) {
1549
+ const clean = String(value ?? '').trim();
1550
+ if (!clean) throw financialError(`${field} is required`);
1551
+ return clean;
1552
+ }
1553
+
1554
+ function optionalString(value) {
1555
+ const clean = String(value ?? '').trim();
1556
+ return clean || undefined;
1557
+ }
1558
+
1559
+ function stringArray(value) {
1560
+ return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
1561
+ }
1562
+
1563
+ function finiteNumber(value, fallback) {
1564
+ const number = Number(value);
1565
+ return Number.isFinite(number) ? number : fallback;
1566
+ }
1567
+
1568
+ function objectValue(value) {
1569
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1570
+ }
1571
+
1572
+ function normalizeScope(value) {
1573
+ return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
1574
+ }
1575
+
1576
+ function financialError(message) {
1577
+ const error = new Error(message);
1578
+ error.code = 'THUMBGATE_FINANCIAL_CONTROL_ERROR';
1579
+ return error;
1580
+ }
1581
+
1582
+ module.exports = {
1583
+ ECONOMIC_ACTION_PATTERNS,
1584
+ createPurchaseRequisition,
1585
+ buildActionAuthorization,
1586
+ detectEconomicAction,
1587
+ detectOpaqueScreenMutation,
1588
+ evaluateFinancialControl,
1589
+ getLedgerHeadPath,
1590
+ getLedgerJournalPath,
1591
+ getLedgerPath,
1592
+ getFinancialControlRuntimeOptions,
1593
+ getRuntimePrincipal,
1594
+ listPurchaseRequisitions,
1595
+ projectRequisition,
1596
+ readEvents,
1597
+ reconcilePurchaseLedger,
1598
+ reservePurchaseRequisition,
1599
+ settlePurchaseRequisition,
1600
+ };