otto-intel-mcp 0.1.2 โ†’ 0.1.4

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 (239) hide show
  1. package/README.md +76 -18
  2. package/dist/account-userop-policy-definition.d.ts +14 -0
  3. package/dist/account-userop-policy-definition.js +12 -0
  4. package/dist/account-userop-policy-relation.d.ts +371 -0
  5. package/dist/account-userop-policy-relation.js +87 -0
  6. package/dist/adapter/cdp-signer.d.ts +76 -9
  7. package/dist/adapter/cdp-signer.js +274 -33
  8. package/dist/adapter/evm-call-failure.d.ts +29 -0
  9. package/dist/adapter/evm-call-failure.js +109 -0
  10. package/dist/adapter/refusal.d.ts +1 -1
  11. package/dist/adapter/refusal.js +13 -0
  12. package/dist/adapter/route-simulator.d.ts +91 -0
  13. package/dist/adapter/route-simulator.js +143 -0
  14. package/dist/adapter-cdp-index.d.ts +1 -1
  15. package/dist/executable-equities.d.ts +55 -0
  16. package/dist/executable-equities.js +116 -0
  17. package/dist/executable-yield-markets.d.ts +261 -0
  18. package/dist/executable-yield-markets.js +154 -0
  19. package/dist/execution-account-gas-quote.d.ts +141 -0
  20. package/dist/execution-account-gas-quote.js +443 -0
  21. package/dist/execution-account-gas-review.d.ts +123 -0
  22. package/dist/execution-account-gas-review.js +104 -0
  23. package/dist/execution-account-gas-runtime.d.ts +33 -0
  24. package/dist/execution-account-gas-runtime.js +131 -0
  25. package/dist/execution-account-gas-view.d.ts +936 -0
  26. package/dist/execution-account-gas-view.js +56 -0
  27. package/dist/execution-config.d.ts +3 -0
  28. package/dist/execution-config.js +4 -0
  29. package/dist/execution-delegated-definition.d.ts +7 -6
  30. package/dist/execution-delegated-definition.js +13 -2
  31. package/dist/execution-delegation-admin-definition.d.ts +127 -5
  32. package/dist/execution-delegation-admin-definition.js +155 -5
  33. package/dist/execution-delegation-admin.d.ts +48 -2
  34. package/dist/execution-delegation-admin.js +422 -12
  35. package/dist/execution-delegation-cap-store.d.ts +386 -0
  36. package/dist/execution-delegation-cap-store.js +1150 -0
  37. package/dist/execution-delegation-exit-definition.d.ts +309 -0
  38. package/dist/execution-delegation-exit-definition.js +204 -0
  39. package/dist/execution-delegation-exit-store.d.ts +114 -0
  40. package/dist/execution-delegation-exit-store.js +671 -0
  41. package/dist/execution-delegation-exit.d.ts +4 -0
  42. package/dist/execution-delegation-exit.js +614 -0
  43. package/dist/execution-delegation-finality.d.ts +22 -0
  44. package/dist/execution-delegation-finality.js +90 -0
  45. package/dist/execution-delegation-operation-store.d.ts +129 -0
  46. package/dist/execution-delegation-operation-store.js +1107 -0
  47. package/dist/execution-delegation-operations-definition.d.ts +282 -0
  48. package/dist/execution-delegation-operations-definition.js +117 -0
  49. package/dist/execution-delegation-operations.d.ts +6 -0
  50. package/dist/execution-delegation-operations.js +282 -0
  51. package/dist/execution-delegation-policy.d.ts +154 -8
  52. package/dist/execution-delegation-policy.js +273 -40
  53. package/dist/execution-delegation.d.ts +51 -0
  54. package/dist/execution-delegation.js +441 -31
  55. package/dist/execution-errors.d.ts +1 -1
  56. package/dist/execution-errors.js +122 -0
  57. package/dist/execution-index.d.ts +7 -2
  58. package/dist/execution-index.js +7 -2
  59. package/dist/execution-journal-schema.d.ts +96 -0
  60. package/dist/execution-journal-schema.js +368 -0
  61. package/dist/execution-journal-tables.d.ts +14 -0
  62. package/dist/execution-journal-tables.js +227 -0
  63. package/dist/execution-policy-compatibility.d.ts +34 -0
  64. package/dist/execution-policy-compatibility.js +76 -0
  65. package/dist/execution-registration.d.ts +54 -2
  66. package/dist/execution-registration.js +151 -5
  67. package/dist/execution-sponsored-account-profile.d.ts +32 -0
  68. package/dist/execution-sponsored-account-profile.js +59 -0
  69. package/dist/execution-sponsored-account.d.ts +1519 -0
  70. package/dist/execution-sponsored-account.js +222 -0
  71. package/dist/execution-sponsored-definition.d.ts +1713 -0
  72. package/dist/execution-sponsored-definition.js +185 -0
  73. package/dist/execution-sponsored-gas-policy.d.ts +27 -0
  74. package/dist/execution-sponsored-gas-policy.js +110 -0
  75. package/dist/execution-sponsored-hosted.d.ts +3 -0
  76. package/dist/execution-sponsored-hosted.js +71 -0
  77. package/dist/execution-sponsored-index.d.ts +1322 -0
  78. package/dist/execution-sponsored-index.js +102 -0
  79. package/dist/execution-sponsored-mcp-definition.d.ts +1662 -0
  80. package/dist/execution-sponsored-mcp-definition.js +94 -0
  81. package/dist/execution-sponsored-receipt.d.ts +47 -0
  82. package/dist/execution-sponsored-receipt.js +74 -0
  83. package/dist/execution-sponsored-registration.d.ts +14 -0
  84. package/dist/execution-sponsored-registration.js +77 -0
  85. package/dist/execution-sponsored-runtime.d.ts +84 -0
  86. package/dist/execution-sponsored-runtime.js +149 -0
  87. package/dist/execution-sponsored-settlement.d.ts +15 -0
  88. package/dist/execution-sponsored-settlement.js +88 -0
  89. package/dist/execution-sponsored-store.d.ts +67 -0
  90. package/dist/execution-sponsored-store.js +517 -0
  91. package/dist/execution-sponsored-transport.d.ts +90 -0
  92. package/dist/execution-sponsored-transport.js +180 -0
  93. package/dist/execution-tool-definitions.d.ts +21 -21
  94. package/dist/execution-tool-definitions.js +0 -7
  95. package/dist/execution-tools.d.ts +53 -1
  96. package/dist/execution-tools.js +171 -13
  97. package/dist/execution-worker-guard.d.ts +28 -0
  98. package/dist/execution-worker-guard.js +124 -0
  99. package/dist/hybrid-yield-index.d.ts +10 -0
  100. package/dist/hybrid-yield-index.js +11 -0
  101. package/dist/hybrid-yield-markets.d.ts +96 -0
  102. package/dist/hybrid-yield-markets.js +124 -0
  103. package/dist/hybrid-yield.d.ts +411 -0
  104. package/dist/hybrid-yield.js +155 -0
  105. package/dist/hyperliquid-order-builder.js +4 -0
  106. package/dist/lifi-execution-client.d.ts +6 -0
  107. package/dist/lifi-execution-client.js +3 -0
  108. package/dist/lp-userop-account.d.ts +461 -0
  109. package/dist/lp-userop-account.js +417 -0
  110. package/dist/lp-userop-authority.d.ts +49 -0
  111. package/dist/lp-userop-authority.js +211 -0
  112. package/dist/lp-userop-definition.d.ts +233 -0
  113. package/dist/lp-userop-definition.js +85 -0
  114. package/dist/lp-userop-hosted.d.ts +3 -0
  115. package/dist/lp-userop-hosted.js +73 -0
  116. package/dist/lp-userop-index.d.ts +1569 -0
  117. package/dist/lp-userop-index.js +112 -0
  118. package/dist/lp-userop-mcp-definition.d.ts +1220 -0
  119. package/dist/lp-userop-mcp-definition.js +46 -0
  120. package/dist/lp-userop-operation.d.ts +1230 -0
  121. package/dist/lp-userop-operation.js +140 -0
  122. package/dist/lp-userop-registration.d.ts +14 -0
  123. package/dist/lp-userop-registration.js +70 -0
  124. package/dist/lp-userop-settlement.d.ts +89 -0
  125. package/dist/lp-userop-settlement.js +184 -0
  126. package/dist/lp-userop.d.ts +643 -0
  127. package/dist/lp-userop.js +343 -0
  128. package/dist/native-yield-account-definition.d.ts +1192 -0
  129. package/dist/native-yield-account-definition.js +232 -0
  130. package/dist/native-yield-account-gas.d.ts +58 -0
  131. package/dist/native-yield-account-gas.js +228 -0
  132. package/dist/native-yield-account-index.d.ts +23 -0
  133. package/dist/native-yield-account-index.js +28 -0
  134. package/dist/native-yield-account-policy.d.ts +168 -0
  135. package/dist/native-yield-account-policy.js +525 -0
  136. package/dist/native-yield-account-rpc.d.ts +8 -0
  137. package/dist/native-yield-account-rpc.js +128 -0
  138. package/dist/native-yield-account-runtime.d.ts +71 -0
  139. package/dist/native-yield-account-runtime.js +125 -0
  140. package/dist/native-yield-account-store.d.ts +40 -0
  141. package/dist/native-yield-account-store.js +307 -0
  142. package/dist/native-yield-account.d.ts +662 -0
  143. package/dist/native-yield-account.js +371 -0
  144. package/dist/native-yield-consent-store.d.ts +20 -0
  145. package/dist/native-yield-consent-store.js +317 -0
  146. package/dist/native-yield-definition.d.ts +288 -0
  147. package/dist/native-yield-definition.js +105 -0
  148. package/dist/native-yield-reader.d.ts +4 -0
  149. package/dist/native-yield-reader.js +137 -0
  150. package/dist/native-yield-settlement.d.ts +48 -0
  151. package/dist/native-yield-settlement.js +167 -0
  152. package/dist/native-yield-simulation.d.ts +18 -0
  153. package/dist/native-yield-simulation.js +45 -0
  154. package/dist/native-yield-userop-account.d.ts +675 -0
  155. package/dist/native-yield-userop-account.js +357 -0
  156. package/dist/native-yield-userop-authority.d.ts +214 -0
  157. package/dist/native-yield-userop-authority.js +296 -0
  158. package/dist/native-yield-userop-definition.d.ts +331 -0
  159. package/dist/native-yield-userop-definition.js +105 -0
  160. package/dist/native-yield-userop-hosted.d.ts +3 -0
  161. package/dist/native-yield-userop-hosted.js +71 -0
  162. package/dist/native-yield-userop-index.d.ts +1522 -0
  163. package/dist/native-yield-userop-index.js +142 -0
  164. package/dist/native-yield-userop-mcp-definition.d.ts +1356 -0
  165. package/dist/native-yield-userop-mcp-definition.js +50 -0
  166. package/dist/native-yield-userop-operation.d.ts +845 -0
  167. package/dist/native-yield-userop-operation.js +127 -0
  168. package/dist/native-yield-userop-policy.d.ts +23 -0
  169. package/dist/native-yield-userop-policy.js +25 -0
  170. package/dist/native-yield-userop-registration.d.ts +17 -0
  171. package/dist/native-yield-userop-registration.js +76 -0
  172. package/dist/native-yield-userop-settlement.d.ts +8 -0
  173. package/dist/native-yield-userop-settlement.js +79 -0
  174. package/dist/native-yield-userop-simulation.d.ts +18 -0
  175. package/dist/native-yield-userop-simulation.js +34 -0
  176. package/dist/native-yield-userop.d.ts +35 -0
  177. package/dist/native-yield-userop.js +156 -0
  178. package/dist/native-yield-verify.d.ts +30 -0
  179. package/dist/native-yield-verify.js +141 -0
  180. package/dist/native-yield.d.ts +17 -0
  181. package/dist/native-yield.js +119 -0
  182. package/dist/stdio-main.js +0 -0
  183. package/dist/stock-buy-definition.d.ts +338 -0
  184. package/dist/stock-buy-definition.js +127 -0
  185. package/dist/stock-buy-oracle.d.ts +8 -0
  186. package/dist/stock-buy-oracle.js +22 -0
  187. package/dist/stock-buy.d.ts +17 -0
  188. package/dist/stock-buy.js +164 -0
  189. package/dist/tool-definitions.d.ts +3 -1
  190. package/dist/tool-definitions.js +55 -2
  191. package/dist/vendor/lp-v3/lp-v3-atomic-contract.d.ts +445 -0
  192. package/dist/vendor/lp-v3/lp-v3-atomic-contract.js +90 -0
  193. package/dist/vendor/lp-v3/lp-v3-atomic-evidence.d.ts +14 -0
  194. package/dist/vendor/lp-v3/lp-v3-atomic-evidence.js +82 -0
  195. package/dist/vendor/lp-v3/lp-v3-atomic-index.d.ts +80 -0
  196. package/dist/vendor/lp-v3/lp-v3-atomic-index.js +19 -0
  197. package/dist/vendor/lp-v3/lp-v3-atomic-plan.d.ts +43 -0
  198. package/dist/vendor/lp-v3/lp-v3-atomic-plan.js +112 -0
  199. package/dist/vendor/lp-v3/lp-v3-atomic-read-contract.d.ts +312 -0
  200. package/dist/vendor/lp-v3/lp-v3-atomic-read-contract.js +41 -0
  201. package/dist/vendor/lp-v3/lp-v3-atomic-read-index.d.ts +3 -0
  202. package/dist/vendor/lp-v3/lp-v3-atomic-read-index.js +3 -0
  203. package/dist/vendor/lp-v3/lp-v3-atomic-read-rpc.d.ts +49 -0
  204. package/dist/vendor/lp-v3/lp-v3-atomic-read-rpc.js +88 -0
  205. package/dist/vendor/lp-v3/lp-v3-atomic-read.d.ts +17 -0
  206. package/dist/vendor/lp-v3/lp-v3-atomic-read.js +232 -0
  207. package/dist/vendor/lp-v3/lp-v3-atomic-settlement.d.ts +105 -0
  208. package/dist/vendor/lp-v3/lp-v3-atomic-settlement.js +162 -0
  209. package/dist/vendor/lp-v3/lp-v3-collect-protocol-contract.d.ts +606 -0
  210. package/dist/vendor/lp-v3/lp-v3-collect-protocol-contract.js +59 -0
  211. package/dist/vendor/lp-v3/lp-v3-collect-protocol-index.d.ts +168 -0
  212. package/dist/vendor/lp-v3/lp-v3-collect-protocol-index.js +23 -0
  213. package/dist/vendor/lp-v3/lp-v3-collect-protocol-observation.d.ts +32 -0
  214. package/dist/vendor/lp-v3/lp-v3-collect-protocol-observation.js +228 -0
  215. package/dist/vendor/lp-v3/lp-v3-collect-protocol-plan.d.ts +11 -0
  216. package/dist/vendor/lp-v3/lp-v3-collect-protocol-plan.js +91 -0
  217. package/dist/vendor/lp-v3/lp-v3-collect-protocol-reconcile.d.ts +9 -0
  218. package/dist/vendor/lp-v3/lp-v3-collect-protocol-reconcile.js +124 -0
  219. package/dist/vendor/lp-v3/lp-v3-contract.d.ts +538 -0
  220. package/dist/vendor/lp-v3/lp-v3-contract.js +137 -0
  221. package/dist/vendor/lp-v3/lp-v3-evidence.d.ts +25 -0
  222. package/dist/vendor/lp-v3/lp-v3-evidence.js +168 -0
  223. package/dist/vendor/lp-v3/lp-v3-plan.d.ts +22 -0
  224. package/dist/vendor/lp-v3/lp-v3-plan.js +135 -0
  225. package/dist/vendor/lp-v3/types.d.ts +131 -0
  226. package/dist/vendor/lp-v3/types.js +6 -0
  227. package/dist/vendor/lp-v3/v4-math.d.ts +18 -0
  228. package/dist/vendor/lp-v3/v4-math.js +90 -0
  229. package/dist/yield-deposit-definition.d.ts +374 -0
  230. package/dist/yield-deposit-definition.js +123 -0
  231. package/dist/yield-deposit.d.ts +16 -0
  232. package/dist/yield-deposit.js +149 -0
  233. package/dist/yield-route-semantics.d.ts +27 -0
  234. package/dist/yield-route-semantics.js +71 -0
  235. package/dist/yield-withdraw-definition.d.ts +357 -0
  236. package/dist/yield-withdraw-definition.js +90 -0
  237. package/dist/yield-withdraw.d.ts +11 -0
  238. package/dist/yield-withdraw.js +122 -0
  239. package/package.json +239 -7
