otto-intel-mcp 0.1.2 → 0.1.3

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.
Files changed (51) hide show
  1. package/README.md +4 -2
  2. package/dist/adapter/cdp-signer.d.ts +44 -5
  3. package/dist/adapter/cdp-signer.js +168 -18
  4. package/dist/adapter/evm-call-failure.d.ts +29 -0
  5. package/dist/adapter/evm-call-failure.js +109 -0
  6. package/dist/adapter/refusal.d.ts +1 -1
  7. package/dist/adapter/refusal.js +13 -0
  8. package/dist/adapter/route-simulator.d.ts +91 -0
  9. package/dist/adapter/route-simulator.js +143 -0
  10. package/dist/executable-equities.d.ts +55 -0
  11. package/dist/executable-equities.js +116 -0
  12. package/dist/executable-yield-markets.d.ts +261 -0
  13. package/dist/executable-yield-markets.js +154 -0
  14. package/dist/execution-delegated-definition.d.ts +1 -0
  15. package/dist/execution-delegated-definition.js +13 -2
  16. package/dist/execution-delegation-admin-definition.d.ts +126 -5
  17. package/dist/execution-delegation-admin-definition.js +154 -5
  18. package/dist/execution-delegation-admin.d.ts +48 -2
  19. package/dist/execution-delegation-admin.js +314 -12
  20. package/dist/execution-delegation-cap-store.d.ts +369 -0
  21. package/dist/execution-delegation-cap-store.js +1083 -0
  22. package/dist/execution-delegation-policy.d.ts +153 -6
  23. package/dist/execution-delegation-policy.js +260 -16
  24. package/dist/execution-delegation.d.ts +35 -0
  25. package/dist/execution-delegation.js +224 -9
  26. package/dist/execution-errors.d.ts +1 -1
  27. package/dist/execution-errors.js +98 -0
  28. package/dist/execution-index.d.ts +3 -2
  29. package/dist/execution-index.js +3 -2
  30. package/dist/execution-registration.d.ts +51 -2
  31. package/dist/execution-registration.js +45 -2
  32. package/dist/execution-tools.d.ts +44 -1
  33. package/dist/execution-tools.js +157 -13
  34. package/dist/lifi-execution-client.d.ts +6 -0
  35. package/dist/lifi-execution-client.js +3 -0
  36. package/dist/stock-buy-definition.d.ts +338 -0
  37. package/dist/stock-buy-definition.js +127 -0
  38. package/dist/stock-buy-oracle.d.ts +8 -0
  39. package/dist/stock-buy-oracle.js +22 -0
  40. package/dist/stock-buy.d.ts +17 -0
  41. package/dist/stock-buy.js +164 -0
  42. package/dist/tool-definitions.js +1 -1
  43. package/dist/yield-deposit-definition.d.ts +323 -0
  44. package/dist/yield-deposit-definition.js +121 -0
  45. package/dist/yield-deposit.d.ts +16 -0
  46. package/dist/yield-deposit.js +141 -0
  47. package/dist/yield-withdraw-definition.d.ts +306 -0
  48. package/dist/yield-withdraw-definition.js +88 -0
  49. package/dist/yield-withdraw.d.ts +21 -0
  50. package/dist/yield-withdraw.js +120 -0
  51. package/package.json +28 -4
