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