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
@@ -1,9 +1,11 @@
1
1
  /**
2
- * execution-delegation-admin.ts — the three delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
2
+ * execution-delegation-admin.ts — the four delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
3
3
  *
4
4
  * otto_delegation_fence — the ONLY writer of the Model-B project policy: ensure-by-digest + read-back.
5
5
  * otto_delegation_status — one user's grant/fence read-back, bound to the user a CDP access token resolves to.
6
6
  * otto_delegation_revoke — developer-side revoke with read-back, fail-closed under the rig lock.
7
+ * otto_delegation_cap — reserves the expiry a delegation is minted with, records the chosen per-swap
8
+ * cap against it, and confirms after the mint that CDP kept that exact instant.
7
9
  *
8
10
  * Every tool: the request header FIRST (constant-time, before parsing or any CDP read — the same seam as
9
11
  * `otto_submit_under_delegation`), then the strict schema, then the reads. Nothing here logs a token, the
@@ -44,7 +46,7 @@
44
46
  * support_reason }` from an operator holding the server secret; the reason is written to the structured log
45
47
  * through the boot redactor (URLs and configured secret values never land in the log), the token never is.
46
48
  */
47
- import { type DelegationFenceResult, type DelegationFenceStatusResult, type DelegationRevokeResult, type DelegationStatusResult } from './execution-delegation-admin-definition.js';
49
+ import { type DelegationCapResult, type DelegationFenceResult, type DelegationFenceStatusResult, type DelegationRevokeResult, type DelegationStatusResult } from './execution-delegation-admin-definition.js';
48
50
  import { type DelegationCaller } from './execution-delegation.js';
49
51
  import type { ExecutionToolRuntime } from './execution-tools.js';
50
52
  /** Read-back after a write: the engine propagates asynchronously (the proof kit waited 3 s flat). */