@@ -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 STAYS UNCONFIRMED AND REQUIRES AN EXPLICIT STOP. CDP exposes revoke by user id,
101
+ * not by grant identity. A compensating revoke after this function's grant read could therefore delete a
102
+ * newer permission that replaced the one observed here. The durable cap never becomes active, so Otto
103
+ * submits nothing under the mismatched grant; the person uses the normal Stop action to revoke whatever
104
+ * grant is current under a fresh read.
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, DELEGATION_TRADE_MINT_RETIRED_MESSAGE, effectiveTradeMintEnabled, 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) {
@@ -58,6 +61,10 @@ function servicesOf(runtime) {
58
61
  return refuse('DELEGATION_UNAVAILABLE', 'delegated send is not configured on this server');
59
62
  return services;
60
63
  }
64
+ function exactMintClaim(claim, userId, mintKey, generation) {
65
+ return claim?.userId === userId && claim.kind === 'mint' && claim.claimId === mintKey &&
66
+ claim.claimGeneration === generation;
67
+ }
61
68
  function parseWith(schema, rawInput) {
62
69
  const parsed = schema.safeParse(rawInput);
63
70
  if (!parsed.success) {
@@ -110,7 +117,9 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
110
117
  if (project.length > 1)
111
118
  return refuseAmbiguous(project);
112
119
  const before = readBackModelBFence(snapshot);
113
- if (before.present)
120
+ // A v1 fence reads back as PRESENT (swaps and deposits keep working under it) but is not current: it
121
+ // is upgraded in place below โ€” the one write the Founder's `fence v2` word triggers, by running this tool.
122
+ if (before.present && fenceIsCurrent(before))
114
123
  return fenceResult('unchanged', before.id, before.rulesDigest);
115
124
  let action;
116
125
  let targetId;
@@ -129,8 +138,12 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
129
138
  if (againProject.length > 1)
130
139
  return refuseAmbiguous(againProject);
131
140
  const read = readBackModelBFence(again);
132
- if (read.present)
141
+ // Only a CURRENT fence is "unchanged" (codex gate r1 MEDIUM): a v1 fence that appeared during the
142
+ // ensure is present but not current, and this path never updates โ€” it says so and asks for a re-run.
143
+ if (read.present && fenceIsCurrent(read))
133
144
  return fenceResult('unchanged', read.id, read.rulesDigest);
145
+ if (read.present)
146
+ 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
147
  const appeared = againProject[0];
135
148
  if (!appeared)
136
149
  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 +188,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
175
188
  if (afterProject.length > 1)
176
189
  return refuseAmbiguous(afterProject);
177
190
  const after = readBackModelBFence(listed);
178
- if (after.present && after.id === targetId)
191
+ if (after.present && fenceIsCurrent(after) && after.id === targetId)
179
192
  return fenceResult(action, after.id, after.rulesDigest);
180
193
  last = after.present
181
194
  ? `a v-current policy reads back under id ${after.id}, not the written id ${targetId}`
@@ -183,7 +196,7 @@ export async function ensureDelegationFence(runtime, rawInput, caller, wait = {}
183
196
  if (poll + 1 < maxPolls)
184
197
  await new Promise((resolve) => setTimeout(resolve, pollMs));
185
198
  }
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`);
199
+ 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
200
  });
188
201
  }
189
202
  function fenceResult(action, id, rulesDigest) {
@@ -191,7 +204,7 @@ function fenceResult(action, id, rulesDigest) {
191
204
  status: 'present',
192
205
  action,
193
206
  policy_id: id,
194
- policy_name: MODEL_B_POLICY_NAME_V1,
207
+ policy_name: MODEL_B_POLICY_NAME,
195
208
  rules_digest: rulesDigest,
196
209
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
197
210
  });
@@ -205,11 +218,21 @@ export async function readDelegationFenceStatus(runtime) {
205
218
  const fence = readBackModelBFence(await services.rig.listProjectPolicies());
206
219
  return Object.freeze({
207
220
  present: fence.present,
208
- policy_name: MODEL_B_POLICY_NAME_V1,
221
+ // The name that READS BACK (v1 until the fence tool upgrades it), so the page can tell which fence stands.
222
+ policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
209
223
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
224
+ per_swap_cap_min_usd: MODEL_B_PER_SWAP_CAP_MIN_USD,
210
225
  // The public authority binding: published present or absent, so the agent CLI mints to THIS project
211
226
  // (and can still bind status/revoke to it after the fence is removed). Registration requires it set.
212
227
  ...(services.publicProjectId ? { project_id: services.publicProjectId } : {}),
228
+ ruleset_version: MODEL_B_CAP_RULESET_VERSION,
229
+ /**
230
+ * ๐Ÿ”ด THE PAUSE IS PUBLISHED, so the page and the CLI can SAY it before a person signs in rather than
231
+ * discovering it at the reserve. False means no new permission can be created on this worker; it says
232
+ * nothing about permissions that already stand โ€” those keep working, and revoke always does.
233
+ */
234
+ // This public field describes Model B reserve, not Muse's separate global mint gate.
235
+ mint_enabled: effectiveTradeMintEnabled(services),
213
236
  ...(fence.present ? { rules_digest: fence.rulesDigest } : { reason: fence.reason }),
214
237
  });
215
238
  }
@@ -232,24 +255,345 @@ export async function readDelegationStatus(runtime, rawInput, caller) {
232
255
  const input = parseWith(DELEGATION_STATUS_INPUT_SCHEMA, rawInput);
233
256
  const user = await userFromAccessToken(services, input.accessToken);
234
257
  const fence = readBackModelBFence(await services.rig.listProjectPolicies());
235
- const requestedHere = revocationRequested(services.rig.rig, user.userId);
236
258
  const grant = await services.rig.rig.readDelegation(user.userId);
237
259
  const expiresAtMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
260
+ // Per GRANT, not per user: a revoke aimed at a permission the person no longer holds says nothing
261
+ // about the one they hold now.
262
+ const requestedHere = grant !== undefined && revocationRequested(services.rig.rig, user.userId, grant.expiresAt);
238
263
  const active = grant !== undefined &&
239
264
  !requestedHere &&
240
265
  Number.isFinite(expiresAtMs) &&
241
266
  expiresAtMs > runtime.nowMs();
267
+ /**
268
+ * ๐Ÿ”ด THE CAP COMES FROM THE CONFIRMED-ACTIVE RECORD, AND ONLY WHEN IT NAMES THE GRANT THAT READS BACK
269
+ * โ€” exactly the check the delegated send makes, so this surface and the send can never disagree about
270
+ * what is in force. A reservation that was never confirmed shows NO cap, because it authorises
271
+ * nothing. It is OMITTED, never defaulted: absence here means the send will refuse, and a page that
272
+ * showed the ceiling instead would tell the reader their permission allows $5,000 when it allows
273
+ * nothing at all.
274
+ */
275
+ let recordedCapUsd;
276
+ if (grant && services.capStore.available) {
277
+ try {
278
+ const confirmed = await services.capStore.readActive(user.userId, runtime.nowMs());
279
+ if (confirmed &&
280
+ Date.parse(confirmed.expiresAt) === expiresAtMs &&
281
+ confirmed.requiredVersion <= MODEL_B_CAP_RULESET_VERSION) {
282
+ recordedCapUsd = confirmed.perSwapCapUsd;
283
+ }
284
+ }
285
+ catch {
286
+ // A read that FAILED is not "nothing is confirmed" โ€” the send will refuse either way, and this
287
+ // surface says so by omitting the field rather than inventing a number in either direction.
288
+ recordedCapUsd = undefined;
289
+ }
290
+ }
242
291
  return Object.freeze({
243
292
  user_id: user.userId,
244
293
  addresses: [...user.addresses],
245
294
  fence: Object.freeze({
246
295
  present: fence.present,
247
- policy_name: MODEL_B_POLICY_NAME_V1,
296
+ policy_name: fence.present ? fence.name : MODEL_B_POLICY_NAME,
248
297
  per_swap_cap_usd: MODEL_B_PER_SWAP_CAP_USD,
249
298
  ...(fence.present ? { rules_digest: fence.rulesDigest } : {}),
250
299
  }),
251
- delegation: Object.freeze({ active, ...(grant ? { expires_at: grant.expiresAt } : {}) }),
300
+ delegation: Object.freeze({
301
+ active,
302
+ ...(grant ? { expires_at: grant.expiresAt } : {}),
303
+ ...(recordedCapUsd !== undefined ? { per_swap_cap_usd: recordedCapUsd } : {}),
304
+ }),
252
305
  revocation_requested_here: requestedHere,
306
+ // What this worker implements โ€” the page and the CLI refuse to reserve or mint against an older one.
307
+ ruleset_version: MODEL_B_CAP_RULESET_VERSION,
308
+ // Whether a NEW permission can be created here at all (the staged-deploy gate). Never gates this read,
309
+ // and never gates revoke.
310
+ // Older dApp clients check this immediately before replacement revoke. Keep it false
311
+ // whenever the server will refuse Model B reserve, so they stop before revoking.
312
+ mint_enabled: effectiveTradeMintEnabled(services),
313
+ });
314
+ }
315
+ /**
316
+ * `otto_delegation_cap` โ€” RESERVE the expiry a delegation will be minted with, record the chosen per-swap
317
+ * cap against it, and CONFIRM the mint (Founder word `delegate cap choice`, 2026-09-02; round 3, after the
318
+ * codex gate NO-GO'd rounds 1 and 2 and the seat ruled the collision out rather than documenting it).
319
+ *
320
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
321
+ * ๐Ÿ”ด WHY THE ORDER IS RESERVE โ†’ MINT โ†’ CONFIRM, AND NOT MINT โ†’ RECORD
322
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
323
+ * The gate's second finding is the whole reason: an expiry is the ONLY thing a send can learn about a live
324
+ * grant, so keying the RECORD by anything else โ€” r1's expiry-as-key, r2's mint key โ€” fixes the record and
325
+ * not the send. Its probe minted a $10 replacement carrying the SAME expiry as a standing $5,000 row and
326
+ * watched a plan run at $5,000. Live confirmation of exactly that, on the dev project (2026-09-02T22:17Z):
327
+ * CDP refuses a second mint over a live grant (409 `already_exists`), but after a user-side revoke a
328
+ * re-mint at the SAME instant succeeds and reads back at that identical instant. Two grants for one user
329
+ * CAN share an expiry.
330
+ *
331
+ * So round 3 stops trying to tell them apart and makes them impossible:
332
+ *
333
+ * RESERVE โ€” the SERVER picks the instant. It walks back from the second the person's chosen duration
334
+ * lands on and takes the first whole second no durable row for this user occupies, then writes the cap
335
+ * against it BEFORE any grant exists. The caller cannot choose the enforcement key, which is the
336
+ * property that makes it unique. The ladder only ever steps EARLIER (seat ruling): a permission must
337
+ * never outlive the duration the person chose, and the page shows the instant it actually got.
338
+ *
339
+ * MINT โ€” the caller passes `mint_expires_at` to `createDelegation` verbatim. It is the reserved second
340
+ * plus one millisecond, because CDP refuses a bare `.000Z` (401 `unauthorized` "Wallet authentication
341
+ * error" โ€” isolated on one session: `.000Z` refused, `.137Z` accepted, `.000Z` refused again) and
342
+ * truncates the filler away, storing exactly the reserved second.
343
+ *
344
+ * CONFIRM โ€” the server re-reads the grant from CDP and refuses unless the instant CDP reports IS the
345
+ * reserved one. That is the assertion the whole construction rests on: if CDP ever normalises,
346
+ * truncates differently, or another actor's grant is what reads back, this is where it surfaces
347
+ * instead of silently binding a permission to a row it was never matched to.
348
+ *
349
+ * ๐Ÿ”ด A CONFIRM MISMATCH STAYS UNCONFIRMED AND REQUIRES AN EXPLICIT STOP. CDP exposes revoke by user id,
350
+ * not by grant identity. A compensating revoke after this function's grant read could therefore delete a
351
+ * newer permission that replaced the one observed here. The durable cap never becomes active, so Otto
352
+ * submits nothing under the mismatched grant; the person uses the normal Stop action to revoke whatever
353
+ * grant is current under a fresh read.
354
+ *
355
+ * ๐Ÿ”ด NEITHER PHASE TAKES A USER ID OR AN EXPIRY FROM THE CALLER. The identity comes from
356
+ * `validateAccessToken`; the reserved instant comes from the store; the live instant comes from CDP.
357
+ */
358
+ export async function reserveDelegationCap(runtime, rawInput, caller) {
359
+ const services = servicesOf(runtime);
360
+ assertDelegationCaller(services, caller);
361
+ const input = parseWith(DELEGATION_CAP_INPUT_SCHEMA, rawInput);
362
+ // No new Model B mint claim or reservation is issued. Confirm remains available for a
363
+ // permission already minted against a reservation before this release.
364
+ if (input.phase === 'reserve' && !services.tradeMintEnabled) {
365
+ return refuse('DELEGATION_MINT_PAUSED', DELEGATION_TRADE_MINT_RETIRED_MESSAGE);
366
+ }
367
+ if (!services.capStore.available) {
368
+ 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'})`);
369
+ }
370
+ /**
371
+ * ๐Ÿ”ด THE STAGED-DEPLOY GATE, ON RESERVE ONLY (gate finding, CRITICAL 2 โ€” the first rolling deployment).
372
+ *
373
+ * With `DELEGATION_MINT_ENABLED` false โ€” the default in code โ€” no NEW permission can be created on
374
+ * this worker, so there is no cap-bearing grant a still-serving cap-unaware binary could execute at
375
+ * the shared ceiling. That is the fence the ruleset handshake cannot be for the FIRST rollout, because
376
+ * the binary that would have to honour it predates it.
377
+ *
378
+ * ๐Ÿ”ด AND CONFIRM IS DELIBERATELY NOT GATED. Confirm binds a permission that ALREADY EXISTS at Coinbase
379
+ * to the reservation it was minted for. Refusing it would leave a live grant with no record โ€” which is
380
+ * fail-closed for sending, but strictly worse for the person: an unbindable permission they have to
381
+ * find and revoke. The flag is flipped falseโ†’true by the runbook, so the only mid-flow flip is
382
+ * onโ†’off, and letting an in-flight mint finish binding is the safe half of that.
383
+ *
384
+ * Sends under permissions that already stand are untouched, and REVOKE is never gated: pausing the
385
+ * creation of authority must never pause its withdrawal.
386
+ */
387
+ if (input.phase === 'reserve' && !services.mintEnabled) {
388
+ return refuse('DELEGATION_MINT_PAUSED', DELEGATION_MINT_PAUSED_MESSAGE);
389
+ }
390
+ const user = await userFromAccessToken(services, input.accessToken);
391
+ return input.phase === 'reserve'
392
+ ? reservePhase(services, runtime, user.userId, input)
393
+ : confirmPhase(services, runtime, user.userId, input);
394
+ }
395
+ /** The RESERVE phase: claim a free instant, record the cap against it, hand back what to mint with. */
396
+ async function reservePhase(services, runtime, userId, input) {
397
+ const nowMs = runtime.nowMs();
398
+ if (!services.operationStore.available) {
399
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the unified operation journal is unavailable; no permission is reserved');
400
+ }
401
+ let journalReady;
402
+ try {
403
+ journalReady = (await services.operationStore.journalStatus()).ready;
404
+ }
405
+ catch {
406
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the unified operation journal could not be read; no permission is reserved');
407
+ }
408
+ if (!journalReady) {
409
+ return refuse('DELEGATION_OPERATION_JOURNAL_NOT_READY', 'the unified operation journal is waiting for every legacy worker to drain; no permission is reserved');
410
+ }
411
+ if (!services.exitStore?.available) {
412
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the unified mint/delegated/exit claim store is unavailable; no permission is reserved');
413
+ }
414
+ let claimGeneration;
415
+ try {
416
+ // Sample before the separate unified-claim write. `capStore.reserve` persists this exact value, so
417
+ // a Stop that lands anywhere afterwards makes the reservation permanently cancelable rather than
418
+ // letting a delayed cap transaction join the newer generation.
419
+ claimGeneration = await services.capStore.readRevokeGeneration(userId);
420
+ }
421
+ catch {
422
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the current Stop generation could not be read durably; no permission is reserved');
423
+ }
424
+ try {
425
+ const claim = await services.exitStore.claimMint(userId, input.mintKey, nowMs, claimGeneration);
426
+ if (claim.outcome === 'conflict') {
427
+ return refuse('DELEGATION_OPERATION_UNRESOLVED', `this end user has an unresolved ${claim.claim.kind} operation (${claim.claim.claimId}); no permission is reserved`);
428
+ }
429
+ }
430
+ catch {
431
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the permission could not acquire the unified execution claim; no permission is reserved');
432
+ }
433
+ /**
434
+ * The LATEST instant this permission may carry โ€” the person's own duration, on a whole second. The
435
+ * ladder never returns anything after it, so an offset can only ever shorten a grant.
436
+ */
437
+ let latest;
438
+ try {
439
+ latest = boundedDelegationExpiry(input.requestedDays, nowMs);
440
+ }
441
+ catch (error) {
442
+ return refuse('DELEGATION_INPUT_INVALID', error instanceof Error ? error.message : 'the requested expiry is not usable');
443
+ }
444
+ let reservation;
445
+ try {
446
+ reservation = await services.capStore.reserve(userId, input.mintKey, latest.expiresAt, input.perSwapCapUsd, nowMs, claimGeneration);
447
+ }
448
+ catch (error) {
449
+ // Keep the cross-kind claim even when the caps write fails. Another same-key process may already
450
+ // have observed it and still commit its reservation; only a successful confirm or explicit Stop can
451
+ // prove every issued mint instruction harmless enough to release the shared claim.
452
+ if (error instanceof DelegationCapExpiryUnavailableError) {
453
+ 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');
454
+ }
455
+ // The claim (or the record beside it) failed. Nothing is cached either โ€” the store writes disk
456
+ // first โ€” so the honest answer is that nothing was reserved, never a success the caller mints against.
457
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the per-swap cap could not be written durably; nothing is reserved and no permission should be minted');
458
+ }
459
+ let claimAfterReserve;
460
+ let generationAfterReserve;
461
+ try {
462
+ [claimAfterReserve, generationAfterReserve] = await Promise.all([
463
+ services.exitStore.readClaim(userId),
464
+ services.capStore.readRevokeGeneration(userId),
465
+ ]);
466
+ }
467
+ catch {
468
+ // Ambiguous revalidation retains the claim. That blocks every conflicting financial action until a
469
+ // later exact confirm or Stop can resolve it.
470
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the permission claim could not be revalidated after reservation; nothing should be minted');
471
+ }
472
+ if (reservation.record.revokeGeneration !== claimGeneration ||
473
+ generationAfterReserve !== claimGeneration ||
474
+ !exactMintClaim(claimAfterReserve, userId, input.mintKey, claimGeneration)) {
475
+ // The row, if written, carries the older generation and confirm will classify it as cancelled.
476
+ // Delete only this exact claim; a later same-key claim under a newer generation is untouched.
477
+ await services.exitStore.releaseMint(userId, input.mintKey, claimGeneration).catch(() => false);
478
+ return refuse('DELEGATION_CAP_CANCELLED', 'Stop overlapped this permission reservation; nothing is confirmed. Check status and use Stop if a permission remains active');
479
+ }
480
+ return capResult('reserve', reservation.record, reservation.outcome === 'reserved');
481
+ }
482
+ /**
483
+ * The CONFIRM phase: the instant CDP holds must BE the instant that was reserved, and the reservation
484
+ * must still be confirmable. Promotes it to confirmed-active in one durable write. A mismatch stays
485
+ * unconfirmed and requires an explicit Stop because CDP exposes only a user-wide revoke.
486
+ */
487
+ async function confirmPhase(services, runtime, userId, input) {
488
+ const nowMs = runtime.nowMs();
489
+ if (!services.exitStore.available) {
490
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the unified mint/delegated/exit claim store is unavailable; nothing is confirmed');
491
+ }
492
+ let reservation;
493
+ try {
494
+ reservation = await services.capStore.readReservation(userId, input.mintKey, nowMs);
495
+ }
496
+ catch {
497
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the reservation for this mint could not be read; nothing is confirmed');
498
+ }
499
+ if (!reservation) {
500
+ return refuse('DELEGATION_CAP_UNRECORDED', 'no per-swap cap was reserved for this mint, so nothing is confirmed. If a permission reads back, use Stop to revoke it; new Model B setup is retired');
501
+ }
502
+ const claimGeneration = reservation.revokeGeneration;
503
+ let claim;
504
+ try {
505
+ claim = await services.exitStore.readClaim(userId);
506
+ }
507
+ catch {
508
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the permission claim could not be read; nothing is confirmed');
509
+ }
510
+ if (!exactMintClaim(claim, userId, input.mintKey, claimGeneration)) {
511
+ if (claim) {
512
+ return refuse('DELEGATION_OPERATION_UNRESOLVED', `this end user has an unresolved ${claim.kind} operation (${claim.claimId}); nothing is confirmed`);
513
+ }
514
+ return refuse('DELEGATION_CAP_CANCELLED', 'the exact generation-bound permission claim no longer stands; nothing is confirmed. Check status and use Stop if a permission remains active');
515
+ }
516
+ const grant = await services.rig.rig.readDelegation(userId);
517
+ const grantMs = grant ? Date.parse(grant.expiresAt) : Number.NaN;
518
+ if (!grant ||
519
+ !Number.isFinite(grantMs) ||
520
+ grantMs <= nowMs ||
521
+ revocationRequested(services.rig.rig, userId, grant.expiresAt)) {
522
+ return refuse('DELEGATION_NOT_ACTIVE', 'no active delegation reads back for this end user, so there is nothing to confirm');
523
+ }
524
+ let claimAfterGrant;
525
+ try {
526
+ claimAfterGrant = await services.exitStore.readClaim(userId);
527
+ }
528
+ catch {
529
+ return refuse('DELEGATION_OPERATION_STORE_UNAVAILABLE', 'the permission claim could not be revalidated after the grant read; nothing is confirmed');
530
+ }
531
+ if (!exactMintClaim(claimAfterGrant, userId, input.mintKey, claimGeneration)) {
532
+ return refuseCancelledMint();
533
+ }
534
+ let outcome;
535
+ try {
536
+ outcome = await services.capStore.confirm(userId, input.mintKey, grant.expiresAt, nowMs);
537
+ }
538
+ catch {
539
+ return refuse('DELEGATION_CAP_STORE_UNAVAILABLE', 'the reservation for this mint could not be read or promoted, so nothing is confirmed');
540
+ }
541
+ switch (outcome.outcome) {
542
+ case 'confirmed':
543
+ case 'already_confirmed':
544
+ await services.exitStore.releaseMint(userId, input.mintKey, claimGeneration);
545
+ return capResult('confirm', outcome.record, outcome.outcome === 'confirmed');
546
+ case 'unreserved':
547
+ return refuse('DELEGATION_CAP_UNRECORDED', 'no per-swap cap was reserved for this mint, so nothing is confirmed. If a permission reads back, use Stop to revoke it; new Model B setup is retired');
548
+ case 'not_reservable':
549
+ return refuse('DELEGATION_CAP_UNRECORDED', `the reservation for this mint is ${outcome.state} and can no longer be confirmed. Check status and use Stop if a permission remains active`);
550
+ case 'superseded':
551
+ /**
552
+ * ๐Ÿ”ด NEVER REVIVED. This reservation WAS confirmed and a later one replaced it; promoting it again
553
+ * would bring a replaced permission back, and on a wider cap that is the widening this whole unit
554
+ * exists to prevent.
555
+ */
556
+ return refuse('DELEGATION_CAP_SUPERSEDED', 'this permission was replaced by a later one and is not brought back. Check the current permission in status; use Stop if you want to revoke it');
557
+ case 'cancelled':
558
+ /**
559
+ * ๐Ÿ”ด THE PERSON PRESSED STOP WHILE THIS PERMISSION WAS BEING CREATED (gate finding, r4: "Add a
560
+ * durable revoke generation so Stop cancels already-reserved/in-flight mints"). The reservation
561
+ * stays unconfirmed; an explicit fresh Stop safely revokes whichever remote grant is current.
562
+ */
563
+ return refuseCancelledMint();
564
+ case 'expiry_mismatch':
565
+ return refuseMismatchedGrant();
566
+ }
567
+ }
568
+ /**
569
+ * A Stop or replacement landed between this mint's reservation and confirmation. The exact claim is no
570
+ * longer current, so this stale confirm must not call CDP's user-wide revoke: that call could revoke the
571
+ * replacement permission. The old reservation authorises nothing, and an explicit fresh Stop handles the
572
+ * current remote grant.
573
+ */
574
+ function refuseCancelledMint() {
575
+ return refuse('DELEGATION_CAP_CANCELLED', 'this permission was stopped or replaced while it was being created; nothing is confirmed for it and no automatic user-wide revoke was issued. Check status and press Stop if you want to revoke the current permission');
576
+ }
577
+ /**
578
+ * The live permission is not the one this cap was reserved for. It remains unconfirmed and therefore
579
+ * authorises nothing through Otto. CDP has no conditional grant-id revoke, so this stale confirm never
580
+ * issues a user-wide compensating revoke that could take away a newer permission.
581
+ */
582
+ function refuseMismatchedGrant() {
583
+ return refuse('DELEGATION_CAP_GRANT_CHANGED', 'the delegation that reads back is not the one this per-swap cap was reserved for; it remains unconfirmed and nothing is submitted under it. Check status and press Stop if you want to revoke the current permission');
584
+ }
585
+ /** One shape for both phases, so the two answers cannot drift apart. */
586
+ function capResult(phase, record, wroteIt) {
587
+ return Object.freeze({
588
+ phase,
589
+ user_id: record.userId,
590
+ expires_at: record.expiresAt,
591
+ // The value to mint with exists only on the reserve โ€” by confirm the mint has already happened.
592
+ ...(phase === 'reserve' ? { mint_expires_at: record.requestedExpiresAt } : {}),
593
+ per_swap_cap_usd: record.perSwapCapUsd,
594
+ state: record.state,
595
+ ruleset_version: record.requiredVersion,
596
+ recorded: wroteIt,
253
597
  });
254
598
  }