@@ -0,0 +1,1083 @@
1
+ /**
2
+ * execution-delegation-cap-store.ts — the durable, TRANSACTIONAL state machine behind a person's chosen
3
+ * per-swap cap (Founder word `delegate cap choice`, 2026-09-02; ROUND 5, after four codex NO-GOs).
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
6
+ * 🔴 THE THREAT MODEL THIS ENFORCES (Founder ruling, 2026-09-03, verbatim)
7
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
8
+ * "The per-person delegation cap protects every delegated send that goes through Otto's page
9
+ * (/app/delegate) and Otto's CLI (otto-execute delegate). A permission a person mints or revokes
10
+ * directly against Coinbase with their own login, outside Otto's page and CLI, is OUT OF SCOPE: Otto
11
+ * cannot distinguish it (CDP returns no grant identity, only an expiry) and does not defend against it;
12
+ * the CDP project-scope $5,000 ceiling remains the hard backstop for every permission. The delegated
13
+ * agent never holds the person's login."
14
+ *
15
+ * Everything below is built to be correct INSIDE that model. Where a defect is only reachable by a
16
+ * direct-to-Coinbase mint or revoke, the behaviour is stated rather than defended — and every one of
17
+ * those paths still fails CLOSED here (an unrecorded or unconfirmed grant authorises nothing at all),
18
+ * so the out-of-scope actor can shorten what Otto may do, never widen it.
19
+ *
20
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
21
+ * 🔴 WHY THE MACHINE, AND WHY IT IS SQLITE
22
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
23
+ * Round 3 made the expiry unique among ROWS and thought that was enough. The gate's two CRITICALs:
24
+ *
25
+ * "The durable row still authenticates an expiry, not a grant. Enforcement requires only
26
+ * (userId, grant.expiresAt); it does not require a successful confirmation, the reservation's
27
+ * mintKey, or an active-grant pointer."
28
+ * "Confirmation is not an enforcement gate. Reserve writes the complete enforceable capability;
29
+ * confirm writes no state at all."
30
+ *
31
+ * Both are the same defect: a reserved row was a BEARER CAPABILITY keyed by an instant. Round 4 took the
32
+ * gate's own minimum safe model —
33
+ *
34
+ * reserved ──confirm──▶ confirmed_active ──┬──▶ superseded (a later confirm won)
35
+ * │ ├──▶ revoked (the person, or a mismatch)
36
+ * └──10 min──▶ expired └──▶ (expiry passes)
37
+ *
38
+ * — and made ONE thing authoritative: `readActive`. A reserved row authorises NOTHING; the send gate
39
+ * reads the confirmed-active record and requires its expiry to equal the live grant's.
40
+ *
41
+ * Round 4 implemented that on filesystem primitives (`open(…,'wx')` + `rename()`), and round 4's gate
42
+ * retired them: *"`rename()` gives atomic visibility of one pointer, not durability or a multi-file
43
+ * transaction: there is no file/directory `fsync`, and confirmation performs three writes … There is
44
+ * also no confirm CAS/serialization. My concurrent-confirm probe returned both calls as `confirmed` and
45
+ * left both reservation files `confirmed_active`, although only one `active.json` won."* Its prescription
46
+ * was explicit: *"Use a transactional shared store with unique `(userId, expiry)` and `(userId, mintKey)`
47
+ * constraints, an active-grant CAS, and atomic confirm/revoke transitions. A database transaction is the
48
+ * simpler safe implementation."*
49
+ *
50
+ * So the primitives are now SQL, on one database file on the mounted Railway volume:
51
+ *
52
+ * · `PRIMARY KEY (user_id, mint_key)` — the mint key is claimed exactly once. Idempotent reserve.
53
+ * · `UNIQUE (user_id, expires_at_ms)` — the reservation instant is claimed exactly once, ACROSS
54
+ * processes, by the database rather than by a directory entry.
55
+ * · `CHECK (state IN …)` — a record can never hold a state this binary does not know.
56
+ * · `active_grants` — ONE ROW PER USER, the only pointer enforcement reads, moved by a
57
+ * COMPARE-AND-SWAP (`UPDATE … WHERE mint_key = <prior> AND expires_at_ms = <prior>`) inside the
58
+ * same transaction that promotes the reservation.
59
+ * · Every transition is a CONDITIONAL UPDATE whose `changes` count decides the winner
60
+ * (`UPDATE … WHERE state = 'reserved'`), so a concurrent double confirm has exactly one winner and
61
+ * the loser is told it was superseded — it is not a race that both sides win.
62
+ * · `BEGIN IMMEDIATE` takes the write lock before the first read, so a reserve/confirm/revoke is
63
+ * serialized against every other writer on the same file, in this process or another.
64
+ * · `journal_mode = WAL`, `synchronous = FULL` — a committed transition survives a crash and a power
65
+ * loss, which is the durability `rename()` did not give.
66
+ *
67
+ * ⚠️ THE PREMISE, STATED. One database file, reached by every worker: `numReplicas: 1` and one mounted
68
+ * volume. SQLite's locking is per file, so two volumes would be two independent stores — and two
69
+ * independent stores can each confirm a different cap for one person, which is the one shape that could
70
+ * widen a permission from inside the model. Nothing inside a process can check this; the deploy does
71
+ * (`railway.json`), and the staged first rollout below is what proves it before minting is even possible.
72
+ *
73
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
74
+ * 🔴 WHY `node:sqlite` AND NOT `better-sqlite3`
75
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
76
+ * `better-sqlite3` installs fine on this image (verified: a prebuilt linux-x64 / Node 22 binary, no
77
+ * compile). It is not a Railway problem. It is a PUBLISHED-PACKAGE problem: `otto-intel-mcp` ships to npm
78
+ * and is run as `npx otto-intel-mcp`, and `services/otto-execute` vendors it file-for-file at build time.
79
+ * A hard native dependency becomes a node-gyp source build wherever no prebuild matches the platform
80
+ * (musl/Alpine, a newer Node ABI, some ARM builds) and fails the install for every user of the CLI,
81
+ * including the majority who never touch delegation. `node:sqlite` is in the Node standard library at
82
+ * this package's own engine floor (`node >= 22`), needs no build step, and gives the identical
83
+ * primitives: `BEGIN IMMEDIATE` transactions, UNIQUE/CHECK constraints, conditional UPDATE with a
84
+ * `changes` count, WAL and `synchronous = FULL`.
85
+ *
86
+ * It is imported LAZILY and the failure is named: a runtime without `node:sqlite` opens an UNAVAILABLE
87
+ * store, and every delegated send then refuses by name rather than falling back to anything.
88
+ *
89
+ * 🔴 IT FAILS CLOSED, LOUDLY. No durable path configured, a database that cannot be opened, or a record
90
+ * that cannot be parsed means every delegated send refuses. An unreadable store is NOT an empty one, and
91
+ * the difference decides whether a person's cap is honoured.
92
+ *
93
+ * Nothing here logs a token, an address or a credential. A record is a user id, a mint key, two instants,
94
+ * a number, a version and a state.
95
+ */
96
+ import { mkdir, readdir, readFile } from 'node:fs/promises';
97
+ import { join } from 'node:path';
98
+ /** The explicit path override; otherwise the mounted Railway volume is used. */
99
+ export const DELEGATION_CAP_STORE_PATH_ENV = 'OTTO_DELEGATION_CAP_STORE_PATH';
100
+ const RAILWAY_VOLUME_ENV = 'RAILWAY_VOLUME_MOUNT_PATH';
101
+ const CAP_STORE_DIR_NAME = 'delegation-caps';
102
+ /** The database file inside that directory. The directory is kept so a legacy import can still read it. */
103
+ export const CAP_STORE_DB_FILE = 'caps.db';
104
+ /**
105
+ * 🔴 THE STAGED-DEPLOY GATE (gate finding, CRITICAL 2 — the first rolling deployment).
106
+ *
107
+ * "The ruleset handshake cannot fence the first rolling deployment. The version refusal exists only in
108
+ * the new binary … status/reserve/confirm may hit a new worker while prepare/submit hits a still-serving
109
+ * old worker, which cannot interpret `requiredVersion` and executes anyway."
110
+ *
111
+ * The handshake cannot fix that, because the binary that must refuse is the one that has never heard of
112
+ * it. A DEPLOYMENT SEQUENCE can, and this flag is what makes the sequence possible:
113
+ *
114
+ * 1. deploy with `DELEGATION_MINT_ENABLED` unset or false — no new permission can be created at all,
115
+ * so there is no cap-bearing grant for an old worker to execute at the ceiling;
116
+ * 2. confirm exactly one cap-aware worker is serving (`/health` reports `rulesetVersion`);
117
+ * 3. set the flag true and redeploy.
118
+ *
119
+ * DEFAULT FALSE IN CODE. An operator who forgets the variable gets the paused surface, never an
120
+ * unfenced first rollout. Sends under permissions that are ALREADY confirmed keep working under their
121
+ * own cap, and REVOKE always works — pausing the creation of authority must never pause its withdrawal.
122
+ */
123
+ export const DELEGATION_MINT_ENABLED_ENV = 'DELEGATION_MINT_ENABLED';
124
+ /** True only for an explicit affirmative value. Absent, empty, or anything else is FALSE. */
125
+ export function resolveDelegationMintEnabled(env = process.env) {
126
+ const raw = env[DELEGATION_MINT_ENABLED_ENV]?.trim().toLowerCase();
127
+ return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
128
+ }
129
+ /** The one sentence every paused surface says. No security narration: it is an update, and it is short. */
130
+ export const DELEGATION_MINT_PAUSED_MESSAGE = 'Delegation is paused for a few minutes while we update it';
131
+ /**
132
+ * The shape a mint key must have: what `createDelegation({ idempotencyKey })` accepts, bounded so it
133
+ * cannot be used as a path or a smuggling channel. A UUID satisfies it.
134
+ */
135
+ export const MINT_KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/;
136
+ /** A CDP end-user id, as every schema on this surface bounds it. */
137
+ export const END_USER_ID_PATTERN = /^[a-zA-Z0-9-]{1,100}$/;
138
+ /**
139
+ * How far back the offset ladder may walk, in whole seconds, before it refuses.
140
+ *
141
+ * Five minutes of headroom on a grant measured in days: invisible to the reader, far more collisions
142
+ * than a real person can produce, and bounded so a corrupt or adversarially stuffed table cannot turn a
143
+ * request into one that never returns.
144
+ */
145
+ export const CAP_EXPIRY_MAX_OFFSET_STEPS = 300;
146
+ /**
147
+ * 🔴 A RESERVATION THAT IS NEVER CONFIRMED DIES (seat ruling, round 4). Ten minutes is long enough for a
148
+ * person to finish an OTP sign-in and a mint, and short enough that an abandoned reservation stops being
149
+ * something a later actor could try to confirm. Its SECOND stays taken — releasing it would let a
150
+ * different permission land on an instant this one may already have minted at.
151
+ */
152
+ export const CAP_RESERVATION_WINDOW_MS = 10 * 60_000;
153
+ /**
154
+ * The version of the CAP RULESET this binary implements — distinct from the fence policy's version,
155
+ * which describes what Coinbase enforces.
156
+ *
157
+ * 🔴 WHY IT EXISTS (gate finding, HIGH — rolling deploy). "An older worker that accepts the same fence
158
+ * but lacks per-user cap enforcement can execute a new $10 permission up to the engine ceiling." The
159
+ * fence is identical on both binaries, so the fence cannot tell them apart. Every record carries the
160
+ * ruleset version it REQUIRES, a worker refuses a record it is too old to honour, and the page and the
161
+ * CLI refuse to reserve or mint against a server that reports a version below this one.
162
+ *
163
+ * 🔴 AND IT IS DEFENCE IN DEPTH, NOT THE FENCE FOR THE FIRST ROLLOUT — that is
164
+ * `DELEGATION_MINT_ENABLED` above, because a binary that predates the handshake cannot honour it.
165
+ */
166
+ /** v2 (fence v2, 2026-09-03): a permission minted under v2 consents to Yield Copilot vault WITHDRAWS as well as swaps/deposits. */
167
+ export const MODEL_B_CAP_RULESET_VERSION = 2;
168
+ /** The lifecycle of one permission, as the gate prescribed it, plus the one state a MIGRATION writes. */
169
+ export const CAP_RECORD_STATES = [
170
+ 'reserved',
171
+ 'confirmed_active',
172
+ 'superseded',
173
+ 'revoked',
174
+ 'expired',
175
+ /**
176
+ * 🔴 IMPORTED FROM A PRE-SQLITE STORE, AND AUTHORISING NOTHING. The filesystem store's records cannot
177
+ * be trusted as confirmations — nothing about them was written under a transaction — so the migration
178
+ * imports every one of them as `legacy`: never `confirmed_active`, never confirmable, never returned
179
+ * by `readActive`. Its instant STAYS CONSUMED, so no later permission can land on it. The consequence
180
+ * is accepted and stated on the page: a grant minted before this ships must be re-minted once.
181
+ */
182
+ 'legacy',
183
+ ];
184
+ export class DelegationCapExpiryUnavailableError extends Error {
185
+ constructor(steps) {
186
+ super(`no free expiry instant within ${steps} seconds of the requested one`);
187
+ this.name = 'DelegationCapExpiryUnavailableError';
188
+ }
189
+ }
190
+ /* ------------------------------------------------------------------ *
191
+ * Paths and pure helpers.
192
+ * ------------------------------------------------------------------ */
193
+ export function resolveDelegationCapStorePath(env = process.env) {
194
+ const explicit = env[DELEGATION_CAP_STORE_PATH_ENV]?.trim();
195
+ if (explicit)
196
+ return explicit;
197
+ const volume = env[RAILWAY_VOLUME_ENV]?.trim();
198
+ if (volume)
199
+ return `${volume.replace(/\/+$/, '')}/${CAP_STORE_DIR_NAME}`;
200
+ return null;
201
+ }
202
+ function assertIdentifiers(userId, mintKey) {
203
+ if (!END_USER_ID_PATTERN.test(userId))
204
+ throw new Error('the end user id is not a usable identity');
205
+ if (mintKey !== undefined && !MINT_KEY_PATTERN.test(mintKey))
206
+ throw new Error('the mint key is not a usable identity');
207
+ }
208
+ /** The instant `ms` floored to a whole second — the only granularity CDP preserves. */
209
+ export function wholeSecondMs(ms) {
210
+ return Math.floor(ms / 1000) * 1000;
211
+ }
212
+ /**
213
+ * The value that goes ON THE WIRE for a reserved second.
214
+ *
215
+ * 🔴 CDP REFUSES a `.000` fraction — 401 `unauthorized` "Wallet authentication error", a code that says
216
+ * nothing about the cause (isolated live on one session, 2026-09-02: `.000Z` refused, `.137Z` accepted
217
+ * on the very next call, `.000Z` refused again). And it TRUNCATES the fraction when it stores the grant
218
+ * (`…:46.001Z` → `…:46Z`, `…:45.999Z` → `…:45Z`), so any millisecond in 1..999 lands on the reserved
219
+ * second and none of them lengthens the permission.
220
+ */
221
+ export const MINT_EXPIRY_FILLER_MS = 1;
222
+ export function mintExpiryForSecond(wholeSecondIso) {
223
+ const instant = Date.parse(wholeSecondIso);
224
+ if (!Number.isFinite(instant))
225
+ throw new Error('the reserved expiry is not a parseable instant');
226
+ return new Date(wholeSecondMs(instant) + MINT_EXPIRY_FILLER_MS).toISOString();
227
+ }
228
+ /**
229
+ * The offset ladder as a pure function: the first whole second at or BEFORE `latestMs` that `taken`
230
+ * does not hold and that is still in the future, or undefined. It NEVER returns anything later than
231
+ * `latestMs` — a permission must not outlive the duration the person chose, not even by a second.
232
+ */
233
+ export function firstFreeExpiryMs(latestMs, nowMs, taken, maxSteps = CAP_EXPIRY_MAX_OFFSET_STEPS) {
234
+ const start = wholeSecondMs(latestMs);
235
+ for (let step = 0; step < maxSteps; step += 1) {
236
+ const candidate = start - step * 1000;
237
+ // Stepping back must never walk into the past: an already-expired reservation matches no usable grant.
238
+ if (candidate <= nowMs)
239
+ return undefined;
240
+ if (!taken(candidate))
241
+ return candidate;
242
+ }
243
+ return undefined;
244
+ }
245
+ /** A record's state as of `nowMs`: a reservation whose window has passed reads as `expired`. */
246
+ export function stateAsOf(record, nowMs) {
247
+ if (record.state !== 'reserved')
248
+ return record.state;
249
+ const reservedAt = Date.parse(record.reservedAt);
250
+ if (!Number.isFinite(reservedAt))
251
+ return 'expired';
252
+ return nowMs - reservedAt > CAP_RESERVATION_WINDOW_MS ? 'expired' : 'reserved';
253
+ }
254
+ /**
255
+ * 🔴 A MALFORMED DURABLE RECORD FAILS CLOSED (gate finding, MEDIUM). A row that exists and cannot be
256
+ * read as a record is not an absent row: `readActive` THROWS on one rather than answering "nothing is
257
+ * confirmed", because those two answers have opposite consequences. The database's own CHECK and NOT
258
+ * NULL constraints make this nearly unreachable — which is the point of moving to them — but the
259
+ * direction is still stated in code rather than assumed.
260
+ */
261
+ class CorruptCapRecordError extends Error {
262
+ constructor(where) {
263
+ super(`a durable per-swap cap record could not be read (${where})`);
264
+ this.name = 'CorruptCapRecordError';
265
+ }
266
+ }
267
+ function recordFromRow(row, where) {
268
+ if (row === undefined)
269
+ return undefined;
270
+ const expiresAtMs = Number(row.expires_at_ms);
271
+ const reservedAtMs = Number(row.reserved_at_ms);
272
+ if (typeof row.user_id !== 'string' ||
273
+ typeof row.mint_key !== 'string' ||
274
+ !Number.isFinite(expiresAtMs) ||
275
+ !Number.isFinite(reservedAtMs) ||
276
+ typeof row.state !== 'string' ||
277
+ !CAP_RECORD_STATES.includes(row.state) ||
278
+ !Number.isInteger(Number(row.cap_usd)) ||
279
+ !Number.isInteger(Number(row.required_version))) {
280
+ throw new CorruptCapRecordError(where);
281
+ }
282
+ const requestedMs = Number(row.requested_expires_at_ms);
283
+ const confirmedAtMs = row.confirmed_at_ms === null ? undefined : Number(row.confirmed_at_ms);
284
+ const endedAtMs = row.ended_at_ms === null ? undefined : Number(row.ended_at_ms);
285
+ return Object.freeze({
286
+ userId: row.user_id,
287
+ mintKey: row.mint_key,
288
+ expiresAt: new Date(expiresAtMs).toISOString(),
289
+ requestedExpiresAt: new Date(Number.isFinite(requestedMs) ? requestedMs : expiresAtMs + MINT_EXPIRY_FILLER_MS).toISOString(),
290
+ perSwapCapUsd: Number(row.cap_usd),
291
+ requiredVersion: Number(row.required_version),
292
+ revokeGeneration: Number.isFinite(Number(row.revoke_generation))
293
+ ? Number(row.revoke_generation)
294
+ : 0,
295
+ state: row.state,
296
+ reservedAt: new Date(reservedAtMs).toISOString(),
297
+ ...(confirmedAtMs !== undefined && Number.isFinite(confirmedAtMs)
298
+ ? { confirmedAt: new Date(confirmedAtMs).toISOString() }
299
+ : {}),
300
+ ...(endedAtMs !== undefined && Number.isFinite(endedAtMs)
301
+ ? { endedAt: new Date(endedAtMs).toISOString() }
302
+ : {}),
303
+ });
304
+ }
305
+ /**
306
+ * The schema. Every constraint here is a control, not bookkeeping:
307
+ * · `PRIMARY KEY (user_id, mint_key)` makes the mint key claimable once — reserve is idempotent.
308
+ * · `UNIQUE (user_id, expires_at_ms)` makes the enforcement instant claimable once, across processes.
309
+ * · `CHECK (state IN …)` makes an unknown state unwritable.
310
+ * · `active_grants` holds AT MOST ONE ROW PER USER by its primary key — the single pointer the send
311
+ * gate reads, moved only by a compare-and-swap on its prior value.
312
+ */
313
+ const SCHEMA = `
314
+ CREATE TABLE IF NOT EXISTS reservations (
315
+ user_id TEXT NOT NULL,
316
+ mint_key TEXT NOT NULL,
317
+ expires_at_ms INTEGER NOT NULL,
318
+ requested_expires_at_ms INTEGER NOT NULL,
319
+ cap_usd INTEGER NOT NULL,
320
+ required_version INTEGER NOT NULL,
321
+ state TEXT NOT NULL CHECK (state IN ('reserved','confirmed_active','superseded','revoked','expired','legacy')),
322
+ reserved_at_ms INTEGER NOT NULL,
323
+ revoke_generation INTEGER NOT NULL DEFAULT 0,
324
+ confirmed_at_ms INTEGER,
325
+ ended_at_ms INTEGER,
326
+ PRIMARY KEY (user_id, mint_key)
327
+ );
328
+ CREATE UNIQUE INDEX IF NOT EXISTS reservations_user_expiry
329
+ ON reservations (user_id, expires_at_ms);
330
+ CREATE TABLE IF NOT EXISTS active_grants (
331
+ user_id TEXT NOT NULL PRIMARY KEY,
332
+ mint_key TEXT NOT NULL,
333
+ expires_at_ms INTEGER NOT NULL
334
+ );
335
+ CREATE TABLE IF NOT EXISTS revoke_generations (
336
+ user_id TEXT NOT NULL PRIMARY KEY,
337
+ generation INTEGER NOT NULL,
338
+ bumped_at_ms INTEGER NOT NULL
339
+ );
340
+ CREATE TABLE IF NOT EXISTS store_meta (
341
+ key TEXT NOT NULL PRIMARY KEY,
342
+ value TEXT NOT NULL
343
+ );
344
+ `;
345
+ function readPragmas(db) {
346
+ const journal = db.prepare('PRAGMA journal_mode').get();
347
+ const sync = db.prepare('PRAGMA synchronous').get();
348
+ return Object.freeze({
349
+ journalMode: String(journal?.journal_mode ?? '').toLowerCase(),
350
+ synchronous: Number(sync?.synchronous ?? -1),
351
+ });
352
+ }
353
+ class SqliteDelegationCapStore {
354
+ db;
355
+ available = true;
356
+ /** What this connection actually has in force. Asserted by the suite; read by nothing else. */
357
+ pragmas;
358
+ constructor(db, pragmas) {
359
+ this.db = db;
360
+ this.pragmas = pragmas;
361
+ }
362
+ static open(db) {
363
+ /**
364
+ * 🔴 WAL + `synchronous = FULL` — a committed transition is on disk before the call returns, which
365
+ * is what `rename()` did not give. `busy_timeout` makes a concurrent writer WAIT for the lock rather
366
+ * than fail: a reserve that lost a race must walk the ladder, not refuse the person.
367
+ */
368
+ db.exec('PRAGMA journal_mode = WAL');
369
+ db.exec('PRAGMA synchronous = FULL');
370
+ db.exec('PRAGMA busy_timeout = 5000');
371
+ const applied = readPragmas(db);
372
+ db.exec(SCHEMA);
373
+ return new SqliteDelegationCapStore(db, applied);
374
+ }
375
+ close() {
376
+ try {
377
+ this.db.close();
378
+ }
379
+ catch {
380
+ // Closing twice, or closing a handle the process is already tearing down, is not a failure.
381
+ }
382
+ }
383
+ /**
384
+ * One write transaction. `BEGIN IMMEDIATE` takes the write lock BEFORE the first read, so a
385
+ * read-then-decide-then-write sequence cannot interleave with another writer's — the property a
386
+ * deferred transaction would not have given.
387
+ */
388
+ #transact(body) {
389
+ this.db.exec('BEGIN IMMEDIATE');
390
+ try {
391
+ const value = body();
392
+ this.db.exec('COMMIT');
393
+ return value;
394
+ }
395
+ catch (error) {
396
+ try {
397
+ this.db.exec('ROLLBACK');
398
+ }
399
+ catch {
400
+ // A transaction the failure already ended cannot be rolled back again; the original error stands.
401
+ }
402
+ throw error;
403
+ }
404
+ }
405
+ /**
406
+ * One READ transaction: a deferred `BEGIN`, so the pointer, the row and the generation an enforcement
407
+ * read compares are all seen at ONE committed instant (WAL gives a reader its own snapshot, and never
408
+ * blocks a writer). Three separate statements could straddle a Stop or a confirm; none of the
409
+ * interleavings widens anything, but an authoritative read should not have to argue that.
410
+ */
411
+ #snapshot(body) {
412
+ this.db.exec('BEGIN');
413
+ try {
414
+ const value = body();
415
+ this.db.exec('COMMIT');
416
+ return value;
417
+ }
418
+ catch (error) {
419
+ try {
420
+ this.db.exec('ROLLBACK');
421
+ }
422
+ catch {
423
+ // Already ended; the original error stands.
424
+ }
425
+ throw error;
426
+ }
427
+ }
428
+ #readByMintKey(userId, mintKey) {
429
+ const row = this.db
430
+ .prepare('SELECT * FROM reservations WHERE user_id = ? AND mint_key = ?')
431
+ .get(userId, mintKey);
432
+ return recordFromRow(row, 'reservations by mint key');
433
+ }
434
+ /** This person's revoke generation, 0 until their first Stop. */
435
+ #readGeneration(userId) {
436
+ const row = this.db
437
+ .prepare('SELECT generation FROM revoke_generations WHERE user_id = ?')
438
+ .get(userId);
439
+ if (row === undefined)
440
+ return 0;
441
+ const generation = Number(row.generation);
442
+ if (!Number.isFinite(generation))
443
+ throw new CorruptCapRecordError('revoke_generations');
444
+ return generation;
445
+ }
446
+ /** The generation write, INSIDE a caller's transaction: read the counter, write counter + 1. */
447
+ #bumpGenerationRow(userId, nowMs) {
448
+ const next = this.#readGeneration(userId) + 1;
449
+ this.db
450
+ .prepare(`INSERT INTO revoke_generations (user_id, generation, bumped_at_ms) VALUES (?, ?, ?)
451
+ ON CONFLICT(user_id) DO UPDATE SET generation = excluded.generation,
452
+ bumped_at_ms = excluded.bumped_at_ms`)
453
+ .run(userId, next, nowMs);
454
+ return next;
455
+ }
456
+ /**
457
+ * The record transition, INSIDE a caller's transaction: whatever record the pointer names moves
458
+ * `confirmed_active → revoked`, decided by the row (`WHERE state = 'confirmed_active'`), and the
459
+ * instant it held is answered. A pointer at a row that is not confirmed-active changes nothing.
460
+ */
461
+ #revokeActiveRow(userId, nowMs) {
462
+ const pointer = this.#readActiveRow(userId);
463
+ if (!pointer)
464
+ return undefined;
465
+ const ended = this.db
466
+ .prepare(`UPDATE reservations SET state = 'revoked', ended_at_ms = ?
467
+ WHERE user_id = ? AND mint_key = ? AND state = 'confirmed_active'`)
468
+ .run(nowMs, userId, pointer.mintKey);
469
+ return Number(ended.changes) === 1 ? new Date(pointer.expiresAtMs).toISOString() : undefined;
470
+ }
471
+ bumpRevokeGeneration(userId, nowMs) {
472
+ try {
473
+ assertIdentifiers(userId);
474
+ return Promise.resolve(this.#transact(() => this.#bumpGenerationRow(userId, nowMs)));
475
+ }
476
+ catch (error) {
477
+ return Promise.reject(error);
478
+ }
479
+ }
480
+ stop(userId, nowMs) {
481
+ try {
482
+ assertIdentifiers(userId);
483
+ /**
484
+ * 🔴 ONE `BEGIN IMMEDIATE`, BOTH WRITES. The write lock is held from before the generation read to
485
+ * after the row transition, so no reserve, confirm or second Stop interleaves, and a failure
486
+ * anywhere inside rolls BOTH back — never a bumped generation beside a record that still stands.
487
+ * The single-transaction shape is what the crash-injection test and the split-transaction
488
+ * mutation in the suite prove; do not lift either write out of this closure.
489
+ */
490
+ return Promise.resolve(this.#transact(() => {
491
+ const generation = this.#bumpGenerationRow(userId, nowMs);
492
+ const revokedExpiresAt = this.#revokeActiveRow(userId, nowMs);
493
+ return Object.freeze(revokedExpiresAt === undefined ? { generation } : { generation, revokedExpiresAt });
494
+ }));
495
+ }
496
+ catch (error) {
497
+ return Promise.reject(error);
498
+ }
499
+ }
500
+ #readActiveRow(userId) {
501
+ const row = this.db
502
+ .prepare('SELECT mint_key, expires_at_ms FROM active_grants WHERE user_id = ?')
503
+ .get(userId);
504
+ if (row === undefined)
505
+ return undefined;
506
+ const expiresAtMs = Number(row.expires_at_ms);
507
+ if (typeof row.mint_key !== 'string' || !Number.isFinite(expiresAtMs))
508
+ throw new CorruptCapRecordError('active_grants');
509
+ return { mintKey: row.mint_key, expiresAtMs };
510
+ }
511
+ reserve(userId, mintKey, latestExpiresAt, perSwapCapUsd, nowMs) {
512
+ try {
513
+ assertIdentifiers(userId, mintKey);
514
+ const latestMs = Date.parse(latestExpiresAt);
515
+ if (!Number.isFinite(latestMs))
516
+ throw new Error('the requested expiry is not a parseable instant');
517
+ return Promise.resolve(this.#transact(() => {
518
+ /**
519
+ * (1) THE MINT KEY IS THE IDEMPOTENCY KEY. A retry after a lost response finds its own row and
520
+ * returns the same instant rather than consuming a second one. Read INSIDE the transaction, so
521
+ * the answer cannot be overtaken between the read and the insert below.
522
+ */
523
+ const standing = this.#readByMintKey(userId, mintKey);
524
+ if (standing) {
525
+ return Object.freeze({
526
+ outcome: 'already_reserved',
527
+ record: Object.freeze({ ...standing, state: stateAsOf(standing, nowMs) }),
528
+ });
529
+ }
530
+ /**
531
+ * (2) THE UNIQUE INSERT. `UNIQUE (user_id, expires_at_ms)` is the cross-process exclusion the
532
+ * append-only log did not have and the directory entry only approximated: exactly one row can
533
+ * hold a second, the loser's insert changes nothing, and it walks EARLIER.
534
+ */
535
+ // The generation in force RIGHT NOW, read inside the same transaction: a Stop that lands
536
+ // after this reservation is written is what cancels it, and one that landed before is
537
+ // already reflected here.
538
+ const generation = this.#readGeneration(userId);
539
+ const insert = this.db.prepare(`INSERT INTO reservations
540
+ (user_id, mint_key, expires_at_ms, requested_expires_at_ms, cap_usd, required_version, state, reserved_at_ms, revoke_generation)
541
+ VALUES (?, ?, ?, ?, ?, ?, 'reserved', ?, ?)
542
+ ON CONFLICT DO NOTHING`);
543
+ const start = wholeSecondMs(latestMs);
544
+ for (let step = 0; step < CAP_EXPIRY_MAX_OFFSET_STEPS; step += 1) {
545
+ const candidate = start - step * 1000;
546
+ // Stepping back must never walk into the past, and never FORWARD past the chosen duration.
547
+ if (candidate <= nowMs)
548
+ break;
549
+ const written = insert.run(userId, mintKey, candidate, candidate + MINT_EXPIRY_FILLER_MS, perSwapCapUsd, MODEL_B_CAP_RULESET_VERSION, nowMs, generation);
550
+ if (Number(written.changes) !== 1)
551
+ continue;
552
+ return Object.freeze({
553
+ outcome: 'reserved',
554
+ record: Object.freeze({
555
+ userId,
556
+ mintKey,
557
+ expiresAt: new Date(candidate).toISOString(),
558
+ requestedExpiresAt: new Date(candidate + MINT_EXPIRY_FILLER_MS).toISOString(),
559
+ perSwapCapUsd,
560
+ requiredVersion: MODEL_B_CAP_RULESET_VERSION,
561
+ revokeGeneration: generation,
562
+ state: 'reserved',
563
+ reservedAt: new Date(nowMs).toISOString(),
564
+ }),
565
+ });
566
+ }
567
+ throw new DelegationCapExpiryUnavailableError(CAP_EXPIRY_MAX_OFFSET_STEPS);
568
+ }));
569
+ }
570
+ catch (error) {
571
+ return Promise.reject(error);
572
+ }
573
+ }
574
+ readReservation(userId, mintKey, nowMs) {
575
+ try {
576
+ assertIdentifiers(userId, mintKey);
577
+ const record = this.#readByMintKey(userId, mintKey);
578
+ return Promise.resolve(record ? Object.freeze({ ...record, state: stateAsOf(record, nowMs) }) : undefined);
579
+ }
580
+ catch (error) {
581
+ return Promise.reject(error);
582
+ }
583
+ }
584
+ confirm(userId, mintKey, grantExpiresAt, nowMs) {
585
+ try {
586
+ assertIdentifiers(userId, mintKey);
587
+ return Promise.resolve(this.#transact(() => {
588
+ const stored = this.#readByMintKey(userId, mintKey);
589
+ if (!stored)
590
+ return { outcome: 'unreserved' };
591
+ const record = Object.freeze({ ...stored, state: stateAsOf(stored, nowMs) });
592
+ const active = this.#readActiveRow(userId);
593
+ if (record.state === 'confirmed_active') {
594
+ /**
595
+ * 🔴 ONE-SHOT, AND THIS IS THE HALF THAT MATTERS. A record that WAS active and has since
596
+ * been superseded must never be revived by a second confirm — that would resurrect a
597
+ * replaced permission, which on a wider cap is exactly the widening this unit exists to
598
+ * prevent. It is still confirmed only while it is the one `active_grants` names.
599
+ *
600
+ * 🔴 AND THE LIVE GRANT MUST STILL BE THE ONE IT NAMES (gate finding, MEDIUM — round 4: "a
601
+ * re-confirmed current record never compares the supplied live grant before returning
602
+ * `already_confirmed`; my probe passed a different live expiry and still received
603
+ * `already_confirmed`"). A retry that carries a DIFFERENT live instant is not a retry.
604
+ */
605
+ if (active &&
606
+ active.mintKey === record.mintKey &&
607
+ active.expiresAtMs === Date.parse(record.expiresAt)) {
608
+ return Date.parse(grantExpiresAt) === Date.parse(record.expiresAt)
609
+ ? { outcome: 'already_confirmed', record }
610
+ : { outcome: 'expiry_mismatch', record };
611
+ }
612
+ return { outcome: 'superseded' };
613
+ }
614
+ /**
615
+ * 🔴 A SETTLED RECORD IS NEVER BROUGHT BACK. `superseded` (a later confirmation won) and
616
+ * `revoked` (the person, or a mismatch) are terminal: promoting either would resurrect a
617
+ * permission that was deliberately replaced or taken away. `expired` and `legacy` are their
618
+ * own answers because the remedy differs — nothing was ever confirmed, so the person mints
619
+ * again.
620
+ */
621
+ if (record.state === 'superseded' || record.state === 'revoked')
622
+ return { outcome: 'superseded' };
623
+ if (record.state !== 'reserved')
624
+ return { outcome: 'not_reservable', state: record.state };
625
+ /**
626
+ * 🔴 STOP WINS OVER A MINT THAT WAS ALREADY IN FLIGHT. The person pressed Stop after this
627
+ * reservation was taken; promoting it now would hand them the permission they just cancelled.
628
+ * Checked BEFORE the expiry so the answer names what actually happened.
629
+ */
630
+ if (record.revokeGeneration !== this.#readGeneration(userId))
631
+ return { outcome: 'cancelled' };
632
+ if (Date.parse(grantExpiresAt) !== Date.parse(record.expiresAt))
633
+ return { outcome: 'expiry_mismatch', record };
634
+ /**
635
+ * 🔴 THE ONE-SHOT TRANSITION, ENFORCED BY THE DATABASE. `WHERE state = 'reserved'` means a
636
+ * concurrent second confirm changes NO rows and is answered below from the state it lost to —
637
+ * exactly one winner, decided by the row and not by who read first. (The gate's round-4 probe
638
+ * on the filesystem store: "both calls returned `confirmed` and left both reservation files
639
+ * `confirmed_active`, although only one `active.json` won.")
640
+ */
641
+ const promoted = this.db
642
+ .prepare(`UPDATE reservations SET state = 'confirmed_active', confirmed_at_ms = ?
643
+ WHERE user_id = ? AND mint_key = ? AND state = 'reserved'`)
644
+ .run(nowMs, userId, mintKey);
645
+ if (Number(promoted.changes) !== 1) {
646
+ const now = this.#readByMintKey(userId, mintKey);
647
+ return now && now.state === 'confirmed_active'
648
+ ? { outcome: 'already_confirmed', record: now }
649
+ : { outcome: 'superseded' };
650
+ }
651
+ const expiresAtMs = Date.parse(record.expiresAt);
652
+ /**
653
+ * 🔴 THE ACTIVE-GRANT POINTER MOVES BY COMPARE-AND-SWAP, in this same transaction. The WHERE
654
+ * clause names the value this transaction READ; if anything else moved the pointer, no row
655
+ * changes and the whole transition is thrown away rather than half-applied.
656
+ */
657
+ const swapped = active
658
+ ? this.db
659
+ .prepare(`UPDATE active_grants SET mint_key = ?, expires_at_ms = ?
660
+ WHERE user_id = ? AND mint_key = ? AND expires_at_ms = ?`)
661
+ .run(mintKey, expiresAtMs, userId, active.mintKey, active.expiresAtMs)
662
+ : this.db
663
+ .prepare(`INSERT INTO active_grants (user_id, mint_key, expires_at_ms) VALUES (?, ?, ?)
664
+ ON CONFLICT DO NOTHING`)
665
+ .run(userId, mintKey, expiresAtMs);
666
+ if (Number(swapped.changes) !== 1) {
667
+ throw new Error('the active-grant pointer moved while this permission was being confirmed');
668
+ }
669
+ // The permission this one replaces is marked superseded in the SAME transaction — never a
670
+ // second write that could be lost.
671
+ if (active && active.expiresAtMs !== expiresAtMs) {
672
+ this.db
673
+ .prepare(`UPDATE reservations SET state = 'superseded', ended_at_ms = ?
674
+ WHERE user_id = ? AND mint_key = ? AND state = 'confirmed_active'`)
675
+ .run(nowMs, userId, active.mintKey);
676
+ }
677
+ const confirmed = this.#readByMintKey(userId, mintKey);
678
+ if (!confirmed)
679
+ throw new CorruptCapRecordError('reservations after confirm');
680
+ return { outcome: 'confirmed', record: confirmed };
681
+ }));
682
+ }
683
+ catch (error) {
684
+ return Promise.reject(error);
685
+ }
686
+ }
687
+ readActive(userId, nowMs) {
688
+ try {
689
+ assertIdentifiers(userId);
690
+ return Promise.resolve(this.#snapshot(() => {
691
+ const pointer = this.#readActiveRow(userId);
692
+ if (!pointer)
693
+ return undefined;
694
+ const record = this.#readByMintKey(userId, pointer.mintKey);
695
+ if (!record || record.state !== 'confirmed_active')
696
+ return undefined;
697
+ // The pointer and the row must agree; a pointer at another instant authorises nothing.
698
+ if (Date.parse(record.expiresAt) !== pointer.expiresAtMs)
699
+ return undefined;
700
+ /**
701
+ * 🔴 A RECORD FROM BEFORE THE LAST STOP AUTHORISES NOTHING (gate r5 CRITICAL: "`readActive`
702
+ * does not compare the record's generation with the current generation"). A confirmed record
703
+ * always carries the generation in force when it was confirmed — `confirm` cancels anything
704
+ * older — so a generation that has moved since means a Stop was committed after it. The row
705
+ * transition in `stop` is the same transaction, so this is a second, independent reading of
706
+ * the same fact: whatever the row says, a Stop after a confirmation means zero authority.
707
+ */
708
+ if (record.revokeGeneration !== this.#readGeneration(userId))
709
+ return undefined;
710
+ // A record whose own expiry has passed authorises nothing, whatever the pointer still says.
711
+ return Date.parse(record.expiresAt) > nowMs ? record : undefined;
712
+ }));
713
+ }
714
+ catch (error) {
715
+ return Promise.reject(error);
716
+ }
717
+ }
718
+ revoke(userId, expiresAt, nowMs) {
719
+ try {
720
+ assertIdentifiers(userId);
721
+ return Promise.resolve(this.#transact(() => {
722
+ const pointer = this.#readActiveRow(userId);
723
+ if (!pointer || pointer.expiresAtMs !== Date.parse(expiresAt))
724
+ return false;
725
+ /**
726
+ * 🔴 ONE-SHOT AGAIN, AND THE ROW IS WHAT DECIDES. The transition lands only if the row IS
727
+ * confirmed-active, so a second revoke — or one racing a supersession — changes nothing and is
728
+ * answered `false`. The POINTER IS DELIBERATELY LEFT WHERE IT IS: `readActive` follows it to
729
+ * the row and requires `confirmed_active`, so the state on the row is the single fact that
730
+ * decides whether anything may be spent. One place to look, one transition to prove, and the
731
+ * row's instant stays consumed so no later permission can land on it.
732
+ */
733
+ const ended = this.db
734
+ .prepare(`UPDATE reservations SET state = 'revoked', ended_at_ms = ?
735
+ WHERE user_id = ? AND mint_key = ? AND state = 'confirmed_active'`)
736
+ .run(nowMs, userId, pointer.mintKey);
737
+ return Number(ended.changes) === 1;
738
+ }));
739
+ }
740
+ catch (error) {
741
+ return Promise.reject(error);
742
+ }
743
+ }
744
+ /**
745
+ * 🔴 THE MIGRATION, RUN ONCE. Every record a pre-SQLite store holds is imported as `legacy`: never
746
+ * `confirmed_active`, never confirmable, never returned by `readActive`. Nothing about those records
747
+ * was written under a transaction, so treating one as a confirmation would be trusting exactly the
748
+ * primitives the gate retired. Their INSTANTS are kept consumed, so no later permission can land on
749
+ * one. The consequence — every grant minted before this ships must be re-minted once on
750
+ * `/app/delegate` — is accepted, and it is the safe direction: an unconfirmed grant refuses rather
751
+ * than falling back to the shared ceiling.
752
+ */
753
+ importLegacy(records) {
754
+ return this.#transact(() => {
755
+ const done = this.db
756
+ .prepare("SELECT value FROM store_meta WHERE key = 'legacy_import'")
757
+ .get();
758
+ if (done !== undefined)
759
+ return 0;
760
+ const insert = this.db.prepare(`INSERT INTO reservations
761
+ (user_id, mint_key, expires_at_ms, requested_expires_at_ms, cap_usd, required_version, state, reserved_at_ms, revoke_generation, ended_at_ms)
762
+ VALUES (?, ?, ?, ?, ?, ?, 'legacy', ?, 0, ?)
763
+ ON CONFLICT DO NOTHING`);
764
+ let imported = 0;
765
+ for (const record of records) {
766
+ const expiresAtMs = Date.parse(record.expiresAt);
767
+ const reservedAtMs = Date.parse(record.reservedAt);
768
+ if (!Number.isFinite(expiresAtMs) || !Number.isFinite(reservedAtMs))
769
+ continue;
770
+ if (!END_USER_ID_PATTERN.test(record.userId))
771
+ continue;
772
+ if (!MINT_KEY_PATTERN.test(record.mintKey))
773
+ continue;
774
+ const written = insert.run(record.userId, record.mintKey, expiresAtMs, expiresAtMs + MINT_EXPIRY_FILLER_MS, record.perSwapCapUsd, record.requiredVersion, reservedAtMs, Date.now());
775
+ imported += Number(written.changes);
776
+ }
777
+ this.db
778
+ .prepare("INSERT INTO store_meta (key, value) VALUES ('legacy_import', ?)")
779
+ .run(new Date().toISOString());
780
+ return imported;
781
+ });
782
+ }
783
+ }
784
+ /* ------------------------------------------------------------------ *
785
+ * Reading whatever a pre-SQLite store left behind.
786
+ * ------------------------------------------------------------------ */
787
+ /** Parse one JSON record as either store wrote it. Anything unreadable is skipped, never guessed at. */
788
+ function legacyRecordFrom(text) {
789
+ let value;
790
+ try {
791
+ value = JSON.parse(text);
792
+ }
793
+ catch {
794
+ return undefined;
795
+ }
796
+ if (typeof value !== 'object' || value === null)
797
+ return undefined;
798
+ const r = value;
799
+ if (typeof r.userId !== 'string' || typeof r.mintKey !== 'string')
800
+ return undefined;
801
+ if (typeof r.expiresAt !== 'string' || !Number.isFinite(Date.parse(r.expiresAt)))
802
+ return undefined;
803
+ if (typeof r.perSwapCapUsd !== 'number' || !Number.isInteger(r.perSwapCapUsd))
804
+ return undefined;
805
+ if (typeof r.reservedAt !== 'string' || !Number.isFinite(Date.parse(r.reservedAt)))
806
+ return undefined;
807
+ return Object.freeze({
808
+ userId: r.userId,
809
+ mintKey: r.mintKey,
810
+ expiresAt: r.expiresAt,
811
+ requestedExpiresAt: typeof r.requestedExpiresAt === 'string' ? r.requestedExpiresAt : r.expiresAt,
812
+ perSwapCapUsd: r.perSwapCapUsd,
813
+ requiredVersion: typeof r.requiredVersion === 'number' && Number.isInteger(r.requiredVersion)
814
+ ? r.requiredVersion
815
+ : MODEL_B_CAP_RULESET_VERSION,
816
+ revokeGeneration: 0,
817
+ state: 'legacy',
818
+ reservedAt: r.reservedAt,
819
+ });
820
+ }
821
+ /**
822
+ * Everything a pre-SQLite store left in `root`: the round-1/2 append-only JSONL, and the round-4
823
+ * per-instant JSON files under `u/<userId>/r/`. Read best-effort — a migration that THREW on an
824
+ * unreadable leftover would stop the store opening at all, and the leftovers authorise nothing either
825
+ * way.
826
+ */
827
+ export async function readLegacyCapRecords(root) {
828
+ const found = [];
829
+ // (a) the append-only log rounds 1-3 wrote.
830
+ for (const name of await readdir(root).catch(() => [])) {
831
+ if (!name.endsWith('.jsonl'))
832
+ continue;
833
+ const text = await readFile(join(root, name), 'utf8').catch(() => '');
834
+ for (const line of text.split('\n')) {
835
+ const record = line.trim() ? legacyRecordFrom(line) : undefined;
836
+ if (record)
837
+ found.push(record);
838
+ }
839
+ }
840
+ // (b) the per-instant files round 4 wrote: <root>/u/<userId>/r/<ms>.json
841
+ const users = await readdir(join(root, 'u'), { withFileTypes: true }).catch(() => []);
842
+ for (const user of users) {
843
+ if (!user.isDirectory())
844
+ continue;
845
+ const dir = join(root, 'u', user.name, 'r');
846
+ for (const name of await readdir(dir).catch(() => [])) {
847
+ if (!name.endsWith('.json'))
848
+ continue;
849
+ const record = legacyRecordFrom(await readFile(join(dir, name), 'utf8').catch(() => ''));
850
+ if (record)
851
+ found.push(record);
852
+ }
853
+ }
854
+ return found;
855
+ }
856
+ /* ------------------------------------------------------------------ *
857
+ * Construction.
858
+ * ------------------------------------------------------------------ */
859
+ /** A store that is present but permanently unavailable — what an unconfigured deploy gets. */
860
+ export function unavailableDelegationCapStore(reason) {
861
+ const dead = () => Promise.reject(new Error(reason));
862
+ return Object.freeze({
863
+ available: false,
864
+ unavailableReason: reason,
865
+ reserve: dead,
866
+ readReservation: dead,
867
+ confirm: dead,
868
+ readActive: dead,
869
+ revoke: dead,
870
+ bumpRevokeGeneration: dead,
871
+ stop: dead,
872
+ });
873
+ }
874
+ /**
875
+ * Open the SQLite store at `root`, importing anything a pre-SQLite store left behind as `legacy`.
876
+ * Exported so a test can open one on a temp directory without going through the environment.
877
+ */
878
+ export async function openDelegationCapStore(root) {
879
+ let DatabaseSync;
880
+ try {
881
+ /**
882
+ * 🔴 THE SPECIFIER IS INDIRECT ON PURPOSE, and it is the only reason this is not a plain import.
883
+ * The dApp's root typecheck compiles this module (a contract test reaches it through the admin
884
+ * definitions) against `@types/node` 20, which predates `node:sqlite`. A literal specifier would
885
+ * fail that gate over a module this file only ever touches at RUNTIME, on the hosted worker's Node
886
+ * 22. The interfaces above keep every call site typed; only the module resolution is given up.
887
+ */
888
+ const specifier = 'node:sqlite';
889
+ ({ DatabaseSync } = (await import(specifier)));
890
+ }
891
+ catch {
892
+ // Node older than the `node:sqlite` release. Named, and fail-closed — never an in-memory fallback.
893
+ return unavailableDelegationCapStore('this runtime has no node:sqlite, so the per-swap cap cannot be recorded durably; run on Node 22 or newer');
894
+ }
895
+ let store;
896
+ try {
897
+ await mkdir(root, { recursive: true });
898
+ store = SqliteDelegationCapStore.open(new DatabaseSync(join(root, CAP_STORE_DB_FILE)));
899
+ }
900
+ catch {
901
+ return unavailableDelegationCapStore('the per-swap cap store could not be opened for writing');
902
+ }
903
+ try {
904
+ const legacy = await readLegacyCapRecords(root);
905
+ if (legacy.length > 0)
906
+ store.importLegacy(legacy);
907
+ else
908
+ store.importLegacy([]);
909
+ }
910
+ catch {
911
+ // A leftover that cannot be imported does not stop the store: those records authorise nothing here
912
+ // either way, and the person's remedy is the same re-mint.
913
+ }
914
+ return store;
915
+ }
916
+ /**
917
+ * The store for this process, from the environment. Unconfigured is NOT a silent in-memory fallback: it
918
+ * is an unavailable store, so the delegated send refuses by name and the ops fix (mount the volume, or
919
+ * set `OTTO_DELEGATION_CAP_STORE_PATH`) is the thing the refusal asks for.
920
+ */
921
+ export async function createDelegationCapStore(env = process.env) {
922
+ const path = resolveDelegationCapStorePath(env);
923
+ if (!path) {
924
+ return unavailableDelegationCapStore(`no durable per-swap cap store is configured: set ${DELEGATION_CAP_STORE_PATH_ENV} or mount a volume (${RAILWAY_VOLUME_ENV})`);
925
+ }
926
+ return openDelegationCapStore(path);
927
+ }
928
+ /**
929
+ * An in-memory store — for TESTS and for a local stdio run, where nothing is durable anyway.
930
+ *
931
+ * 🔴 IT IMPLEMENTS THE SAME STATE MACHINE, and the suite runs the SAME conformance cases against both,
932
+ * so a test cannot pass against semantics production does not have. What it cannot model is the
933
+ * cross-process exclusion and the crash durability SQLite provides — those are asserted against a real
934
+ * database file, and against two real processes, only.
935
+ */
936
+ export function inMemoryDelegationCapStore(seed = []) {
937
+ const reservations = new Map();
938
+ const mints = new Map();
939
+ const active = new Map();
940
+ const generations = new Map();
941
+ const key = (userId, expiresAt) => `${userId}\n${Date.parse(expiresAt)}`;
942
+ /** The generation bump, shared by `bumpRevokeGeneration` and `stop` — one decision in one place. */
943
+ const bump = (userId) => {
944
+ const next = (generations.get(userId) ?? 0) + 1;
945
+ generations.set(userId, next);
946
+ return next;
947
+ };
948
+ for (const record of seed) {
949
+ reservations.set(key(record.userId, record.expiresAt), record);
950
+ mints.set(`${record.userId}\n${record.mintKey}`, record.expiresAt);
951
+ if (record.state === 'confirmed_active')
952
+ active.set(record.userId, record);
953
+ }
954
+ const readReservation = (userId, mintKey, nowMs) => {
955
+ const expiresAt = mints.get(`${userId}\n${mintKey}`);
956
+ if (expiresAt === undefined)
957
+ return undefined;
958
+ const record = reservations.get(key(userId, expiresAt));
959
+ return record ? Object.freeze({ ...record, state: stateAsOf(record, nowMs) }) : undefined;
960
+ };
961
+ const store = {
962
+ available: true,
963
+ reserve(userId, mintKey, latestExpiresAt, perSwapCapUsd, nowMs) {
964
+ try {
965
+ assertIdentifiers(userId, mintKey);
966
+ }
967
+ catch (error) {
968
+ return Promise.reject(error);
969
+ }
970
+ const latestMs = Date.parse(latestExpiresAt);
971
+ if (!Number.isFinite(latestMs))
972
+ return Promise.reject(new Error('the requested expiry is not a parseable instant'));
973
+ const standing = readReservation(userId, mintKey, nowMs);
974
+ if (standing)
975
+ return Promise.resolve({ outcome: 'already_reserved', record: standing });
976
+ const chosen = firstFreeExpiryMs(latestMs, nowMs, (candidate) => reservations.has(`${userId}\n${candidate}`));
977
+ if (chosen === undefined)
978
+ return Promise.reject(new DelegationCapExpiryUnavailableError(CAP_EXPIRY_MAX_OFFSET_STEPS));
979
+ const record = Object.freeze({
980
+ userId,
981
+ mintKey,
982
+ expiresAt: new Date(chosen).toISOString(),
983
+ requestedExpiresAt: new Date(chosen + MINT_EXPIRY_FILLER_MS).toISOString(),
984
+ perSwapCapUsd,
985
+ requiredVersion: MODEL_B_CAP_RULESET_VERSION,
986
+ revokeGeneration: generations.get(userId) ?? 0,
987
+ state: 'reserved',
988
+ reservedAt: new Date(nowMs).toISOString(),
989
+ });
990
+ reservations.set(key(userId, record.expiresAt), record);
991
+ mints.set(`${userId}\n${mintKey}`, record.expiresAt);
992
+ return Promise.resolve({ outcome: 'reserved', record });
993
+ },
994
+ readReservation(userId, mintKey, nowMs) {
995
+ return Promise.resolve(readReservation(userId, mintKey, nowMs));
996
+ },
997
+ confirm(userId, mintKey, grantExpiresAt, nowMs) {
998
+ const record = readReservation(userId, mintKey, nowMs);
999
+ if (!record)
1000
+ return Promise.resolve({ outcome: 'unreserved' });
1001
+ const live = active.get(userId);
1002
+ if (record.state === 'confirmed_active') {
1003
+ if (live && live.mintKey === record.mintKey && live.expiresAt === record.expiresAt) {
1004
+ // The same comparison the SQLite store makes: a retry carrying a DIFFERENT live instant is
1005
+ // not a retry (gate finding, round-4 MEDIUM).
1006
+ return Promise.resolve(Date.parse(grantExpiresAt) === Date.parse(record.expiresAt)
1007
+ ? { outcome: 'already_confirmed', record: live }
1008
+ : { outcome: 'expiry_mismatch', record });
1009
+ }
1010
+ return Promise.resolve({ outcome: 'superseded' });
1011
+ }
1012
+ // Terminal states are never revived — the same rule the SQLite store applies.
1013
+ if (record.state === 'superseded' || record.state === 'revoked')
1014
+ return Promise.resolve({ outcome: 'superseded' });
1015
+ if (record.state !== 'reserved')
1016
+ return Promise.resolve({ outcome: 'not_reservable', state: record.state });
1017
+ // The same rule the SQLite store applies: Stop wins over a mint already in flight.
1018
+ if (record.revokeGeneration !== (generations.get(userId) ?? 0))
1019
+ return Promise.resolve({ outcome: 'cancelled' });
1020
+ if (Date.parse(grantExpiresAt) !== Date.parse(record.expiresAt))
1021
+ return Promise.resolve({ outcome: 'expiry_mismatch', record });
1022
+ const confirmed = Object.freeze({
1023
+ ...record,
1024
+ state: 'confirmed_active',
1025
+ confirmedAt: new Date(nowMs).toISOString(),
1026
+ });
1027
+ // Only a record that IS confirmed-active is superseded — the row's `WHERE state = 'confirmed_active'`.
1028
+ if (live && live.state === 'confirmed_active' && live.expiresAt !== confirmed.expiresAt) {
1029
+ reservations.set(key(userId, live.expiresAt), Object.freeze({ ...live, state: 'superseded', endedAt: new Date(nowMs).toISOString() }));
1030
+ }
1031
+ reservations.set(key(userId, confirmed.expiresAt), confirmed);
1032
+ active.set(userId, confirmed);
1033
+ return Promise.resolve({ outcome: 'confirmed', record: confirmed });
1034
+ },
1035
+ readActive(userId, nowMs) {
1036
+ const record = active.get(userId);
1037
+ if (!record || record.state !== 'confirmed_active')
1038
+ return Promise.resolve(undefined);
1039
+ // The same second reading of a Stop the SQLite store makes: a record from before it authorises nothing.
1040
+ if (record.revokeGeneration !== (generations.get(userId) ?? 0))
1041
+ return Promise.resolve(undefined);
1042
+ return Promise.resolve(Date.parse(record.expiresAt) > nowMs ? record : undefined);
1043
+ },
1044
+ revoke(userId, expiresAt, nowMs) {
1045
+ const record = active.get(userId);
1046
+ if (!record || Date.parse(record.expiresAt) !== Date.parse(expiresAt))
1047
+ return Promise.resolve(false);
1048
+ // 🔴 ONE-SHOT, like the row's `WHERE state = 'confirmed_active'` (gate r5 LOW: the twin answered a
1049
+ // second revoke `true`). A record already revoked, superseded or expired changes nothing.
1050
+ if (record.state !== 'confirmed_active')
1051
+ return Promise.resolve(false);
1052
+ const revoked = Object.freeze({
1053
+ ...record,
1054
+ state: 'revoked',
1055
+ endedAt: new Date(nowMs).toISOString(),
1056
+ });
1057
+ // The pointer stays and the STATE decides — the same shape the SQLite store has.
1058
+ active.set(userId, revoked);
1059
+ reservations.set(key(userId, revoked.expiresAt), revoked);
1060
+ return Promise.resolve(true);
1061
+ },
1062
+ bumpRevokeGeneration(userId) {
1063
+ return Promise.resolve(bump(userId));
1064
+ },
1065
+ stop(userId, nowMs) {
1066
+ // Synchronous, so the two writes are as indivisible here as one transaction makes them in SQLite.
1067
+ const generation = bump(userId);
1068
+ const record = active.get(userId);
1069
+ if (!record || record.state !== 'confirmed_active')
1070
+ return Promise.resolve({ generation });
1071
+ const revoked = Object.freeze({
1072
+ ...record,
1073
+ state: 'revoked',
1074
+ endedAt: new Date(nowMs).toISOString(),
1075
+ });
1076
+ active.set(userId, revoked);
1077
+ reservations.set(key(userId, revoked.expiresAt), revoked);
1078
+ return Promise.resolve({ generation, revokedExpiresAt: revoked.expiresAt });
1079
+ },
1080
+ };
1081
+ return Object.freeze(store);
1082
+ }
1083
+ //# sourceMappingURL=execution-delegation-cap-store.js.map