@@ -61,6 +63,50 @@ export declare function ensureDelegationFence(runtime: ExecutionToolRuntime, raw
61
63
  export declare function readDelegationFenceStatus(runtime: ExecutionToolRuntime): Promise<DelegationFenceStatusResult>;
62
64
  /** `otto_delegation_status` — one user's read-back, bound to the token's user. */
63
65
  export declare function readDelegationStatus(runtime: ExecutionToolRuntime, rawInput: unknown, caller: DelegationCaller): Promise<DelegationStatusResult>;
66
+ /**
67
+ * `otto_delegation_cap` — RESERVE the expiry a delegation will be minted with, record the chosen per-swap
68
+ * cap against it, and CONFIRM the mint (Founder word `delegate cap choice`, 2026-09-02; round 3, after the
69
+ * codex gate NO-GO'd rounds 1 and 2 and the seat ruled the collision out rather than documenting it).
70
+ *
71
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
72
+ * 🔴 WHY THE ORDER IS RESERVE → MINT → CONFIRM, AND NOT MINT → RECORD
73
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
74
+ * The gate's second finding is the whole reason: an expiry is the ONLY thing a send can learn about a live
75
+ * grant, so keying the RECORD by anything else — r1's expiry-as-key, r2's mint key — fixes the record and
76
+ * not the send. Its probe minted a $10 replacement carrying the SAME expiry as a standing $5,000 row and
77
+ * watched a plan run at $5,000. Live confirmation of exactly that, on the dev project (2026-09-02T22:17Z):
78
+ * CDP refuses a second mint over a live grant (409 `already_exists`), but after a user-side revoke a
79
+ * re-mint at the SAME instant succeeds and reads back at that identical instant. Two grants for one user
80
+ * CAN share an expiry.
81
+ *
82
+ * So round 3 stops trying to tell them apart and makes them impossible:
83
+ *
84
+ * RESERVE — the SERVER picks the instant. It walks back from the second the person's chosen duration
85
+ * lands on and takes the first whole second no durable row for this user occupies, then writes the cap
86
+ * against it BEFORE any grant exists. The caller cannot choose the enforcement key, which is the
87
+ * property that makes it unique. The ladder only ever steps EARLIER (seat ruling): a permission must
88
+ * never outlive the duration the person chose, and the page shows the instant it actually got.
89
+ *
90
+ * MINT — the caller passes `mint_expires_at` to `createDelegation` verbatim. It is the reserved second
91
+ * plus one millisecond, because CDP refuses a bare `.000Z` (401 `unauthorized` "Wallet authentication
92
+ * error" — isolated on one session: `.000Z` refused, `.137Z` accepted, `.000Z` refused again) and
93
+ * truncates the filler away, storing exactly the reserved second.
94
+ *
95
+ * CONFIRM — the server re-reads the grant from CDP and refuses unless the instant CDP reports IS the
96
+ * reserved one. That is the assertion the whole construction rests on: if CDP ever normalises,
97
+ * truncates differently, or another actor's grant is what reads back, this is where it surfaces
98
+ * instead of silently binding a permission to a row it was never matched to.
99
+ *
100
+ * 🔴 A CONFIRM MISMATCH REVOKES THE FRESH GRANT, AND MARKS THE USER FAIL-CLOSED ON THIS SERVER. That is a
101
+ * heavy consequence and it is the intended one: the live permission is not the one this cap was chosen
102
+ * for, and the alternative — leaving it standing — is a permission whose bound nobody can name. If CDP's
103
+ * echo behaviour ever changed globally, every mint would land here and every delegated send would stop,
104
+ * which is the correct direction for a control that bounds a person's money.
105
+ *
106
+ * 🔴 NEITHER PHASE TAKES A USER ID OR AN EXPIRY FROM THE CALLER. The identity comes from
107
+ * `validateAccessToken`; the reserved instant comes from the store; the live instant comes from CDP.
108
+ */
109
+ export declare function reserveDelegationCap(runtime: ExecutionToolRuntime, rawInput: unknown, caller: DelegationCaller): Promise<DelegationCapResult>;
64
110
  /** What the support path writes to the structured log: the user id and the REDACTED reason — never a token. */
65
111
  export interface SupportActionSink {
66
112
  write(entry: {
@@ -1,9 +1,11 @@
1
1
  /**
2
- * execution-delegation-admin.ts — the three delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
2
+ * execution-delegation-admin.ts — the four delegation ADMIN tools on the hosted MCP (PR-1b, seat ruling D10 = (a)).
3
3
  *
4
4
  * otto_delegation_fence — the ONLY writer of the Model-B project policy: ensure-by-digest + read-back.
5
5
  * otto_delegation_status — one user's grant/fence read-back, bound to the user a CDP access token resolves to.
6
6
  * otto_delegation_revoke — developer-side revoke with read-back, fail-closed under the rig lock.
7
+ * otto_delegation_cap — reserves the expiry a delegation is minted with, records the chosen per-swap
8
+ * cap against it, and confirms after the mint that CDP kept that exact instant.
7
9
  *
8
10
  * Every tool: the request header FIRST (constant-time, before parsing or any CDP read — the same seam as
9
11
  * `otto_submit_under_delegation`), then the strict schema, then the reads. Nothing here logs a token, the
@@ -47,9 +49,10 @@
47
49
  import { getAddress } from 'viem';
48
50
  import { revocationRequested } from './adapter/cdp-signer.js';
49
51
  import { redactBootMessage } from './boot-redaction.js';
50
- import { DELEGATION_FENCE_INPUT_SCHEMA, DELEGATION_REVOKE_INPUT_SCHEMA, DELEGATION_STATUS_INPUT_SCHEMA, } from './execution-delegation-admin-definition.js';
52
+ import { DELEGATION_CAP_INPUT_SCHEMA, DELEGATION_FENCE_INPUT_SCHEMA, DELEGATION_REVOKE_INPUT_SCHEMA, DELEGATION_STATUS_INPUT_SCHEMA, } from './execution-delegation-admin-definition.js';
51
53
  import { assertDelegationCaller, } from './execution-delegation.js';
52
- import { MODEL_B_PER_SWAP_CAP_USD, MODEL_B_POLICY_LINEAGE_PREFIX, MODEL_B_POLICY_NAME_V1, MODEL_B_POLICY_VERSION, modelBLineageVersion, modelBProjectPolicyBody, policyRulesDigest, readBackModelBFence, } from './execution-delegation-policy.js';
54
+ import { boundedDelegationExpiry, MODEL_B_PER_SWAP_CAP_MIN_USD, MODEL_B_PER_SWAP_CAP_USD, MODEL_B_POLICY_LINEAGE_PREFIX, MODEL_B_POLICY_NAME, MODEL_B_POLICY_VERSION, fenceIsCurrent, modelBLineageVersion, modelBProjectPolicyBody, policyRulesDigest, readBackModelBFence, } from './execution-delegation-policy.js';
55
+ import { DelegationCapExpiryUnavailableError, DELEGATION_MINT_PAUSED_MESSAGE, MODEL_B_CAP_RULESET_VERSION, } from './execution-delegation-cap-store.js';
53
56
  import { refuse } from './execution-errors.js';
54
57
  const DEFAULT_FENCE_WAIT = { pollMs: 500, maxPolls: 20 };
55
58
  function servicesOf(runtime) {
@@ -110,7 +113,9 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
110
113
  if (project.length > 1)
111
114
  return refuseAmbiguous(project);
112
115
  const before = readBackModelBFence(snapshot);
113
- if (before.present)
116
+ // A v1 fence reads back as PRESENT (swaps and deposits keep working under it) but is not current: it
117
+ // is upgraded in place below — the one write the Founder's `fence v2` word triggers, by running this tool.
118
+ if (before.present && fenceIsCurrent(before))
114
119
  return fenceResult('unchanged', before.id, before.rulesDigest);
115
120
  let action;
116
121
  let targetId;
@@ -129,8 +134,12 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
129
134
  if (againProject.length > 1)
130
135
  return refuseAmbiguous(againProject);
131
136
  const read = readBackModelBFence(again);
132
- if (read.present)
137
+ // Only a CURRENT fence is "unchanged" (codex gate r1 MEDIUM): a v1 fence that appeared during the
138
+ // ensure is present but not current, and this path never updates — it says so and asks for a re-run.
139
+ if (read.present && fenceIsCurrent(read))
133
140
  return fenceResult('unchanged', read.id, read.rulesDigest);
141
+ if (read.present)
142
+ return refuse('DELEGATION_FENCE_ABSENT', `a v${read.version} fence of our lineage appeared during the ensure (id ${read.id}); this path never updates — run the fence tool again to upgrade it in place`);
134
143
  const appeared = againProject[0];
135
144
  if (!appeared)
136
145
  return refuse('DELEGATION_FENCE_ABSENT', 'the create was refused as a duplicate but no project policy reads back; run the fence tool again');
@@ -175,7 +184,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
175
184
  if (afterProject.length > 1)
176
185
  return refuseAmbiguous(afterProject);
177
186
  const after = readBackModelBFence(listed);
178
- if (after.present && after.id === targetId)
187
+ if (after.present && fenceIsCurrent(after) && after.id === targetId)
179
188
  return fenceResult(action, after.id, after.rulesDigest);
180
189
  last = after.present
181
190
  ? `a v-current policy reads back under id ${after.id}, not the written id ${targetId}`
@@ -183,7 +192,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
183
192
  if (poll + 1 < maxPolls)
184
193
  await new Promise((resolve) => setTimeout(resolve, pollMs));
185
194
  }
186
- return refuse('DELEGATION_FENCE_ABSENT', `the fence was ${action} (id ${targetId}) but does not read back as "${MODEL_B_POLICY_NAME_V1}" with the v1 rules digest under that id after ${maxPolls} polls (${last}); nothing is submittable until it does`);
195
+ return refuse('DELEGATION_FENCE_ABSENT', `the fence was ${action} (id ${targetId}) but does not read back as "${MODEL_B_POLICY_NAME}" with the v${MODEL_B_POLICY_VERSION} rules digest under that id after ${maxPolls} polls (${last}); nothing is submittable until it does`);
187
196
  });
188
197
  }
189
198
  function fenceResult(action, id, rulesDigest) {
@@ -191,7 +200,7 @@ function fenceResult(action, id, rulesDigest) {
191
200
  status: 'present',
192
201
  action,
193
202
  policy_id: id,
194
- policy_name: MODEL_B_POLICY_NAME_V1,
203
+ policy_name: MODEL_B_POLICY_NAME,
195
204
  rules_digest: rulesDigest,
196
205
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
197
206
  });
@@ -205,11 +214,20 @@ export async function readDelegationFenceStatus(runtime) {
205
214
  const fence = readBackModelBFence(await services.rig.listProjectPolicies());
206
215
  return Object.freeze({
207
216
  present: fence.present,
208
- policy_name: MODEL_B_POLICY_NAME_V1,
217
+ // The name that READS BACK (v1 until the fence tool upgrades it), so the page can tell which fence stands.
218
+ policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
209
219
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
220
+ per_swap_cap_min_usd: MODEL_B_PER_SWAP_CAP_MIN_USD,
210
221
  // The public authority binding: published present or absent, so the agent CLI mints to THIS project
211
222
  // (and can still bind status/revoke to it after the fence is removed). Registration requires it set.
212
223
  ...(services.publicProjectId ? { project_id: services.publicProjectId } : {}),
224
+ ruleset_version: MODEL_B_CAP_RULESET_VERSION,
225
+ /**
226
+ * 🔴 THE PAUSE IS PUBLISHED, so the page and the CLI can SAY it before a person signs in rather than
227
+ * discovering it at the reserve. False means no new permission can be created on this worker; it says
228
+ * nothing about permissions that already stand — those keep working, and revoke always does.
229
+ */
230
+ mint_enabled: services.mintEnabled,
213
231
  ...(fence.present ? { rules_digest: fence.rulesDigest } : { reason: fence.reason }),
214
232
  });
215
233
  }
@@ -232,24 +250,261 @@ export async function readDelegationStatus(runtime, rawInput, caller) {
232
250
  const input = parseWith(DELEGATION_STATUS_INPUT_SCHEMA, rawInput);
233
251
  const user = await userFromAccessToken(services, input.accessToken);
234
252
  const fence = readBackModelBFence(await services.rig.listProjectPolicies());
235
- const requestedHere = revocationRequested(services.rig.rig, user.userId);
236
253
  const grant = await services.rig.rig.readDelegation(user.userId);
237
254
  const expiresAtMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
255
+ // Per GRANT, not per user: a revoke aimed at a permission the person no longer holds says nothing
256
+ // about the one they hold now.
257
+ const requestedHere = grant !== undefined && revocationRequested(services.rig.rig, user.userId, grant.expiresAt);
238
258
  const active = grant !== undefined &&
239
259
  !requestedHere &&
240
260
  Number.isFinite(expiresAtMs) &&
241
261
  expiresAtMs > runtime.nowMs();
262
+ /**
263
+ * 🔴 THE CAP COMES FROM THE CONFIRMED-ACTIVE RECORD, AND ONLY WHEN IT NAMES THE GRANT THAT READS BACK
264
+ * — exactly the check the delegated send makes, so this surface and the send can never disagree about
265
+ * what is in force. A reservation that was never confirmed shows NO cap, because it authorises
266
+ * nothing. It is OMITTED, never defaulted: absence here means the send will refuse, and a page that
267
+ * showed the ceiling instead would tell the reader their permission allows $5,000 when it allows
268
+ * nothing at all.
269
+ */
270
+ let recordedCapUsd;
271
+ if (grant && services.capStore.available) {
272
+ try {
273
+ const confirmed = await services.capStore.readActive(user.userId, runtime.nowMs());
274
+ if (confirmed &&
275
+ Date.parse(confirmed.expiresAt) === expiresAtMs &&
276
+ confirmed.requiredVersion <= MODEL_B_CAP_RULESET_VERSION) {
277
+ recordedCapUsd = confirmed.perSwapCapUsd;
278
+ }
279
+ }
280
+ catch {
281
+ // A read that FAILED is not "nothing is confirmed" — the send will refuse either way, and this
282
+ // surface says so by omitting the field rather than inventing a number in either direction.
283
+ recordedCapUsd = undefined;
284
+ }
285
+ }
242
286
  return Object.freeze({
243
287
  user_id: user.userId,
244
288
  addresses: [...user.addresses],
245
289
  fence: Object.freeze({
246
290
  present: fence.present,
247
- policy_name: MODEL_B_POLICY_NAME_V1,
291
+ policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
248
292
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
249
293
  ...(fence.present ? { rules_digest: fence.rulesDigest } : {}),
250
294
  }),
251
- delegation: Object.freeze({ active, ...(grant ? { expires_at: grant.expiresAt } : {}) }),
295
+ delegation: Object.freeze({
296
+ active,
297
+ ...(grant ? { expires_at: grant.expiresAt } : {}),
298
+ ...(recordedCapUsd !== undefined ? { per_swap_cap_usd: recordedCapUsd } : {}),
299
+ }),
252
300
  revocation_requested_here: requestedHere,
301
+ // What this worker implements — the page and the CLI refuse to reserve or mint against an older one.
302
+ ruleset_version: MODEL_B_CAP_RULESET_VERSION,
303
+ // Whether a NEW permission can be created here at all (the staged-deploy gate). Never gates this read,
304
+ // and never gates revoke.
305
+ mint_enabled: services.mintEnabled,
306
+ });
307
+ }
308
+ /**
309
+ * `otto_delegation_cap` — RESERVE the expiry a delegation will be minted with, record the chosen per-swap
310
+ * cap against it, and CONFIRM the mint (Founder word `delegate cap choice`, 2026-09-02; round 3, after the
311
+ * codex gate NO-GO'd rounds 1 and 2 and the seat ruled the collision out rather than documenting it).
312
+ *
313
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
314
+ * 🔴 WHY THE ORDER IS RESERVE → MINT → CONFIRM, AND NOT MINT → RECORD
315
+ * ══════════════════════════════════════════════════════════════════════════════════════════════════
316
+ * The gate's second finding is the whole reason: an expiry is the ONLY thing a send can learn about a live
317
+ * grant, so keying the RECORD by anything else — r1's expiry-as-key, r2's mint key — fixes the record and
318
+ * not the send. Its probe minted a $10 replacement carrying the SAME expiry as a standing $5,000 row and
319
+ * watched a plan run at $5,000. Live confirmation of exactly that, on the dev project (2026-09-02T22:17Z):
320
+ * CDP refuses a second mint over a live grant (409 `already_exists`), but after a user-side revoke a
321
+ * re-mint at the SAME instant succeeds and reads back at that identical instant. Two grants for one user
322
+ * CAN share an expiry.
323
+ *
324
+ * So round 3 stops trying to tell them apart and makes them impossible:
325
+ *
326
+ * RESERVE — the SERVER picks the instant. It walks back from the second the person's chosen duration
327
+ * lands on and takes the first whole second no durable row for this user occupies, then writes the cap
328
+ * against it BEFORE any grant exists. The caller cannot choose the enforcement key, which is the
329
+ * property that makes it unique. The ladder only ever steps EARLIER (seat ruling): a permission must
330
+ * never outlive the duration the person chose, and the page shows the instant it actually got.
331
+ *
332
+ * MINT — the caller passes `mint_expires_at` to `createDelegation` verbatim. It is the reserved second
333
+ * plus one millisecond, because CDP refuses a bare `.000Z` (401 `unauthorized` "Wallet authentication
334
+ * error" — isolated on one session: `.000Z` refused, `.137Z` accepted, `.000Z` refused again) and
335
+ * truncates the filler away, storing exactly the reserved second.
336
+ *
337
+ * CONFIRM — the server re-reads the grant from CDP and refuses unless the instant CDP reports IS the
338
+ * reserved one. That is the assertion the whole construction rests on: if CDP ever normalises,
339
+ * truncates differently, or another actor's grant is what reads back, this is where it surfaces
340
+ * instead of silently binding a permission to a row it was never matched to.
341
+ *
342
+ * 🔴 A CONFIRM MISMATCH REVOKES THE FRESH GRANT, AND MARKS THE USER FAIL-CLOSED ON THIS SERVER. That is a
343
+ * heavy consequence and it is the intended one: the live permission is not the one this cap was chosen
344
+ * for, and the alternative — leaving it standing — is a permission whose bound nobody can name. If CDP's
345
+ * echo behaviour ever changed globally, every mint would land here and every delegated send would stop,
346
+ * which is the correct direction for a control that bounds a person's money.
347
+ *
348
+ * 🔴 NEITHER PHASE TAKES A USER ID OR AN EXPIRY FROM THE CALLER. The identity comes from
349
+ * `validateAccessToken`; the reserved instant comes from the store; the live instant comes from CDP.
350
+ */
351
+ export async function reserveDelegationCap(runtime, rawInput, caller) {
352
+ const services = servicesOf(runtime);
353
+ assertDelegationCaller(services, caller);
354
+ const input = parseWith(DELEGATION_CAP_INPUT_SCHEMA, rawInput);
355
+ if (!services.capStore.available) {
356
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', `the per-swap cap cannot be recorded on this server, so nothing would enforce it (${services.capStore.unavailableReason ?? 'store unavailable'})`);
357
+ }
358
+ /**
359
+ * 🔴 THE STAGED-DEPLOY GATE, ON RESERVE ONLY (gate finding, CRITICAL 2 — the first rolling deployment).
360
+ *
361
+ * With `DELEGATION_MINT_ENABLED` false — the default in code — no NEW permission can be created on
362
+ * this worker, so there is no cap-bearing grant a still-serving cap-unaware binary could execute at
363
+ * the shared ceiling. That is the fence the ruleset handshake cannot be for the FIRST rollout, because
364
+ * the binary that would have to honour it predates it.
365
+ *
366
+ * 🔴 AND CONFIRM IS DELIBERATELY NOT GATED. Confirm binds a permission that ALREADY EXISTS at Coinbase
367
+ * to the reservation it was minted for. Refusing it would leave a live grant with no record — which is
368
+ * fail-closed for sending, but strictly worse for the person: an unbindable permission they have to
369
+ * find and revoke. The flag is flipped false→true by the runbook, so the only mid-flow flip is
370
+ * on→off, and letting an in-flight mint finish binding is the safe half of that.
371
+ *
372
+ * Sends under permissions that already stand are untouched, and REVOKE is never gated: pausing the
373
+ * creation of authority must never pause its withdrawal.
374
+ */
375
+ if (input.phase === 'reserve' && !services.mintEnabled) {
376
+ return refuse('DELEGATION_MINT_PAUSED', DELEGATION_MINT_PAUSED_MESSAGE);
377
+ }
378
+ const user = await userFromAccessToken(services, input.accessToken);
379
+ return input.phase === 'reserve'
380
+ ? reservePhase(services, runtime, user.userId, input)
381
+ : confirmPhase(services, runtime, user.userId, input);
382
+ }
383
+ /** The RESERVE phase: claim a free instant, record the cap against it, hand back what to mint with. */
384
+ async function reservePhase(services, runtime, userId, input) {
385
+ const nowMs = runtime.nowMs();
386
+ /**
387
+ * The LATEST instant this permission may carry — the person's own duration, on a whole second. The
388
+ * ladder never returns anything after it, so an offset can only ever shorten a grant.
389
+ */
390
+ let latest;
391
+ try {
392
+ latest = boundedDelegationExpiry(input.requestedDays, nowMs);
393
+ }
394
+ catch (error) {
395
+ return refuse('DELEGATION_INPUT_INVALID', error instanceof Error ? error.message : 'the requested expiry is not usable');
396
+ }
397
+ let reservation;
398
+ try {
399
+ reservation = await services.capStore.reserve(userId, input.mintKey, latest.expiresAt, input.perSwapCapUsd, nowMs);
400
+ }
401
+ catch (error) {
402
+ if (error instanceof DelegationCapExpiryUnavailableError) {
403
+ return refuse('DELEGATION_CAP_EXPIRY_UNAVAILABLE', 'no free expiry instant is available for this delegation right now; nothing is reserved — try again in a moment');
404
+ }
405
+ // The claim (or the record beside it) failed. Nothing is cached either — the store writes disk
406
+ // first — so the honest answer is that nothing was reserved, never a success the caller mints against.
407
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the per-swap cap could not be written durably; nothing is reserved and no permission should be minted');
408
+ }
409
+ return capResult('reserve', reservation.record, reservation.outcome === 'reserved');
410
+ }
411
+ /**
412
+ * The CONFIRM phase: the instant CDP holds must BE the instant that was reserved, and the reservation
413
+ * must still be confirmable. Promotes it to confirmed-active in one durable write; on a mismatch it
414
+ * revokes the fresh grant.
415
+ */
416
+ async function confirmPhase(services, runtime, userId, input) {
417
+ const nowMs = runtime.nowMs();
418
+ const grant = await services.rig.rig.readDelegation(userId);
419
+ const grantMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
420
+ if (!grant ||
421
+ !Number.isFinite(grantMs) ||
422
+ grantMs <= nowMs ||
423
+ revocationRequested(services.rig.rig, userId, grant.expiresAt)) {
424
+ return refuse('DELEGATION_NOT_ACTIVE', 'no active delegation reads back for this end user, so there is nothing to confirm');
425
+ }
426
+ let outcome;
427
+ try {
428
+ outcome = await services.capStore.confirm(userId, input.mintKey, grant.expiresAt, nowMs);
429
+ }
430
+ catch {
431
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the reservation for this mint could not be read or promoted, so nothing is confirmed');
432
+ }
433
+ switch (outcome.outcome) {
434
+ case 'confirmed':
435
+ case 'already_confirmed':
436
+ return capResult('confirm', outcome.record, outcome.outcome === 'confirmed');
437
+ case 'unreserved':
438
+ return refuse('DELEGATION_CAP_UNRECORDED', 'no per-swap cap was reserved for this mint on this server, so what the person agreed to is unknown; mint the permission again');
439
+ case 'not_reservable':
440
+ return refuse('DELEGATION_CAP_UNRECORDED', `the reservation for this mint is ${outcome.state} and can no longer be confirmed; mint the permission again`);
441
+ case 'superseded':
442
+ /**
443
+ * 🔴 NEVER REVIVED. This reservation WAS confirmed and a later one replaced it; promoting it again
444
+ * would bring a replaced permission back, and on a wider cap that is the widening this whole unit
445
+ * exists to prevent.
446
+ */
447
+ return refuse('DELEGATION_CAP_SUPERSEDED', 'this permission was replaced by a later one and is not brought back; mint the permission again');
448
+ case 'cancelled':
449
+ /**
450
+ * 🔴 THE PERSON PRESSED STOP WHILE THIS PERMISSION WAS BEING CREATED (gate finding, r4: "Add a
451
+ * durable revoke generation so Stop cancels already-reserved/in-flight mints"). The fresh grant
452
+ * is taken away rather than left standing — a Stop that leaves a permission behind is not a Stop.
453
+ */
454
+ return revokeOnCancel(services, userId, nowMs);
455
+ case 'expiry_mismatch':
456
+ return revokeOnMismatch(services, userId, nowMs);
457
+ }
458
+ }
459
+ /**
460
+ * A Stop landed between this mint's reservation and its confirmation. The grant exists at Coinbase and
461
+ * the person has said they do not want it, so it is revoked here and nothing is ever confirmed for it.
462
+ */
463
+ async function revokeOnCancel(services, userId, nowMs) {
464
+ let revoked = false;
465
+ try {
466
+ await services.rig.rig.revoke(userId);
467
+ revoked = true;
468
+ }
469
+ catch {
470
+ // Issued and not confirmed. The refusal is the same either way — nothing is confirmed here.
471
+ }
472
+ void nowMs;
473
+ return refuse('DELEGATION_CAP_CANCELLED', `this permission was stopped while it was being created; ${revoked ? 'it has been revoked' : 'a revoke was issued and could not be confirmed'} and nothing is confirmed for it — allow a new one if you still want it`);
474
+ }
475
+ /**
476
+ * 🔴 A MISMATCH TAKES THE FRESH GRANT AWAY. The live permission is not the one this cap was reserved
477
+ * for — CDP stored something else, or another actor's grant is what reads back — so it is revoked rather
478
+ * than left standing with a bound nobody can name. The revoke is per grant: it marks only the permission
479
+ * that actually read back, never the person.
480
+ */
481
+ async function revokeOnMismatch(services, userId, nowMs) {
482
+ let revoked = false;
483
+ try {
484
+ await services.rig.rig.revoke(userId);
485
+ revoked = true;
486
+ }
487
+ catch {
488
+ // Issued and not confirmed. The refusal is the same either way — this server acts under nothing.
489
+ }
490
+ await services.capStore
491
+ .readActive(userId, nowMs)
492
+ .then((active) => (active ? services.capStore.revoke(userId, active.expiresAt, nowMs) : false))
493
+ .catch(() => false);
494
+ return refuse('DELEGATION_CAP_GRANT_CHANGED', `the delegation that reads back is not the one this per-swap cap was reserved for; ${revoked ? 'it has been revoked' : 'a revoke was issued and could not be confirmed'} and nothing is submitted under it — mint the permission again`);
495
+ }
496
+ /** One shape for both phases, so the two answers cannot drift apart. */
497
+ function capResult(phase, record, wroteIt) {
498
+ return Object.freeze({
499
+ phase,
500
+ user_id: record.userId,
501
+ expires_at: record.expiresAt,
502
+ // The value to mint with exists only on the reserve — by confirm the mint has already happened.
503
+ ...(phase === 'reserve' ? { mint_expires_at: record.requestedExpiresAt } : {}),
504
+ per_swap_cap_usd: record.perSwapCapUsd,
505
+ state: record.state,
506
+ ruleset_version: record.requiredVersion,
507
+ recorded: wroteIt,
253
508
  });
254
509
  }
255
510
  const STDOUT_SUPPORT_SINK = {
@@ -277,6 +532,53 @@ export async function revokeDelegation(runtime, rawInput, caller, supportSink =
277
532
  ts: new Date(runtime.nowMs()).toISOString(),
278
533
  });
279
534
  }
