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.
@@ -13,8 +13,11 @@ const crypto = require('node:crypto');
13
13
  const fs = require('node:fs');
14
14
  const path = require('node:path');
15
15
  const { getFeedbackPaths } = require('./feedback-paths');
16
+ const { withFileLedgerLock } = require('./file-ledger-lock');
16
17
 
17
18
  const ESCALATIONS_FILE = 'human-escalations.jsonl';
19
+ const ESCALATIONS_HEAD_FILE = 'human-escalations.head.json';
20
+ const ESCALATIONS_JOURNAL_FILE = 'human-escalations.journal.json';
18
21
  const MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000;
19
22
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
20
23
  const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
@@ -24,6 +27,16 @@ function getEscalationsPath(options = {}) {
24
27
  return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_FILE);
25
28
  }
26
29
 
30
+ function getEscalationsHeadPath(options = {}) {
31
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.head.json`;
32
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_HEAD_FILE);
33
+ }
34
+
35
+ function getEscalationsJournalPath(options = {}) {
36
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.journal.json`;
37
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_JOURNAL_FILE);
38
+ }
39
+
27
40
  function requestEscalation(input = {}, options = {}) {
28
41
  const now = options.now || new Date();
29
42
  const taskId = requiredString(input.taskId, 'taskId');
@@ -35,8 +48,7 @@ function requestEscalation(input = {}, options = {}) {
35
48
  if (!SEVERITIES.has(severity)) throw escalationError(`severity must be one of ${Array.from(SEVERITIES).join(', ')}`);
36
49
  const ttlMs = Math.min(MAX_TTL_MS, Math.max(1, finiteNumber(input.ttlMs, DEFAULT_TTL_MS)));
37
50
  const idempotencyKey = requiredString(input.idempotencyKey || taskId, 'idempotencyKey');
38
- const existing = listEscalations(options).find((entry) => entry.idempotencyKey === idempotencyKey);
39
-
51
+ const approvalContextDigest = optionalDigest(input.approvalContextDigest, 'approvalContextDigest');
40
52
  const request = {
41
53
  escalationId: input.escalationId || `esc_${crypto.randomUUID()}`,
42
54
  idempotencyKey,
@@ -50,19 +62,31 @@ function requestEscalation(input = {}, options = {}) {
50
62
  status: 'pending',
51
63
  eventType: 'requested',
52
64
  };
53
- request.eventHash = eventHash(request);
54
-
55
- if (existing) {
56
- if (eventComparableHash(existing) !== eventComparableHash(request)) {
57
- const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
58
- error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
59
- throw error;
65
+ if (approvalContextDigest) request.approvalContextDigest = approvalContextDigest;
66
+ return withEscalationLock(options, () => {
67
+ const ledger = readLedger(options);
68
+ assertLedgerHealthy(ledger);
69
+ // Compare retries with the immutable request event. A later decision event
70
+ // deliberately carries the reviewer's reason and status, so comparing the
71
+ // projected row would turn an already-approved request into a false
72
+ // idempotency conflict during cross-ledger recovery.
73
+ const existingRequest = ledger.events.find((event) => (
74
+ event.idempotencyKey === idempotencyKey
75
+ && (!event.eventType || event.eventType === 'requested')
76
+ ));
77
+ if (existingRequest) {
78
+ if (eventComparableHash(existingRequest) !== eventComparableHash(request)) {
79
+ const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
80
+ error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
81
+ throw error;
82
+ }
83
+ const current = projectEscalations(ledger.events, options)
84
+ .find((entry) => entry.escalationId === existingRequest.escalationId);
85
+ return { recorded: false, duplicate: true, escalation: current || existingRequest };
60
86
  }
61
- return { recorded: false, duplicate: true, escalation: existing };
62
- }
63
-
64
- appendEvent(request, options);
65
- return { recorded: true, duplicate: false, escalation: request };
87
+ const recorded = appendEventUnlocked(request, options, ledger.events);
88
+ return { recorded: true, duplicate: false, escalation: recorded };
89
+ });
66
90
  }
67
91
 
68
92
  function decideEscalation(input = {}, options = {}) {
@@ -75,29 +99,74 @@ function decideEscalation(input = {}, options = {}) {
75
99
  const actor = requiredIdentity(options.authenticatedActor, 'authenticatedActor');
76
100
  if (actor.kind !== 'human') throw escalationError('authenticatedActor.kind must be human');
77
101
  const reason = requiredString(input.reason, 'reason');
78
- const current = getEscalation(escalationId, options);
79
- if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
80
- if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
81
- if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
102
+ return withEscalationLock(options, () => {
103
+ const ledger = readLedger(options);
104
+ assertLedgerHealthy(ledger);
105
+ const current = projectEscalations(ledger.events, options)
106
+ .find((entry) => entry.escalationId === escalationId);
107
+ if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
108
+ if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
109
+ if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
82
110
 
83
- const now = options.now || new Date();
84
- const event = {
85
- escalationId,
86
- taskId: current.taskId,
87
- status: decision,
88
- eventType: 'decided',
89
- decision,
90
- actor,
91
- reason,
92
- decidedAt: now.toISOString(),
93
- };
94
- event.eventHash = eventHash(event);
95
- appendEvent(event, options);
96
- return { recorded: true, escalation: { ...current, ...event } };
111
+ const now = options.now || new Date();
112
+ const event = {
113
+ escalationId,
114
+ taskId: current.taskId,
115
+ status: decision,
116
+ eventType: 'decided',
117
+ decision,
118
+ actor,
119
+ reason,
120
+ decidedAt: now.toISOString(),
121
+ };
122
+ if (current.approvalContextDigest) {
123
+ event.approvalContextDigest = current.approvalContextDigest;
124
+ }
125
+ const signingKey = optionalString(options.approvalSigningKey);
126
+ if (signingKey) event.approvalReceipt = signApprovalReceipt(event, signingKey);
127
+ const recorded = appendEventUnlocked(event, options, ledger.events);
128
+ return { recorded: true, escalation: { ...current, ...recorded } };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Return an approval only when both the append-only history and the reviewer
134
+ * receipt authenticate. Merely appending an `approved` JSON row is not proof
135
+ * that the independently authenticated reviewer API produced it.
136
+ */
137
+ function getVerifiedApproval(escalationId, options = {}) {
138
+ return withEscalationLock(options, () => getVerifiedApprovalUnlocked(escalationId, options));
139
+ }
140
+
141
+ function getVerifiedApprovalUnlocked(escalationId, options = {}) {
142
+ const ledger = readLedger(options);
143
+ const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
144
+ if (!integrity.ok) throw escalationError('escalation ledger integrity verification failed');
145
+ const events = ledger.events.filter((event) => event.escalationId === escalationId);
146
+ const requested = events.find((event) => event.eventType === 'requested');
147
+ const decided = events.findLast((event) => event.eventType === 'decided');
148
+ if (!requested || !decided || decided.status !== 'approved') return null;
149
+ if (decided.taskId !== requested.taskId) throw escalationError('approval task does not match its request');
150
+ if ((requested.approvalContextDigest || null) !== (decided.approvalContextDigest || null)) {
151
+ throw escalationError('approval context does not match its request');
152
+ }
153
+ if (decided.actor?.kind !== 'human' || sameIdentity(requested.requester, decided.actor)) {
154
+ throw escalationError('approval is not from an independent human actor');
155
+ }
156
+ const verificationKey = optionalString(
157
+ options.approvalVerificationKey || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
158
+ );
159
+ if (!verificationKey || !verifyApprovalReceipt(decided, verificationKey)) {
160
+ throw escalationError('approval receipt is missing or unauthenticated');
161
+ }
162
+ return { ...requested, ...decided };
97
163
  }
98
164
 
99
165
  function listEscalations(options = {}) {
100
- const events = readEvents(options);
166
+ return projectEscalations(readEvents(options), options);
167
+ }
168
+
169
+ function projectEscalations(events, options = {}) {
101
170
  const byId = new Map();
102
171
  for (const event of events) {
103
172
  const current = byId.get(event.escalationId) || {};
@@ -143,26 +212,254 @@ function calculateEscalationMetrics(escalations = [], now = new Date()) {
143
212
  }
144
213
 
145
214
  function readEvents(options = {}) {
215
+ return readLedger(options).events;
216
+ }
217
+
218
+ function readLedger(options = {}) {
146
219
  const inputPath = options.inputPath ? path.resolve(options.inputPath) : getEscalationsPath(options);
147
220
  let raw = '';
148
221
  try {
149
222
  raw = fs.readFileSync(inputPath, 'utf8');
150
- } catch {
151
- return [];
223
+ } catch (error) {
224
+ if (error.code !== 'ENOENT') throw error;
225
+ return { events: [], malformedRows: [], head: readLedgerHead(options) };
152
226
  }
153
- return raw.split('\n').map((line) => line.trim()).filter(Boolean).flatMap((line) => {
227
+ const events = [];
228
+ const malformedRows = [];
229
+ raw.split('\n').forEach((line, index) => {
230
+ if (!line.trim()) return;
154
231
  try {
155
- return [JSON.parse(line)];
232
+ events.push(JSON.parse(line));
156
233
  } catch {
157
- return [];
234
+ malformedRows.push(index + 1);
158
235
  }
159
236
  });
237
+ return { events, malformedRows, head: readLedgerHead(options) };
160
238
  }
161
239
 
162
- function appendEvent(event, options) {
240
+ function appendEventUnlocked(event, options, existingEvents) {
163
241
  const outputPath = getEscalationsPath(options);
242
+ const previous = existingEvents.at(-1) || null;
243
+ const chained = {
244
+ ...event,
245
+ schemaVersion: 'human-escalation-v2',
246
+ sequence: existingEvents.length + 1,
247
+ previousEventHash: previous?.eventHash || null,
248
+ };
249
+ chained.eventHash = eventHash(chained);
164
250
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
165
- fs.appendFileSync(outputPath, `${JSON.stringify(event)}\n`, 'utf8');
251
+ const journalPath = getEscalationsJournalPath(options);
252
+ writeAtomicJson(journalPath, {
253
+ schemaVersion: 'human-escalation-journal-v1',
254
+ previousHead: readLedgerHead(options),
255
+ event: chained,
256
+ });
257
+ const ledgerFd = fs.openSync(outputPath, 'a', 0o600);
258
+ try {
259
+ fs.writeSync(ledgerFd, `${JSON.stringify(chained)}\n`, null, 'utf8');
260
+ fs.fsyncSync(ledgerFd);
261
+ } finally {
262
+ fs.closeSync(ledgerFd);
263
+ }
264
+ fsyncDirectoryFor(outputPath);
265
+ writeLedgerHead(chained, options);
266
+ removeDurableFile(journalPath);
267
+ return chained;
268
+ }
269
+
270
+ function assertLedgerHealthy(ledger) {
271
+ if (!validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head).ok) {
272
+ throw escalationError('refusing to append to a damaged escalation ledger');
273
+ }
274
+ }
275
+
276
+ function withEscalationLock(options, callback) {
277
+ return withFileLedgerLock(`${getEscalationsPath(options)}.lock`, callback, {
278
+ now: options.now,
279
+ lockStaleMs: options.lockStaleMs,
280
+ errorFactory: (message) => escalationError(message),
281
+ beforeCallback: () => recoverEscalationTransaction(options),
282
+ });
283
+ }
284
+
285
+ function readLedgerHead(options = {}) {
286
+ try {
287
+ return JSON.parse(fs.readFileSync(getEscalationsHeadPath(options), 'utf8'));
288
+ } catch (error) {
289
+ if (error.code === 'ENOENT') return null;
290
+ return { malformed: true };
291
+ }
292
+ }
293
+
294
+ function writeLedgerHead(event, options = {}) {
295
+ const headPath = getEscalationsHeadPath(options);
296
+ const head = {
297
+ schemaVersion: 'human-escalation-head-v1',
298
+ sequence: event.sequence,
299
+ eventHash: event.eventHash,
300
+ };
301
+ writeAtomicJson(headPath, head);
302
+ }
303
+
304
+ function recoverEscalationTransaction(options = {}) {
305
+ const journalPath = getEscalationsJournalPath(options);
306
+ let journal;
307
+ try {
308
+ journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
309
+ } catch (error) {
310
+ if (error.code === 'ENOENT') return;
311
+ throw escalationError(`cannot recover escalation journal: ${error.message}`);
312
+ }
313
+ if (journal?.schemaVersion !== 'human-escalation-journal-v1'
314
+ || !journal.event
315
+ || journal.event.eventHash !== eventHash(journal.event)) {
316
+ throw escalationError('escalation journal integrity verification failed');
317
+ }
318
+
319
+ const ledger = readLedger(options);
320
+ const event = journal.event;
321
+ const currentLast = ledger.events.at(-1) || null;
322
+ const eventHead = { sequence: event.sequence, eventHash: event.eventHash };
323
+ const eventAlreadyAppended = sameHead(currentLast, eventHead);
324
+ const headAtPrevious = sameHead(ledger.head, journal.previousHead);
325
+ const headAtEvent = sameHead(ledger.head, eventHead);
326
+
327
+ if (eventAlreadyAppended) {
328
+ const preceding = ledger.events.at(-2) || null;
329
+ const precedingMatches = event.sequence === ledger.events.length
330
+ && event.previousEventHash === (preceding?.eventHash || null);
331
+ const syntheticHead = {
332
+ schemaVersion: 'human-escalation-head-v1',
333
+ ...eventHead,
334
+ };
335
+ const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, syntheticHead);
336
+ if (!precedingMatches || !integrity.ok || (!headAtPrevious && !headAtEvent)) {
337
+ throw escalationError('escalation journal does not match the recoverable append');
338
+ }
339
+ if (!headAtEvent) writeLedgerHead(event, options);
340
+ removeDurableFile(journalPath);
341
+ return;
342
+ }
343
+
344
+ const currentIntegrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
345
+ if (currentIntegrity.ok && headAtPrevious && event.sequence === ledger.events.length + 1) {
346
+ // No append became durable, so the caller never received success. Discard
347
+ // the prepared transaction and let the original operation be retried.
348
+ removeDurableFile(journalPath);
349
+ return;
350
+ }
351
+ throw escalationError('escalation journal cannot be reconciled safely');
352
+ }
353
+
354
+ function sameHead(left, right) {
355
+ if (!left && !right) return true;
356
+ return left?.sequence === right?.sequence && left?.eventHash === right?.eventHash;
357
+ }
358
+
359
+ function writeAtomicJson(targetPath, value) {
360
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
361
+ const temporaryPath = `${targetPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
362
+ const fd = fs.openSync(temporaryPath, 'w', 0o600);
363
+ try {
364
+ fs.writeSync(fd, `${JSON.stringify(value)}\n`, null, 'utf8');
365
+ fs.fsyncSync(fd);
366
+ } finally {
367
+ fs.closeSync(fd);
368
+ }
369
+ fs.renameSync(temporaryPath, targetPath);
370
+ fsyncDirectoryFor(targetPath);
371
+ }
372
+
373
+ function removeDurableFile(targetPath) {
374
+ fs.unlinkSync(targetPath);
375
+ fsyncDirectoryFor(targetPath);
376
+ }
377
+
378
+ function fsyncDirectoryFor(targetPath) {
379
+ const directoryFd = fs.openSync(path.dirname(targetPath), 'r');
380
+ try {
381
+ fs.fsyncSync(directoryFd);
382
+ } finally {
383
+ fs.closeSync(directoryFd);
384
+ }
385
+ }
386
+
387
+ function validateEscalationLedger(events, malformedRows = [], head = null) {
388
+ const invalidEventHashes = [];
389
+ const invalidChainLinks = [];
390
+ let previousHash = null;
391
+ let chainedEvents = 0;
392
+ events.forEach((event, index) => {
393
+ const sequence = index + 1;
394
+ if (event.eventHash !== eventHash(event)) invalidEventHashes.push(sequence);
395
+ const isLegacy = !event.schemaVersion
396
+ && event.sequence === undefined
397
+ && event.previousEventHash === undefined;
398
+ if (!isLegacy) chainedEvents += 1;
399
+ // Legacy events predate the global chain. They may remain only as a
400
+ // contiguous prefix; the first new event seals their terminal hash into
401
+ // the v2 chain and creates the external head checkpoint.
402
+ if ((!isLegacy && (event.sequence !== sequence || event.previousEventHash !== previousHash))
403
+ || (isLegacy && chainedEvents > 0)) {
404
+ invalidChainLinks.push(sequence);
405
+ }
406
+ previousHash = event.eventHash || null;
407
+ });
408
+ const expected = events.at(-1) || null;
409
+ const headValid = expected === null
410
+ ? head === null
411
+ : chainedEvents === 0
412
+ ? head === null
413
+ : head?.schemaVersion === 'human-escalation-head-v1'
414
+ && head.sequence === expected.sequence
415
+ && head.eventHash === expected.eventHash;
416
+ return {
417
+ ok: malformedRows.length === 0
418
+ && invalidEventHashes.length === 0
419
+ && invalidChainLinks.length === 0
420
+ && headValid,
421
+ malformedRows,
422
+ invalidEventHashes,
423
+ invalidChainLinks,
424
+ headValid,
425
+ };
426
+ }
427
+
428
+ function signApprovalReceipt(event, signingKey) {
429
+ return {
430
+ algorithm: 'hmac-sha256',
431
+ keyId: crypto.createHash('sha256').update(signingKey).digest('hex').slice(0, 16),
432
+ signature: crypto.createHmac('sha256', signingKey).update(approvalPayload(event)).digest('hex'),
433
+ };
434
+ }
435
+
436
+ function verifyApprovalReceipt(event, verificationKey) {
437
+ const receipt = event.approvalReceipt;
438
+ if (!receipt || receipt.algorithm !== 'hmac-sha256') return false;
439
+ const expectedKeyId = crypto.createHash('sha256').update(verificationKey).digest('hex').slice(0, 16);
440
+ if (receipt.keyId !== expectedKeyId) return false;
441
+ const expected = crypto.createHmac('sha256', verificationKey).update(approvalPayload(event)).digest();
442
+ let actual;
443
+ try {
444
+ actual = Buffer.from(String(receipt.signature || ''), 'hex');
445
+ } catch {
446
+ return false;
447
+ }
448
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
449
+ }
450
+
451
+ function approvalPayload(event) {
452
+ const payload = {
453
+ escalationId: event.escalationId,
454
+ taskId: event.taskId,
455
+ status: event.status,
456
+ decision: event.decision,
457
+ actor: event.actor,
458
+ reason: event.reason,
459
+ decidedAt: event.decidedAt,
460
+ };
461
+ if (event.approvalContextDigest) payload.approvalContextDigest = event.approvalContextDigest;
462
+ return stableStringify(payload);
166
463
  }
167
464
 
168
465
  function requiredIdentity(value, field) {
@@ -187,6 +484,14 @@ function optionalString(value) {
187
484
  return clean || undefined;
188
485
  }
189
486
 
487
+ function optionalDigest(value, field) {
488
+ const digest = optionalString(value);
489
+ if (digest && !/^[a-f0-9]{64}$/i.test(digest)) {
490
+ throw escalationError(`${field} must be a SHA-256 hex digest`);
491
+ }
492
+ return digest?.toLowerCase();
493
+ }
494
+
190
495
  function stringArray(value) {
191
496
  return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
192
497
  }
@@ -201,7 +506,9 @@ function sameIdentity(a, b) {
201
506
  }
202
507
 
203
508
  function eventHash(event) {
204
- return crypto.createHash('sha256').update(stableStringify(event)).digest('hex');
509
+ const copy = { ...event };
510
+ delete copy.eventHash;
511
+ return crypto.createHash('sha256').update(stableStringify(copy)).digest('hex');
205
512
  }
206
513
 
207
514
  function eventComparableHash(event) {
@@ -213,6 +520,7 @@ function eventComparableHash(event) {
213
520
  requester: event.requester,
214
521
  evidence: event.evidence,
215
522
  };
523
+ if (event.approvalContextDigest) comparable.approvalContextDigest = event.approvalContextDigest;
216
524
  return crypto.createHash('sha256').update(stableStringify(comparable)).digest('hex');
217
525
  }
218
526
 
@@ -259,7 +567,11 @@ module.exports = {
259
567
  calculateEscalationMetrics,
260
568
  decideEscalation,
261
569
  getEscalation,
570
+ getEscalationsHeadPath,
571
+ getEscalationsJournalPath,
262
572
  getEscalationsPath,
573
+ getVerifiedApproval,
263
574
  listEscalations,
264
575
  requestEscalation,
576
+ validateEscalationLedger,
265
577
  };
@@ -491,12 +491,19 @@ function normalizeProviderAction(input = {}) {
491
491
 
492
492
  function normalizeBudget(input = {}) {
493
493
  const budget = asObject(input);
494
+ const hasNumericAlias = (...keys) => keys.some((key) => (
495
+ Object.hasOwn(budget, key) && Number.isFinite(Number(budget[key]))
496
+ ));
494
497
  return {
495
498
  maxTokensPerAction: firstNumber(budget.maxTokensPerAction, budget.perActionTokens, budget.tokenLimit),
496
499
  remainingTokens: firstNumber(budget.remainingTokens, budget.tokensRemaining),
497
500
  maxCostUsdPerAction: firstNumber(budget.maxCostUsdPerAction, budget.perActionCostUsd, budget.costLimitUsd),
498
501
  remainingCostUsd: firstNumber(budget.remainingCostUsd, budget.costUsdRemaining),
499
502
  maxParallelBranches: firstNumber(budget.maxParallelBranches, budget.parallelBranchLimit, DEFAULT_MAX_PARALLEL_BRANCHES),
503
+ hasMaxTokensPerAction: hasNumericAlias('maxTokensPerAction', 'perActionTokens', 'tokenLimit'),
504
+ hasRemainingTokens: hasNumericAlias('remainingTokens', 'tokensRemaining'),
505
+ hasMaxCostUsdPerAction: hasNumericAlias('maxCostUsdPerAction', 'perActionCostUsd', 'costLimitUsd'),
506
+ hasRemainingCostUsd: hasNumericAlias('remainingCostUsd', 'costUsdRemaining'),
500
507
  };
501
508
  }
502
509
 
@@ -540,16 +547,16 @@ function buildCostControl(normalizedAction = {}, budgetInput = {}) {
540
547
  const totalTokens = firstNumber(usage.totalTokens);
541
548
  const estimatedCostUsd = firstNumber(usage.estimatedCostUsd);
542
549
 
543
- if (budget.maxTokensPerAction > 0 && totalTokens > budget.maxTokensPerAction) {
550
+ if (budget.hasMaxTokensPerAction && totalTokens > budget.maxTokensPerAction) {
544
551
  reasons.push(`Token estimate ${totalTokens} exceeds per-action limit ${budget.maxTokensPerAction}.`);
545
552
  }
546
- if (budget.remainingTokens > 0 && totalTokens > budget.remainingTokens) {
553
+ if (budget.hasRemainingTokens && totalTokens > budget.remainingTokens) {
547
554
  reasons.push(`Token estimate ${totalTokens} exceeds remaining budget ${budget.remainingTokens}.`);
548
555
  }
549
- if (budget.maxCostUsdPerAction > 0 && estimatedCostUsd > budget.maxCostUsdPerAction) {
556
+ if (budget.hasMaxCostUsdPerAction && estimatedCostUsd > budget.maxCostUsdPerAction) {
550
557
  reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds per-action limit $${budget.maxCostUsdPerAction.toFixed(4)}.`);
551
558
  }
552
- if (budget.remainingCostUsd > 0 && estimatedCostUsd > budget.remainingCostUsd) {
559
+ if (budget.hasRemainingCostUsd && estimatedCostUsd > budget.remainingCostUsd) {
553
560
  reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds remaining budget $${budget.remainingCostUsd.toFixed(4)}.`);
554
561
  }
555
562
  if (budget.maxParallelBranches > 0 && normalizedAction.workflow?.branchCount > budget.maxParallelBranches) {
@@ -1185,14 +1185,91 @@ const TOOLS = [
1185
1185
  },
1186
1186
  },
1187
1187
  }),
1188
+ destructiveTool({
1189
+ name: 'create_purchase_requisition',
1190
+ description: 'Create an append-only purchase requisition and independent human-escalation request. This does not authorize spending.',
1191
+ inputSchema: {
1192
+ type: 'object',
1193
+ additionalProperties: false,
1194
+ required: ['taskId', 'vendor', 'amountUsd', 'purpose', 'sourceMessageId', 'evidence', 'toolName', 'toolInput'],
1195
+ properties: {
1196
+ taskId: { type: 'string', minLength: 1 },
1197
+ vendor: { type: 'string', minLength: 1 },
1198
+ amountUsd: { type: 'number', exclusiveMinimum: 0 },
1199
+ purpose: { type: 'string', minLength: 1 },
1200
+ sourceMessageId: { type: 'string', minLength: 1, description: 'Stable identifier for the exact user message authorizing the request.' },
1201
+ toolName: { type: 'string', minLength: 1, description: 'Exact future economic tool name. The human-approved requisition is cryptographically bound to it.' },
1202
+ toolInput: { type: 'object', minProperties: 1, description: 'Exact future economic tool input before financialControl metadata is attached. Stored only as a fingerprint.' },
1203
+ evidence: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
1204
+ ttlMs: { type: 'number', minimum: 1 },
1205
+ idempotencyKey: { type: 'string', minLength: 1 },
1206
+ },
1207
+ },
1208
+ }),
1209
+ readOnlyTool({
1210
+ name: 'list_purchase_requisitions',
1211
+ description: 'List projected purchase-requisition states from the append-only financial ledger and human-review queue.',
1212
+ inputSchema: {
1213
+ type: 'object',
1214
+ additionalProperties: false,
1215
+ properties: {
1216
+ status: { type: 'string' },
1217
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
1218
+ },
1219
+ },
1220
+ }),
1221
+ destructiveTool({
1222
+ name: 'reserve_purchase_requisition',
1223
+ description: 'Reserve a single-use amount from an independently approved purchase requisition. Approval is unavailable from the agent tool surface.',
1224
+ inputSchema: {
1225
+ type: 'object',
1226
+ additionalProperties: false,
1227
+ required: ['requisitionId', 'amountUsd', 'vendor', 'purpose', 'sourceMessageId'],
1228
+ properties: {
1229
+ requisitionId: { type: 'string', minLength: 1 },
1230
+ amountUsd: { type: 'number', exclusiveMinimum: 0 },
1231
+ vendor: { type: 'string', minLength: 1 },
1232
+ purpose: { type: 'string', minLength: 1 },
1233
+ sourceMessageId: { type: 'string', minLength: 1 },
1234
+ ttlMs: { type: 'number', minimum: 1 },
1235
+ idempotencyKey: { type: 'string', minLength: 1 },
1236
+ },
1237
+ },
1238
+ }),
1239
+ destructiveTool({
1240
+ name: 'settle_purchase_requisition',
1241
+ description: 'Commit actual spend with receipt evidence or release an unused reservation. Events are append-only.',
1242
+ inputSchema: {
1243
+ type: 'object',
1244
+ additionalProperties: false,
1245
+ required: ['requisitionId', 'reservationId', 'status'],
1246
+ properties: {
1247
+ requisitionId: { type: 'string', minLength: 1 },
1248
+ reservationId: { type: 'string', minLength: 1 },
1249
+ status: { type: 'string', enum: ['committed', 'released'] },
1250
+ actualAmountUsd: { type: 'number', minimum: 0 },
1251
+ evidence: { type: 'array', items: { type: 'string', minLength: 1 } },
1252
+ reason: { type: 'string' },
1253
+ },
1254
+ },
1255
+ }),
1256
+ readOnlyTool({
1257
+ name: 'reconcile_purchase_ledger',
1258
+ description: 'Reconcile the append-only financial ledger, including totals, stale reservations, status counts, and tamper-evident event hashes.',
1259
+ inputSchema: {
1260
+ type: 'object',
1261
+ additionalProperties: false,
1262
+ properties: {},
1263
+ },
1264
+ }),
1188
1265
  readOnlyTool({
1189
1266
  name: 'verify_claim',
1190
- description: 'Check whether a claim has enough tracked evidence before the agent asserts it.',
1267
+ description: 'Check whether a claim has enough tracked evidence and, for parseable factual claims (row counts, file lines/bytes/existence, versions), recheck configured SQLite/filesystem/JSON verifiers before the agent asserts it.',
1191
1268
  inputSchema: {
1192
1269
  type: 'object',
1193
1270
  required: ['claim'],
1194
1271
  properties: {
1195
- claim: { type: 'string', description: 'The claim text to verify' },
1272
+ claim: { type: 'string', description: 'The claim text to verify (e.g. "the row count is 1,284" or "all tests pass")' },
1196
1273
  goalContract: GOAL_CONTRACT_SCHEMA,
1197
1274
  },
1198
1275
  },
@@ -1248,6 +1325,19 @@ const TOOLS = [
1248
1325
  additionalProperties: true,
1249
1326
  description: 'Optional per-action budget controls: maxTokensPerAction, remainingTokens, maxCostUsdPerAction, remainingCostUsd, maxParallelBranches',
1250
1327
  },
1328
+ financialControl: {
1329
+ type: 'object',
1330
+ additionalProperties: false,
1331
+ description: 'Single-use purchase authorization scope from the append-only financial ledger.',
1332
+ properties: {
1333
+ requisitionId: { type: 'string' },
1334
+ reservationId: { type: 'string' },
1335
+ actionId: { type: 'string' },
1336
+ vendor: { type: 'string' },
1337
+ purpose: { type: 'string' },
1338
+ sourceMessageId: { type: 'string' },
1339
+ },
1340
+ },
1251
1341
  workflowPattern: {
1252
1342
  type: 'string',
1253
1343
  enum: ['single_action', 'chaining', 'routing', 'parallelization', 'evaluator-optimizer', 'agent'],
@@ -1655,13 +1745,13 @@ const TOOLS = [
1655
1745
  }),
1656
1746
  readOnlyTool({
1657
1747
  name: 'require_evidence_for_claim',
1658
- description: 'Leader-Agent completion gate. Before any agent declares done/fixed/shipped/resolved, require tracked evidence. Blocking response when evidence missing; callers honor the blocking flag to stop completion claims.',
1748
+ description: 'Leader-Agent completion gate. Before any agent declares done/fixed/shipped/resolved, require tracked session evidence AND recheck parseable factual claims (row counts, file metrics, versions) against configured verifiers. Blocking response when evidence is missing or a factual claim mismatches; callers honor the blocking flag to stop completion claims.',
1659
1749
  inputSchema: {
1660
1750
  type: 'object',
1661
1751
  required: ['claim'],
1662
1752
  properties: {
1663
- claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "Tests passing")' },
1664
- mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing; advisory returns blocking=false' },
1753
+ claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "the row count is 1,284")' },
1754
+ mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing or factual claim mismatches; advisory returns blocking=false' },
1665
1755
  sessionId: { type: 'string', description: 'Optional session id to associate with the gate decision' },
1666
1756
  goalContract: GOAL_CONTRACT_SCHEMA,
1667
1757
  },