taximeter 0.1.1 → 0.2.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.
@@ -81,7 +81,8 @@ var policySchema = z2.strictObject({
81
81
  denyHosts: z2.array(labelSchema).default([]),
82
82
  allowPayTo: z2.array(labelSchema).default([]),
83
83
  maxSinglePayment: amountSchema.nullable().default("1000000"),
84
- maxSingleAsset: labelSchema.default("USDC")
84
+ maxSingleAsset: labelSchema.default("USDC"),
85
+ unknownAsset: z2.enum(["allow", "deny"]).default("deny")
85
86
  });
86
87
  var portsSchema = z2.strictObject({
87
88
  proxy: z2.number().int().min(0).max(65535).default(8402),
@@ -109,7 +110,8 @@ var configPatchSchema = z2.strictObject({
109
110
  denyHosts: z2.array(labelSchema).optional(),
110
111
  allowPayTo: z2.array(labelSchema).optional(),
111
112
  maxSinglePayment: amountSchema.nullable().optional(),
112
- maxSingleAsset: labelSchema.optional()
113
+ maxSingleAsset: labelSchema.optional(),
114
+ unknownAsset: z2.enum(["allow", "deny"]).optional()
113
115
  }).optional(),
114
116
  ports: z2.strictObject({
115
117
  proxy: z2.number().int().min(0).max(65535).optional(),
@@ -320,6 +322,23 @@ function spentForBudget(proposed, events, budget, scope, now) {
320
322
  return spent.toString();
321
323
  }
322
324
  function evaluate(proposed, events, config, now) {
325
+ return evaluateWithState(
326
+ proposed,
327
+ {
328
+ reserved: events.find(
329
+ (event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
330
+ ),
331
+ spent: {
332
+ perTask: config.budgets.perTask ? spentForBudget(proposed, events, config.budgets.perTask, "perTask", now) : "0",
333
+ perAgent: config.budgets.perAgent ? spentForBudget(proposed, events, config.budgets.perAgent, "perAgent", now) : "0",
334
+ global: config.budgets.global ? spentForBudget(proposed, events, config.budgets.global, "global", now) : "0"
335
+ }
336
+ },
337
+ config,
338
+ now
339
+ );
340
+ }
341
+ function evaluateWithState(proposed, state, config, now) {
323
342
  const { policy } = config;
324
343
  const host = proposed.host.toLowerCase();
325
344
  if (policy.denyHosts.some((value) => value.toLowerCase() === host)) return deny("host_denied");
@@ -327,16 +346,16 @@ function evaluate(proposed, events, config, now) {
327
346
  return deny("host_not_allowed");
328
347
  if (policy.allowPayTo.length > 0 && !policy.allowPayTo.some((value) => value.toLowerCase() === proposed.payTo.toLowerCase()))
329
348
  return deny("recipient_not_allowed");
349
+ if (policy.unknownAsset === "deny" && !assetMetadata(proposed.network, proposed.asset).decimalsKnown)
350
+ return deny("unknown_asset");
330
351
  if (policy.maxSinglePayment !== null && matchesAsset(policy.maxSingleAsset, proposed) && BigInt(proposed.amount) > BigInt(policy.maxSinglePayment))
331
352
  return deny("max_single_payment", policy.maxSinglePayment);
332
- const reserved = events.find(
333
- (event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
334
- );
353
+ const reserved = state.reserved && countsAsSpend(state.reserved) ? state.reserved : void 0;
335
354
  for (const scope of ["perTask", "perAgent", "global"]) {
336
355
  const budget = config.budgets[scope];
337
356
  if (!budget || !matchesAsset(budget.asset, proposed) || budget.network && budget.network !== proposed.network)
338
357
  continue;
339
- const spent = spentForBudget(proposed, events, budget, scope, now);
358
+ const spent = state.spent[scope];
340
359
  const reservedTime = reserved ? Date.parse(reserved.attemptedAt ?? reserved.ts) : -Infinity;
341
360
  const inWindow = reservedTime <= now && (!budget.window || reservedTime >= now - windows[budget.window]);
342
361
  const increment = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : BigInt(proposed.amount);
@@ -647,8 +666,9 @@ var Meter = class {
647
666
  if (!proposed) return {};
648
667
  try {
649
668
  return this.ledger.transaction(() => {
650
- const events = this.ledger.view();
651
- const decision = evaluate(proposed, events, this.config, Date.now());
669
+ const now = Date.now();
670
+ const state = this.ledger.policyState(proposed, this.config.budgets, now);
671
+ const decision = evaluateWithState(proposed, state, this.config, now);
652
672
  if (!decision.allowed) {
653
673
  this.ledger.append(
654
674
  paymentEventSchema.parse({
@@ -660,9 +680,7 @@ var Meter = class {
660
680
  );
661
681
  return { body: decision.body };
662
682
  }
663
- const previous = events.find(
664
- (event) => event.status === "observed" && event.paymentKey === proposed.paymentKey
665
- );
683
+ const previous = state.reserved;
666
684
  const payment = previous ?? proposed;
667
685
  if (!previous) this.ledger.append(payment);
668
686
  const ts = (/* @__PURE__ */ new Date()).toISOString();
@@ -796,32 +814,324 @@ import { mkdirSync } from "fs";
796
814
  import { dirname as dirname2 } from "path";
797
815
  import Database from "better-sqlite3";
798
816
  import { v7 as v73 } from "uuid";
799
- import { z as z7 } from "zod";
817
+ import { z as z8 } from "zod";
800
818
 
801
819
  // migrations/001.sql
802
820
  var __default = "CREATE TABLE schema_version (version INTEGER PRIMARY KEY);\nINSERT INTO schema_version VALUES (1);\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n taskId TEXT,\n agentId TEXT,\n host TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('observed', 'blocked')),\n paymentKey TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX events_ts ON events(ts);\nCREATE INDEX events_task ON events(taskId);\nCREATE INDEX events_agent ON events(agentId);\nCREATE INDEX events_host ON events(host);\nCREATE UNIQUE INDEX events_payment ON events(paymentKey) WHERE status = 'observed';\nCREATE TABLE outcomes (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n paymentId TEXT NOT NULL REFERENCES events(id),\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX outcomes_payment ON outcomes(paymentId);\nCREATE TABLE diagnostics (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER outcomes_no_update BEFORE UPDATE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER outcomes_no_delete BEFORE DELETE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER diagnostics_no_update BEFORE UPDATE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\nCREATE TRIGGER diagnostics_no_delete BEFORE DELETE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\n";
803
821
 
822
+ // migrations/002.sql
823
+ var __default2 = "CREATE TABLE cache_cursors (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n eventRowid INTEGER NOT NULL CHECK (eventRowid >= 0),\n outcomeRowid INTEGER NOT NULL CHECK (outcomeRowid >= 0)\n);\nINSERT INTO cache_cursors VALUES (1, 0, 0);\n\nCREATE TABLE cache_payments (\n paymentId TEXT PRIMARY KEY REFERENCES events(id),\n paymentKey TEXT NOT NULL UNIQUE,\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n maxAttemptedAt TEXT\n);\n\nCREATE TABLE cache_attempts (\n paymentId TEXT NOT NULL REFERENCES cache_payments(paymentId),\n attemptId TEXT NOT NULL,\n firstTs TEXT NOT NULL,\n firstId TEXT NOT NULL,\n winnerTs TEXT NOT NULL,\n winnerId TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('unknown', 'confirmed', 'failed')),\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n PRIMARY KEY (paymentId, attemptId)\n);\nCREATE INDEX cache_attempt_selection ON cache_attempts (\n paymentId, status, firstTs, firstId COLLATE NOCASE, firstId DESC\n);\n\nCREATE TABLE cache_prefix (\n partitionKey TEXT NOT NULL,\n prefix TEXT NOT NULL,\n amount TEXT NOT NULL CHECK (amount <> '' AND amount NOT GLOB '*[^0-9]*'),\n PRIMARY KEY (partitionKey, prefix)\n) WITHOUT ROWID;\n\nINSERT INTO schema_version VALUES (2);\n";
824
+
825
+ // src/ledger/cache.ts
826
+ import { z as z7 } from "zod";
827
+ var cursorSchema = z7.object({
828
+ eventRowid: z7.number().int().nonnegative(),
829
+ outcomeRowid: z7.number().int().nonnegative()
830
+ });
831
+ var sourceSchema = z7.object({
832
+ sourceRowid: z7.number().int().positive(),
833
+ id: z7.string(),
834
+ payload: z7.string()
835
+ });
836
+ var payloadSchema = z7.object({ payload: z7.string() });
837
+ var paymentRowSchema = z7.object({
838
+ payload: z7.string(),
839
+ originalPayload: z7.string(),
840
+ maxAttemptedAt: z7.iso.datetime().nullable()
841
+ });
842
+ var attemptRowSchema = z7.object({
843
+ firstTs: z7.iso.datetime(),
844
+ firstId: z7.uuidv7(),
845
+ winnerTs: z7.iso.datetime(),
846
+ winnerId: z7.uuidv7(),
847
+ status: z7.enum(["unknown", "confirmed", "failed"])
848
+ });
849
+ var prefixRowSchema = z7.object({
850
+ prefix: z7.string().regex(/^[0-9a-f]{0,16}$/),
851
+ amount: integerStringSchema
852
+ });
853
+ var scopeSchema = z7.enum(["perTask", "perAgent", "global"]);
854
+ var windowSchema = z7.enum(["1h", "24h", "7d", "30d"]);
855
+ var timeSchema = z7.number().int().min(-864e13).max(864e13);
856
+ var windows2 = {
857
+ "1h": 36e5,
858
+ "24h": 864e5,
859
+ "7d": 6048e5,
860
+ "30d": 2592e6
861
+ };
862
+ var digits = "0123456789abcdef";
863
+ var bias = 1n << 63n;
864
+ function timeKey(milliseconds) {
865
+ const value = z7.number().int().parse(milliseconds);
866
+ const encoded = bias + BigInt(value);
867
+ if (encoded < 0n || encoded >= 1n << 64n) throw new Error("Timestamp outside cache index range");
868
+ return encoded.toString(16).padStart(16, "0");
869
+ }
870
+ function partition(payment, scope) {
871
+ const attribution = scope === "perTask" ? payment.taskId : scope === "perAgent" ? payment.agentId : null;
872
+ return JSON.stringify([scope, attribution ?? null, payment.network, payment.asset.toLowerCase()]);
873
+ }
874
+ function order2(left, right) {
875
+ return left.ts.localeCompare(right.ts) || left.id.localeCompare(right.id);
876
+ }
877
+ var LedgerCache = class {
878
+ constructor(database) {
879
+ this.database = database;
880
+ }
881
+ database;
882
+ statements = /* @__PURE__ */ new Map();
883
+ statement(sql) {
884
+ let statement = this.statements.get(sql);
885
+ if (!statement) {
886
+ statement = this.database.prepare(sql);
887
+ this.statements.set(sql, statement);
888
+ }
889
+ return statement;
890
+ }
891
+ /** The caller holds an immediate transaction throughout synchronization and budget gating. */
892
+ synchronize() {
893
+ const cursor = cursorSchema.parse(
894
+ this.statement("SELECT eventRowid, outcomeRowid FROM cache_cursors WHERE id = 1").get()
895
+ );
896
+ let eventRowid = cursor.eventRowid;
897
+ let outcomeRowid = cursor.outcomeRowid;
898
+ const eventQuery = this.statement(
899
+ "SELECT rowid AS sourceRowid, id, payload FROM events WHERE rowid > ? ORDER BY rowid LIMIT 256"
900
+ );
901
+ for (let batch = eventQuery.all(eventRowid); batch.length > 0; batch = eventQuery.all(eventRowid)) {
902
+ for (const input of batch) {
903
+ const row = sourceSchema.parse(input);
904
+ const payment = paymentEventSchema.parse(JSON.parse(row.payload));
905
+ if (payment.id !== row.id)
906
+ throw new Error("Payment row identity does not match its payload");
907
+ if (payment.status === "observed") {
908
+ const derived = paymentEventSchema.parse({
909
+ ...payment,
910
+ settlement_unknown: payment.settlementStatus === "unknown"
911
+ });
912
+ const inserted = this.statement(
913
+ "INSERT OR IGNORE INTO cache_payments (paymentId, paymentKey, payload, maxAttemptedAt) VALUES (?, ?, ?, NULL)"
914
+ ).run(payment.id, payment.paymentKey, JSON.stringify(derived));
915
+ if (inserted.changes > 0) this.changeContribution(void 0, derived);
916
+ }
917
+ eventRowid = row.sourceRowid;
918
+ }
919
+ }
920
+ const outcomeQuery = this.statement(
921
+ "SELECT rowid AS sourceRowid, id, payload FROM outcomes WHERE rowid > ? ORDER BY rowid LIMIT 256"
922
+ );
923
+ for (let batch = outcomeQuery.all(outcomeRowid); batch.length > 0; batch = outcomeQuery.all(outcomeRowid)) {
924
+ for (const input of batch) {
925
+ const row = sourceSchema.parse(input);
926
+ const outcome = outcomeSchema.parse(JSON.parse(row.payload));
927
+ if (outcome.id !== row.id)
928
+ throw new Error("Outcome row identity does not match its payload");
929
+ this.applyOutcome(outcome);
930
+ outcomeRowid = row.sourceRowid;
931
+ }
932
+ }
933
+ if (eventRowid !== cursor.eventRowid || outcomeRowid !== cursor.outcomeRowid) {
934
+ this.statement("UPDATE cache_cursors SET eventRowid = ?, outcomeRowid = ? WHERE id = 1").run(
935
+ eventRowid,
936
+ outcomeRowid
937
+ );
938
+ }
939
+ }
940
+ rebuild() {
941
+ this.database.exec(
942
+ "DELETE FROM cache_prefix; DELETE FROM cache_attempts; DELETE FROM cache_payments; UPDATE cache_cursors SET eventRowid = 0, outcomeRowid = 0 WHERE id = 1"
943
+ );
944
+ this.synchronize();
945
+ }
946
+ reservation(paymentKey) {
947
+ const key = z7.string().min(1).parse(paymentKey);
948
+ const row = payloadSchema.optional().parse(this.statement("SELECT payload FROM cache_payments WHERE paymentKey = ?").get(key));
949
+ return row ? paymentEventSchema.parse(JSON.parse(row.payload)) : void 0;
950
+ }
951
+ spent(proposed, inputScope, inputWindow, now) {
952
+ const payment = paymentEventSchema.parse(proposed);
953
+ const scope = scopeSchema.parse(inputScope);
954
+ const window = windowSchema.optional().parse(inputWindow);
955
+ const end = timeSchema.parse(now);
956
+ const key = partition(payment, scope);
957
+ const upper = this.prefixTotal(key, end);
958
+ const lower = window ? this.prefixTotal(key, end - windows2[window] - 1) : 0n;
959
+ const result = upper - lower;
960
+ if (result < 0n) throw new Error("Cached budget total is inconsistent");
961
+ return result.toString();
962
+ }
963
+ applyOutcome(outcome) {
964
+ const row = paymentRowSchema.optional().parse(
965
+ this.statement(
966
+ "SELECT c.payload, c.maxAttemptedAt, e.payload AS originalPayload FROM cache_payments c JOIN events e ON e.id = c.paymentId WHERE c.paymentId = ?"
967
+ ).get(outcome.paymentId)
968
+ );
969
+ if (!row) return;
970
+ const previous = paymentEventSchema.parse(JSON.parse(row.payload));
971
+ const original = paymentEventSchema.parse(JSON.parse(row.originalPayload));
972
+ const attemptId = outcome.attemptId ?? outcome.paymentId;
973
+ const attempt = attemptRowSchema.optional().parse(
974
+ this.statement(
975
+ "SELECT firstTs, firstId, winnerTs, winnerId, status FROM cache_attempts WHERE paymentId = ? AND attemptId = ?"
976
+ ).get(outcome.paymentId, attemptId)
977
+ );
978
+ if (!attempt) {
979
+ this.statement(
980
+ "INSERT INTO cache_attempts (paymentId, attemptId, firstTs, firstId, winnerTs, winnerId, status, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
981
+ ).run(
982
+ outcome.paymentId,
983
+ attemptId,
984
+ outcome.ts,
985
+ outcome.id,
986
+ outcome.ts,
987
+ outcome.id,
988
+ outcome.status,
989
+ JSON.stringify(outcome)
990
+ );
991
+ } else {
992
+ if (order2(outcome, { ts: attempt.firstTs, id: attempt.firstId }) < 0) {
993
+ this.statement(
994
+ "UPDATE cache_attempts SET firstTs = ?, firstId = ? WHERE paymentId = ? AND attemptId = ?"
995
+ ).run(outcome.ts, outcome.id, outcome.paymentId, attemptId);
996
+ }
997
+ const newer = order2(outcome, { ts: attempt.winnerTs, id: attempt.winnerId }) > 0;
998
+ const wins = attempt.status === "confirmed" ? outcome.status === "confirmed" && newer : outcome.status === "confirmed" || newer;
999
+ if (wins) {
1000
+ this.statement(
1001
+ "UPDATE cache_attempts SET winnerTs = ?, winnerId = ?, status = ?, payload = ? WHERE paymentId = ? AND attemptId = ?"
1002
+ ).run(
1003
+ outcome.ts,
1004
+ outcome.id,
1005
+ outcome.status,
1006
+ JSON.stringify(outcome),
1007
+ outcome.paymentId,
1008
+ attemptId
1009
+ );
1010
+ }
1011
+ }
1012
+ const maxAttemptedAt = outcome.attemptedAt && (!row.maxAttemptedAt || outcome.attemptedAt > row.maxAttemptedAt) ? outcome.attemptedAt : row.maxAttemptedAt;
1013
+ const selected = this.selectedOutcome(outcome.paymentId);
1014
+ const status = original.settlementStatus === "confirmed" ? "confirmed" : selected?.status ?? original.settlementStatus;
1015
+ const current = paymentEventSchema.parse({
1016
+ ...original,
1017
+ ...maxAttemptedAt ? { attemptedAt: maxAttemptedAt } : {},
1018
+ settlementStatus: status,
1019
+ settlement_unknown: status === "unknown",
1020
+ ...selected?.txHash ? { txHash: selected.txHash } : {}
1021
+ });
1022
+ this.changeContribution(previous, current);
1023
+ this.statement(
1024
+ "UPDATE cache_payments SET payload = ?, maxAttemptedAt = ? WHERE paymentId = ?"
1025
+ ).run(JSON.stringify(current), maxAttemptedAt, outcome.paymentId);
1026
+ }
1027
+ selectedOutcome(paymentId) {
1028
+ for (const status of ["confirmed", "unknown"]) {
1029
+ const row2 = payloadSchema.optional().parse(
1030
+ this.statement(
1031
+ "SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = ? ORDER BY firstTs, firstId COLLATE NOCASE, firstId DESC LIMIT 1"
1032
+ ).get(paymentId, status)
1033
+ );
1034
+ if (row2) return outcomeSchema.parse(JSON.parse(row2.payload));
1035
+ }
1036
+ const row = payloadSchema.optional().parse(
1037
+ this.statement(
1038
+ "SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = 'failed' ORDER BY firstTs DESC, firstId COLLATE NOCASE DESC, firstId ASC LIMIT 1"
1039
+ ).get(paymentId)
1040
+ );
1041
+ return row ? outcomeSchema.parse(JSON.parse(row.payload)) : void 0;
1042
+ }
1043
+ changeContribution(previous, current) {
1044
+ const changes = /* @__PURE__ */ new Map();
1045
+ const add = (payment, sign) => {
1046
+ if (!countsAsSpend(payment)) return;
1047
+ const amount = BigInt(payment.amount) * sign;
1048
+ if (amount === 0n) return;
1049
+ const timestamp = timeKey(Date.parse(payment.attemptedAt ?? payment.ts));
1050
+ for (const scope of ["perTask", "perAgent", "global"]) {
1051
+ const key = partition(payment, scope);
1052
+ const nodes = changes.get(key) ?? /* @__PURE__ */ new Map();
1053
+ for (let length = 0; length <= timestamp.length; length += 1) {
1054
+ const prefix = timestamp.slice(0, length);
1055
+ nodes.set(prefix, (nodes.get(prefix) ?? 0n) + amount);
1056
+ }
1057
+ changes.set(key, nodes);
1058
+ }
1059
+ };
1060
+ if (previous) add(previous, -1n);
1061
+ add(current, 1n);
1062
+ for (const [key, nodes] of changes) {
1063
+ const pending = [...nodes].filter(([, amount]) => amount !== 0n);
1064
+ if (pending.length === 0) continue;
1065
+ const existing = /* @__PURE__ */ new Map();
1066
+ for (const input of this.statement(
1067
+ "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1068
+ ).iterate(key, JSON.stringify(pending.map(([prefix]) => prefix)))) {
1069
+ const row = prefixRowSchema.parse(input);
1070
+ existing.set(row.prefix, BigInt(row.amount));
1071
+ }
1072
+ for (const [prefix, delta] of pending) {
1073
+ const next = (existing.get(prefix) ?? 0n) + delta;
1074
+ if (next < 0n) throw new Error("Cached monetary contribution underflow");
1075
+ if (next === 0n) {
1076
+ this.statement("DELETE FROM cache_prefix WHERE partitionKey = ? AND prefix = ?").run(
1077
+ key,
1078
+ prefix
1079
+ );
1080
+ } else {
1081
+ this.statement(
1082
+ "INSERT INTO cache_prefix (partitionKey, prefix, amount) VALUES (?, ?, ?) ON CONFLICT(partitionKey, prefix) DO UPDATE SET amount = excluded.amount"
1083
+ ).run(key, prefix, next.toString());
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ prefixTotal(key, time) {
1089
+ const timestamp = timeKey(time);
1090
+ const prefixes = [];
1091
+ for (let index = 0; index < timestamp.length; index += 1) {
1092
+ const digit = timestamp[index];
1093
+ if (digit === void 0) throw new Error("Missing timestamp digit");
1094
+ const limit = digits.indexOf(digit);
1095
+ for (let child = 0; child < limit; child += 1) {
1096
+ prefixes.push(`${timestamp.slice(0, index)}${digits[child]}`);
1097
+ }
1098
+ }
1099
+ prefixes.push(timestamp);
1100
+ let sum = 0n;
1101
+ for (const input of this.statement(
1102
+ "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1103
+ ).iterate(key, JSON.stringify(prefixes))) {
1104
+ sum += BigInt(prefixRowSchema.parse(input).amount);
1105
+ }
1106
+ return sum;
1107
+ }
1108
+ };
1109
+
804
1110
  // src/ledger/store.ts
805
- var rowSchema = z7.object({ payload: z7.string() });
1111
+ var rowSchema = z8.object({ payload: z8.string() });
806
1112
  var Ledger = class {
807
1113
  database;
1114
+ cache;
808
1115
  constructor(input) {
809
- const path = z7.string().min(1).parse(input);
1116
+ const path = z8.string().min(1).parse(input);
810
1117
  if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true, mode: 448 });
811
1118
  this.database = new Database(path);
812
1119
  try {
813
1120
  this.database.pragma("journal_mode = WAL");
814
1121
  this.database.pragma("foreign_keys = ON");
815
1122
  this.database.pragma("busy_timeout = 5000");
816
- this.database.transaction(() => {
1123
+ this.cache = this.database.transaction(() => {
817
1124
  const table = this.database.prepare(
818
1125
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'"
819
1126
  ).get();
820
1127
  if (!table) this.database.exec(__default);
821
- const version3 = z7.object({ version: z7.literal(1) }).parse(
1128
+ const version3 = z8.object({ version: z8.union([z8.literal(1), z8.literal(2)]) }).parse(
822
1129
  this.database.prepare("SELECT MAX(version) AS version FROM schema_version").get()
823
1130
  );
824
- return version3.version;
1131
+ if (version3.version === 1) this.database.exec(__default2);
1132
+ const cache = new LedgerCache(this.database);
1133
+ cache.synchronize();
1134
+ return cache;
825
1135
  }).immediate();
826
1136
  } catch (error) {
827
1137
  this.database.close();
@@ -833,22 +1143,49 @@ var Ledger = class {
833
1143
  }
834
1144
  append(input) {
835
1145
  const event = paymentEventSchema.parse(input);
836
- return this.database.prepare(
837
- "INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
838
- ).run(
839
- event.id,
840
- event.ts,
841
- event.taskId ?? null,
842
- event.agentId ?? null,
843
- event.host,
844
- event.status,
845
- event.paymentKey,
846
- JSON.stringify(event)
847
- ).changes > 0;
1146
+ return this.transaction(() => {
1147
+ const inserted = this.database.prepare(
1148
+ "INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
1149
+ ).run(
1150
+ event.id,
1151
+ event.ts,
1152
+ event.taskId ?? null,
1153
+ event.agentId ?? null,
1154
+ event.host,
1155
+ event.status,
1156
+ event.paymentKey,
1157
+ JSON.stringify(event)
1158
+ ).changes > 0;
1159
+ this.cache.synchronize();
1160
+ return inserted;
1161
+ });
848
1162
  }
849
1163
  appendOutcome(input) {
850
1164
  const outcome = outcomeSchema.parse(input);
851
- return this.database.prepare("INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)").run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
1165
+ return this.transaction(() => {
1166
+ const inserted = this.database.prepare(
1167
+ "INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)"
1168
+ ).run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
1169
+ this.cache.synchronize();
1170
+ return inserted;
1171
+ });
1172
+ }
1173
+ policyState(proposed, budgets, now) {
1174
+ return this.transaction(() => {
1175
+ this.cache.synchronize();
1176
+ return {
1177
+ reserved: this.cache.reservation(proposed.paymentKey),
1178
+ spent: {
1179
+ perTask: budgets.perTask ? this.cache.spent(proposed, "perTask", budgets.perTask.window, now) : "0",
1180
+ perAgent: budgets.perAgent ? this.cache.spent(proposed, "perAgent", budgets.perAgent.window, now) : "0",
1181
+ global: budgets.global ? this.cache.spent(proposed, "global", budgets.global.window, now) : "0"
1182
+ }
1183
+ };
1184
+ });
1185
+ }
1186
+ /** Rebuild disposable projections without modifying the append-only audit log. */
1187
+ rebuildCache() {
1188
+ this.transaction(() => this.cache.rebuild());
852
1189
  }
853
1190
  events() {
854
1191
  return this.database.prepare("SELECT payload FROM events ORDER BY ts, id").all().map((row) => paymentEventSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
@@ -885,10 +1222,10 @@ import {
885
1222
  } from "http";
886
1223
  import { request as httpsRequest } from "https";
887
1224
  import { connect, Socket } from "net";
888
- import { z as z8 } from "zod";
889
- var incomingHeadersSchema = z8.record(
890
- z8.string(),
891
- z8.union([z8.string(), z8.array(z8.string()), z8.undefined()])
1225
+ import { z as z9 } from "zod";
1226
+ var incomingHeadersSchema = z9.record(
1227
+ z9.string(),
1228
+ z9.union([z9.string(), z9.array(z9.string()), z9.undefined()])
892
1229
  );
893
1230
  var sockets = /* @__PURE__ */ new WeakMap();
894
1231
  function normalizedHeaders(input) {
@@ -922,7 +1259,7 @@ function forwardingHeaders(message, host, upgrade = false) {
922
1259
  return result;
923
1260
  }
924
1261
  function targetFor(request, config) {
925
- const reference = z8.string().min(1).parse(request.url);
1262
+ const reference = z9.string().min(1).parse(request.url);
926
1263
  const absolute = /^https?:\/\//i.test(reference);
927
1264
  const url = new URL(
928
1265
  httpUrlSchema.parse(
@@ -931,13 +1268,13 @@ function targetFor(request, config) {
931
1268
  );
932
1269
  const suffix = absolute ? reference.replace(/^https?:\/\/[^/?#]*/i, "") : reference.startsWith("/") ? reference : `${url.pathname}${url.search}`;
933
1270
  const withoutFragment = suffix.split("#", 1)[0] ?? "";
934
- const path = z8.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
1271
+ const path = z9.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
935
1272
  return { url, path, resource: httpUrlSchema.parse(`${url.origin}${path}`) };
936
1273
  }
937
1274
  function wire(request, target) {
938
1275
  return {
939
1276
  url: target.resource,
940
- method: z8.string().parse(request.method),
1277
+ method: z9.string().parse(request.method),
941
1278
  headers: normalizedHeaders(request.headers)
942
1279
  };
943
1280
  }
@@ -986,7 +1323,7 @@ function createProxy(options) {
986
1323
  if (status === 402 && !needsBody) meter.observe(input, upstream);
987
1324
  if (needsBody)
988
1325
  reply.on("data", (chunk) => {
989
- const bytes = z8.instanceof(Buffer).parse(chunk);
1326
+ const bytes = z9.instanceof(Buffer).parse(chunk);
990
1327
  length += bytes.length;
991
1328
  if (length <= 65536) chunks.push(bytes);
992
1329
  else chunks.length = 0;
@@ -1027,9 +1364,9 @@ function createProxy(options) {
1027
1364
  });
1028
1365
  server.on("connect", (request, client, head) => {
1029
1366
  try {
1030
- const address = z8.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
1367
+ const address = z9.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
1031
1368
  const target = new URL(`http://${address}`);
1032
- const port = z8.coerce.number().int().min(1).max(65535).parse(target.port || "80");
1369
+ const port = z9.coerce.number().int().min(1).max(65535).parse(target.port || "80");
1033
1370
  const host = target.hostname.replace(/^\[|\]$/g, "");
1034
1371
  meter.diagnose(
1035
1372
  "tls_unmetered",
@@ -1137,13 +1474,13 @@ async function closeProxy(server) {
1137
1474
  }
1138
1475
 
1139
1476
  // src/version.ts
1140
- import { z as z9 } from "zod";
1477
+ import { z as z10 } from "zod";
1141
1478
 
1142
1479
  // package.json
1143
- var version = "0.1.1";
1480
+ var version = "0.2.0";
1144
1481
 
1145
1482
  // src/version.ts
1146
- var version2 = z9.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
1483
+ var version2 = z10.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
1147
1484
 
1148
1485
  export {
1149
1486
  integerStringSchema,
package/dist/cli/index.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  totalSchema,
23
23
  totals,
24
24
  version
25
- } from "../chunk-P3T2DVCP.js";
25
+ } from "../chunk-NXK2MCRO.js";
26
26
 
27
27
  // src/cli/index.ts
28
28
  import { CommanderError } from "commander";
package/dist/index.d.ts CHANGED
@@ -43,6 +43,10 @@ declare const configSchema: z.ZodObject<{
43
43
  allowPayTo: z.ZodDefault<z.ZodArray<z.ZodString>>;
44
44
  maxSinglePayment: z.ZodDefault<z.ZodNullable<z.ZodString>>;
45
45
  maxSingleAsset: z.ZodDefault<z.ZodString>;
46
+ unknownAsset: z.ZodDefault<z.ZodEnum<{
47
+ allow: "allow";
48
+ deny: "deny";
49
+ }>>;
46
50
  }, z.core.$strict>>;
47
51
  ports: z.ZodPrefault<z.ZodObject<{
48
52
  proxy: z.ZodDefault<z.ZodNumber>;
@@ -88,6 +92,7 @@ declare function loadConfig(flags?: unknown, options?: {
88
92
  allowPayTo: string[];
89
93
  maxSinglePayment: string | null;
90
94
  maxSingleAsset: string;
95
+ unknownAsset: "allow" | "deny";
91
96
  };
92
97
  ports: {
93
98
  proxy: number;
@@ -176,13 +181,31 @@ declare const blockedBodySchema: z.ZodObject<{
176
181
  }, z.core.$strip>;
177
182
  type BlockedBody = z.infer<typeof blockedBodySchema>;
178
183
 
184
+ type Decision = {
185
+ allowed: true;
186
+ } | {
187
+ allowed: false;
188
+ body: BlockedBody;
189
+ };
190
+ type Scope = "perTask" | "perAgent" | "global";
191
+ type PolicyState = {
192
+ reserved: PaymentEvent | undefined;
193
+ spent: Record<Scope, string>;
194
+ };
195
+ /** Pure, synchronous budget decision. The caller must reserve in the same transaction. */
196
+ declare function evaluate(proposed: PaymentEvent, events: PaymentEvent[], config: TaximeterConfig, now: number): Decision;
197
+
179
198
  /** Synchronous storage edge; money derivations remain pure in derive.ts. */
180
199
  declare class Ledger {
181
200
  private readonly database;
201
+ private readonly cache;
182
202
  constructor(input: string);
183
203
  transaction<T>(operation: () => T): T;
184
204
  append(input: unknown): boolean;
185
205
  appendOutcome(input: unknown): boolean;
206
+ policyState(proposed: PaymentEvent, budgets: TaximeterConfig["budgets"], now: number): PolicyState;
207
+ /** Rebuild disposable projections without modifying the append-only audit log. */
208
+ rebuildCache(): void;
186
209
  events(): {
187
210
  id: string;
188
211
  ts: string;
@@ -320,15 +343,6 @@ declare function deriveEvents(events: PaymentEvent[], outcomes?: Outcome[]): Pay
320
343
  declare function totals(events: PaymentEvent[]): Total[];
321
344
  declare function formatAmount(amount: string, decimals: number): string;
322
345
 
323
- type Decision = {
324
- allowed: true;
325
- } | {
326
- allowed: false;
327
- body: BlockedBody;
328
- };
329
- /** Pure, synchronous budget decision. The caller must reserve in the same transaction. */
330
- declare function evaluate(proposed: PaymentEvent, events: PaymentEvent[], config: TaximeterConfig, now: number): Decision;
331
-
332
346
  declare function createProxy(options: {
333
347
  ledger: Ledger;
334
348
  config: TaximeterConfig;
@@ -379,6 +393,10 @@ declare const optionsSchema: z.ZodObject<{
379
393
  allowPayTo: z.ZodOptional<z.ZodArray<z.ZodString>>;
380
394
  maxSinglePayment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
381
395
  maxSingleAsset: z.ZodOptional<z.ZodString>;
396
+ unknownAsset: z.ZodOptional<z.ZodEnum<{
397
+ allow: "allow";
398
+ deny: "deny";
399
+ }>>;
382
400
  }, z.core.$strict>>;
383
401
  ports: z.ZodOptional<z.ZodObject<{
384
402
  proxy: z.ZodOptional<z.ZodNumber>;
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  toJson,
19
19
  totals,
20
20
  version
21
- } from "./chunk-P3T2DVCP.js";
21
+ } from "./chunk-NXK2MCRO.js";
22
22
 
23
23
  // src/sdk/index.ts
24
24
  import { z } from "zod";