535
+ /**
536
+ * ══════════════════════════════════════════════════════════════════════════════════════════════
537
+ * 🔴 THE DURABLE RECORD IS REVOKED IN THE SAME OPERATION, AND FIRST (gate finding, round 4 CRITICAL 1)
538
+ * ══════════════════════════════════════════════════════════════════════════════════════════════
539
+ * "Revoke does not transition the durable record. The normal revoke calls only `rig.revoke()` and never
540
+ * `capStore.revoke()` … a new grant can inherit the revoked grant's old $5,000 cap without
541
+ * confirmation. This is exactly the prohibited widening direction." Its probe on the shipped fixtures:
542
+ * revoke → fresh process → same-expiry re-mint → `afterReportedRevoke=confirmed_active, sendStatus=sent,
543
+ * enforcedOldCap=5000`.
544
+ *
545
+ * The in-process revocation marker dies with the process; the RECORD does not. So the record moves
546
+ * first, and the CDP call happens only after it has:
547
+ *
548
+ * · the grant is read FIRST, so the record and the marker name the SAME permission;
549
+ * · a durable transition that THROWS means nothing is revoked at Coinbase either — the caller is told
550
+ * to retry, rather than being left with a removed grant and a record that still says it stands;
551
+ * · a transition that finds nothing to change (`false`) is NOT a failure: the record was already
552
+ * revoked, or this person's live grant was minted out-of-model and was never recorded here. The
553
+ * CDP revoke still goes out — a person must always be able to take a permission away;
554
+ * · if the CDP revoke then fails, the record is ALREADY revoked. That is the safe direction (this
555
+ * server will submit nothing under it) and a retry re-issues the CDP call.
556
+ *
557
+ * 🔴 THE EXPIRY IT CONSUMED STAYS CONSUMED. The reservation row survives as `revoked`, so no later
558
+ * permission minted through Otto's page or CLI can land on that instant — which is what closes the
559
+ * probe above: the re-minted grant gets a DIFFERENT second, matches no confirmed record, and is
560
+ * refused rather than inheriting anything.
561
+ */
562
+ if (services.capStore.available) {
563
+ try {
564
+ /**
565
+ * 🔴 ONE TRANSACTION, BOTH WRITES (gate r5 CRITICAL: "commits the generation bump and then performs
566
+ * the active-record revoke in a separate transaction … the process dies or stalls before
567
+ * `capStore.revoke()` … a fresh worker still reads the old record as `confirmed_active`").
568
+ *
569
+ * `stop` bumps the revoke generation — EVERY Stop, including one that finds no grant, the case the
570
+ * round-4 gate named, so a mint already in flight is cancelled at its confirm — AND moves whatever
571
+ * record is confirmed-active to `revoked`, inside one `BEGIN IMMEDIATE`. A crash between the two
572
+ * commits neither. It takes no expiry: a Stop means Otto acts under nothing for this person, which
573
+ * is also what the CDP-side revoke below does to whatever grant is live, so the two halves agree
574
+ * without a read in between that could see a different grant than either of them acts on.
575
+ */
576
+ await services.capStore.stop(userId, runtime.nowMs());
577
+ }
578
+ catch {
579
+ return refuse('DELEGATION_REVOKE_NOT_CONFIRMED', 'the permission could not be marked revoked durably, so nothing was revoked at Coinbase either — retry');
580
+ }
581
+ }
280
582
  try {
281
583
  // Serialized on the rig, the lock held through the read-back; the user is marked revocation-requested BEFORE
282
584
  // the raw revoke, so a plan queued behind this call is refused whatever the read-back says.