255
599
  const STDOUT_SUPPORT_SINK = {
@@ -277,6 +621,72 @@ export async function revokeDelegation(runtime, rawInput, caller, supportSink =
277
621
  ts: new Date(runtime.nowMs()).toISOString(),
278
622
  });
279
623
  }
624
+ /**
625
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
626
+ * ๐Ÿ”ด THE DURABLE RECORD IS REVOKED IN THE SAME OPERATION, AND FIRST (gate finding, round 4 CRITICAL 1)
627
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
628
+ * "Revoke does not transition the durable record. The normal revoke calls only `rig.revoke()` and never
629
+ * `capStore.revoke()` โ€ฆ a new grant can inherit the revoked grant's old $5,000 cap without
630
+ * confirmation. This is exactly the prohibited widening direction." Its probe on the shipped fixtures:
631
+ * revoke โ†’ fresh process โ†’ same-expiry re-mint โ†’ `afterReportedRevoke=confirmed_active, sendStatus=sent,
632
+ * enforcedOldCap=5000`.
633
+ *
634
+ * The in-process revocation marker dies with the process; the RECORD does not. So the record moves
635
+ * first, and the CDP call happens only after it has:
636
+ *
637
+ * ยท the grant is read FIRST, so the record and the marker name the SAME permission;
638
+ * ยท a durable transition that THROWS means nothing is revoked at Coinbase either โ€” the caller is told
639
+ * to retry, rather than being left with a removed grant and a record that still says it stands;
640
+ * ยท a transition that finds nothing to change (`false`) is NOT a failure: the record was already
641
+ * revoked, or this person's live grant was minted out-of-model and was never recorded here. The
642
+ * CDP revoke still goes out โ€” a person must always be able to take a permission away;
643
+ * ยท if the CDP revoke then fails, the record is ALREADY revoked. That is the safe direction (this
644
+ * server will submit nothing under it) and a retry re-issues the CDP call.
645
+ *
646
+ * ๐Ÿ”ด THE EXPIRY IT CONSUMED STAYS CONSUMED. The reservation row survives as `revoked`, so no later
647
+ * permission minted through Otto's page or CLI can land on that instant โ€” which is what closes the
648
+ * probe above: the re-minted grant gets a DIFFERENT second, matches no confirmed record, and is
649
+ * refused rather than inheriting anything.
650
+ */
651
+ let capturedMintClaim;
652
+ if (services.capStore.available && services.exitStore.available) {
653
+ try {
654
+ const claim = await services.exitStore.readClaim(userId);
655
+ if (claim?.kind === 'mint' && claim.claimGeneration !== undefined)
656
+ capturedMintClaim = claim;
657
+ }
658
+ catch {
659
+ // Stop and remote revoke remain available; an unreadable claim is conservatively retained.
660
+ }
661
+ }
662
+ let durableStopRecorded = false;
663
+ if (services.capStore.available) {
664
+ try {
665
+ /**
666
+ * ๐Ÿ”ด ONE TRANSACTION, BOTH WRITES (gate r5 CRITICAL: "commits the generation bump and then performs
667
+ * the active-record revoke in a separate transaction โ€ฆ the process dies or stalls before
668
+ * `capStore.revoke()` โ€ฆ a fresh worker still reads the old record as `confirmed_active`").
669
+ *
670
+ * `stop` bumps the revoke generation โ€” EVERY Stop, including one that finds no grant, the case the
671
+ * round-4 gate named, so a mint already in flight is cancelled at its confirm โ€” AND moves whatever
672
+ * record is confirmed-active to `revoked`, inside one `BEGIN IMMEDIATE`. A crash between the two
673
+ * commits neither. It takes no expiry: a Stop means Otto acts under nothing for this person, which
674
+ * is also what the CDP-side revoke below does to whatever grant is live, so the two halves agree
675
+ * without a read in between that could see a different grant than either of them acts on.
676
+ */
677
+ await services.capStore.stop(userId, runtime.nowMs());
678
+ durableStopRecorded = true;
679
+ }
680
+ catch {
681
+ return refuse('DELEGATION_REVOKE_NOT_CONFIRMED', 'the permission could not be marked revoked durably, so nothing was revoked at Coinbase either โ€” retry');
682
+ }
683
+ }
684
+ // Only a successful durable generation bump may retire the exact mint claim observed before it. The
685
+ // mint key plus generation compare-delete cannot erase a newer claim, including same-key reuse after
686
+ // Stop. If the cap store is unavailable, retain the claim while still honoring remote revoke.
687
+ if (durableStopRecorded && capturedMintClaim?.claimGeneration !== undefined) {
688
+ await services.exitStore.releaseMint(userId, capturedMintClaim.claimId, capturedMintClaim.claimGeneration).catch(() => false);
689
+ }
280
690
  try {
281
691
  // Serialized on the rig, the lock held through the read-back; the user is marked revocation-requested BEFORE
282
692
  // the raw revoke, so a plan queued behind this call is refused whatever the read-back says.