okengine 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
@@ -209,10 +209,25 @@ Builtin encrypted store:
209
209
 
210
210
  `--url` overrides the SQL URL (`DATABASE_URL` / `OKE_STORE_SQL_URL`).
211
211
 
212
+ ## Audit sinks
213
+
214
+ | `vault.audit.sink` | Recorded as | `oke vault audit` |
215
+ | ------------------ | --------------------------------- | ------------------- |
216
+ | `db` (default) | SQL hash chain | list, verify, purge |
217
+ | `stdout` | One secret-free JSON line | Refused |
218
+ | `webhook` | POST of that JSON to `webhookUrl` | Refused |
219
+
220
+ `webhookUrl` must be an absolute `http:` or `https:` URL. The POST uses `redirect: "error"` and a 5 second timeout. A failed delivery does not undo the vault write. `audit.enabled: false` records nothing. The body never contains a secret value.
221
+
212
222
  ## Troubleshooting
213
223
 
214
224
  <Accordions>
215
225
 
226
+ <Accordion title="audit verify requires audit.sink db">
227
+ `stdout` and `webhook` sinks are not a local hash chain. `oke vault audit` (list, verify, purge)
228
+ throws `UNSUPPORTED`. Use `sink: "db"` when you need `verify`.
229
+ </Accordion>
230
+
216
231
  <Accordion title="oke vault: no such secret">
217
232
  Rotate/get targeted a path that was never set. `oke vault list` (or Console) for live paths;
218
233
  remember per-tenant storage uses `{tenantId}/{name}`.
@@ -69,7 +69,7 @@ invalidated. Delivery follows `channels` order for addresses you pass.
69
69
  </Step>
70
70
 
71
71
  <Step>
72
- ### Resend on another channel (app mode only)
72
+ ### Resend
73
73
 
74
74
  ```typescript
75
75
  const { data } = await api.auth.resendOtp({
@@ -79,8 +79,8 @@ const { data } = await api.auth.resendOtp({
79
79
  });
80
80
  ```
81
81
 
82
- Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
83
- resend surface — the provider owns the code.
82
+ App mode keeps the same code and TTL. Provider mode is SMS only and asks the
83
+ provider for a new code. Default cooldown is 60 seconds either way.
84
84
 
85
85
  </Step>
86
86
 
@@ -108,18 +108,18 @@ const { data } = await api.auth.verifyOtp({
108
108
 
109
109
  ## Modes
110
110
 
111
- | | Provider mode | App mode |
112
- | -------------------- | ----------------------------- | --------------------------------------------- |
113
- | Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
114
- | Who owns the code | Provider (Verify API) | Your app |
115
- | Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
116
- | Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
117
- | Resend other channel | Impossible | `POST /auth/otp/resend` |
118
- | `exposeDevOtp` | Forbidden | Optional (default off) |
111
+ | | Provider mode | App mode |
112
+ | ----------------- | ----------------------------- | --------------------------------------------- |
113
+ | Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
114
+ | Who owns the code | Provider (Verify API) | Your app |
115
+ | Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
116
+ | Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
117
+ | Resend | SMS only, new provider code | Same code, any declared channel |
118
+ | `exposeDevOtp` | Forbidden | Optional (default off) |
119
119
 
120
120
  <Callout type="warn" title="Provider mode limitation">
121
- Resend-via-different-channel is impossible in provider mode — the code value is never visible to
122
- OKE. Use app mode when you need SMS → email fallback for the same code.
121
+ A provider resend is a new SMS code (`resendCooldownMs`, default 60s). The code never reaches OKE,
122
+ so SMS → email for the same code needs `mode: "app"`.
123
123
  </Callout>
124
124
 
125
125
  ### Provider mode setup
@@ -154,27 +154,27 @@ Override copy with `oke({ channel.catalog })` or `.channelCatalog(…)` (later l
154
154
 
155
155
  ## Options
156
156
 
157
- | Option | Type | Default | Meaning |
158
- | ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
159
- | `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
160
- | `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
161
- | `ttlMs` | `number` | 10m | Challenge lifetime |
162
- | `resendCooldownMs` | `number` | 60s | App-mode resend spacing |
163
- | `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
164
- | `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
165
- | `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
166
- | `sessions` | `SessionStore` | active\* | Session store |
167
- | `identities` | `IdentityStore` | new | Email → user |
168
- | `phones` | `PhoneStore` | new | Phone → user |
169
- | `verifications` | `VerificationStore` | new | Challenge store |
157
+ | Option | Type | Default | Meaning |
158
+ | ------------------ | -------------------------------- | -------------------------- | ------------------------------------ |
159
+ | `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
160
+ | `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
161
+ | `ttlMs` | `number` | 10m | Challenge lifetime |
162
+ | `resendCooldownMs` | `number` | 60s | Spacing between resends (both modes) |
163
+ | `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
164
+ | `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
165
+ | `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
166
+ | `sessions` | `SessionStore` | active\* | Session store |
167
+ | `identities` | `IdentityStore` | new | Email → user |
168
+ | `phones` | `PhoneStore` | new | Phone → user |
169
+ | `verifications` | `VerificationStore` | new | Challenge store |
170
170
 
171
171
  ## Surfaces
172
172
 
173
- | Flow | Path | Gate | Mode |
174
- | ----------------- | ------------------------ | ------------------------ | ------------- |
175
- | `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
176
- | `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
177
- | `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | app mode only |
173
+ | Flow | Path | Gate | Mode |
174
+ | ----------------- | ------------------------ | ------------------------ | ------------------------- |
175
+ | `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
176
+ | `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
177
+ | `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | both (provider: SMS only) |
178
178
 
179
179
  ## Troubleshooting
180
180
 
@@ -28,7 +28,7 @@ drivers: {
28
28
  },
29
29
  },
30
30
  images: {
31
- channel: { email: "axllent/mailpit:v1.31.1" },
31
+ channel: { email: "axllent/mailpit:v1.31.2" },
32
32
  },
33
33
  ```
34
34
 
@@ -28,7 +28,7 @@ drivers: {
28
28
  },
29
29
  },
30
30
  images: {
31
- store: { index: "getmeili/meilisearch:v1.53" },
31
+ store: { index: "getmeili/meilisearch:v1.54" },
32
32
  },
33
33
  ```
34
34
 
@@ -26,7 +26,7 @@ create-oke asks **Add PgDog connection pooling…?** (or pass `--pgdog`). Manual
26
26
  ```typescript title="oke.config.ts"
27
27
  images: {
28
28
  "store.sql": "postgres:18-alpine",
29
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
29
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
30
30
  },
31
31
  ```
32
32
 
@@ -153,8 +153,8 @@ images: {
153
153
  kv: "redis:8-alpine",
154
154
  files: "rustfs/rustfs:1.0.0",
155
155
  },
156
- channel: { email: "axllent/mailpit:v1.31.1" },
157
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
156
+ channel: { email: "axllent/mailpit:v1.31.2" },
157
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
158
158
  // proxy: "caddy:2-alpine",
159
159
  },
160
160
  ```
@@ -120,6 +120,10 @@ at **404**. Clients distinguish by envelope, not status. Domain codes stay **400
120
120
  | Code | Status | Helper |
121
121
  | ------------------------------------------------ | ----------- | ------------------------------------------------------ |
122
122
  | `ValidationError` | `422` | none — failed `in`, `do` never runs |
123
+ | `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header |
124
+ | `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII |
125
+ | `IdempotencyKeyReused` | `422` | none — same key, different payload |
126
+ | `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes |
123
127
  | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
124
128
  | `Forbidden` | `403` | `forbidden` · also a gate denial |
125
129
  | `NotFound` | `404` | `notFound` |
@@ -301,8 +305,14 @@ Thrown by specific subsystems — each names its own cause:
301
305
  <Accordions>
302
306
 
303
307
  <Accordion title="I hit OKE1001–1009">
304
- Explicit `effects` drifted from `do`. Add the missing ledger entry or remove the hand-written
305
- `effects` block so inference covers the Flow.
308
+ The capability token does not include what the Flow touched. Add the missing ledger entry. An
309
+ `effects` block must include every effect inference can see.
310
+ </Accordion>
311
+
312
+ <Accordion title="OKE1900 fx hidden from inference">
313
+ Cause: the Flow renames `fx`, lets a chain alias escape, or passes `fx` to a callee inference
314
+ cannot resolve. Fix: name the parameter `fx`, keep `const q = fx.store(db)` in the same function,
315
+ and include every effect the walk can see. Extra keys in an `effects` block are allowed.
306
316
  </Accordion>
307
317
 
308
318
  <Accordion title="OKE1040 pathless HTTP never stamped">
@@ -54,7 +54,9 @@ async function loadNote(id: string, fx: Fx) {
54
54
  }
55
55
  ```
56
56
 
57
- A narrower structural type will not match `store()` overloads. See [Flow](/docs/elements/flow).
57
+ Name that parameter `fx`. A narrower structural type will not match `store()` overloads. See [Flow](/docs/elements/flow).
58
+
59
+ Inference follows `fx` in four shapes: a direct `fx.*` call, a chain alias kept in the same function (`const q = fx.store(db)` then `q.select()`), a project helper whose parameter is named `fx`, and two package intrinsics — `liveQuery(fx, table, …)` records `reads: ["sql:<table>"]`, and `applySearchEmbedCdc` records the static `sqlRef` plus each column's embed model.
58
60
 
59
61
  </Step>
60
62
 
@@ -155,11 +157,13 @@ on(
155
157
  | `fx.auth.createTenant({ name, slug?, id? })` | `write` `auth:tenants` | Creator becomes a member. Session only |
156
158
  | `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
157
159
 
158
- `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization) and
159
- propagates `fx.tenant.id`. For audit/attribution only, read `fx.principal` — it propagates the
160
- originating identity without copying into `fx.auth`. Gates never consult `fx.principal`.
161
- Controlled `return fx.fail(...)` returns a `FlowFailure` value; an unhandled `throw` rethrows
162
- (never silent `undefined`). The same rules apply to `app.call`.
160
+ `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed) and propagates `fx.tenant.id`.
161
+ Read `fx.principal` for audit — it does not copy into `fx.auth`.
162
+
163
+ <Callout>
164
+ Gates never consult `fx.principal`. `return fx.fail(...)` is a `FlowFailure`; an unhandled `throw`
165
+ rethrows. The same rules apply to `app.call`.
166
+ </Callout>
163
167
 
164
168
  ## Concurrency and retry
165
169
 
@@ -190,11 +194,14 @@ const charge = await fx.step("charge", () =>
190
194
  | `when` | thrown | Predicate; skips `AbortError` and sleep park |
191
195
 
192
196
  <Callout title="Cooperative cancel">
193
- Losing branches see `fx.signal` abort. Drivers that do not yet honor the signal may still finish
194
- in the background — check `fx.signal.aborted` in long user work, and prefer `fx.all` over bare
195
- `Promise.all`.
197
+ Losing branches see `fx.signal` abort. `fx.fetch`, Meilisearch, remote Vault HTTP, and AI streams
198
+ cancel in flight. An effect that has not started throws `AbortError`.
196
199
  </Callout>
197
200
 
201
+ SQL (`Bun.sql`), Redis, and a Channel send already on the wire through sently still finish. Those
202
+ clients expose no abort hook, and the shared connection stays open. Prefer `fx.all` over bare
203
+ `Promise.all`.
204
+
198
205
  `fx.using(acquire, release, use)` scopes a process-local resource to one attempt: `release` runs
199
206
  exactly once when `use` settles **or** when the ambient signal aborts (a sibling `fx.race` winner,
200
207
  a failing `fx.all` sibling). It is not journaled — do not hold handles across durable park/resume.
@@ -221,9 +228,9 @@ contact a provider. Bodies live on `.template({ catalog })` (`{{field}}`, not IC
221
228
 
222
229
  ## Outbound HTTP
223
230
 
224
- | Signature | Records | Notes |
225
- | ---------------------- | --------------------------- | ------------------------------------------------------------------------- |
226
- | `fx.fetch(url, init?)` | `fetch` on the URL hostname | Always stamps `EffectEntry.external` with `{ host, kind: "third-party" }` |
231
+ | Signature | Records | Notes |
232
+ | ---------------------- | --------------------------- | -------------------------------------------------------------------------- |
233
+ | `fx.fetch(url, init?)` | `fetch` on the URL hostname | Stamps `external: { host, kind: "third-party" }`. Aborts with `fx.signal`. |
227
234
 
228
235
  Declare hosts in `effects.fetches` (e.g. `["api.stripe.com"]`). Prefer `fx.step`; use
229
236
  `fx.retry` only when the remote API is safe to repeat. Dry runs never hit the network.
@@ -340,9 +347,15 @@ to the key — see [Gate](/docs/elements/gate#api-keys).
340
347
  <Accordions>
341
348
 
342
349
  <Accordion title="OKE1001–1007 / 1008 / 1009 undeclared effect">
343
- An explicit `effects` block drifted from what `do` touches. Add the missing ledger entry (`reads`
344
- · `writes` · `emits` · `sends` · `asks` · `embeds` · `secrets` · `calls` · `fetches`), or drop the
345
- block so inference covers the Flow ([Errors](/docs/reference/errors)).
350
+ The Manifest token does not include what `do` touched. Add the missing ledger entry (`reads` ·
351
+ `writes` · `emits` · `sends` · `asks` · `embeds` · `secrets` · `calls` · `fetches`). An `effects`
352
+ block must include every effect inference can see ([Errors](/docs/reference/errors)).
353
+ </Accordion>
354
+
355
+ <Accordion title="OKE1900 fx hidden from inference">
356
+ Name the parameter `fx` and keep chain aliases in the same function. Passing `fx` to an unresolved
357
+ callee, or returning, reassigning, or storing `fx` or a chain alias, fails extract. An `effects`
358
+ block may add keys. It must include every effect the walk can see.
346
359
  </Accordion>
347
360
 
348
361
  <Accordion title="OKE1240 orphan emit">
@@ -0,0 +1,192 @@
1
+ ---
2
+ title: "Idempotency"
3
+ description: "Send one Idempotency-Key so a retried mutating call runs once and a later retry replays the stored response."
4
+ icon: "KeyRound"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ A checkout that times out should not charge the card twice. Send one `Idempotency-Key` for that attempt. The server runs `do` once and replays the stored response when the same key comes back.
9
+
10
+ <Callout title="The one rule">
11
+ Mint one key per logical call and send it on every retry of that call. Two submits are two calls
12
+ unless your app passes the same key. Provider webhooks do not send this header — keep a unique
13
+ constraint on the provider's event id.
14
+ </Callout>
15
+
16
+ ## Quick start
17
+
18
+ <Steps>
19
+
20
+ <Step>
21
+ ### Mark the flow
22
+
23
+ `idempotency: "required"` rejects a call that omits the header. Leave it off and the header is
24
+ optional. `"required"` on a read, GET, stream, or live flow fails extract.
25
+
26
+ ```typescript
27
+ import { on, flow, http } from "okengine";
28
+
29
+ export const charge = on(
30
+ http.post("/charges", { in: ChargeIn, out: ChargeOut }),
31
+ flow("payments.charge", {
32
+ idempotency: "required",
33
+ do: async (input, fx) => {
34
+ return fx.store(db).insert(charges).values(input);
35
+ },
36
+ }),
37
+ );
38
+ ```
39
+
40
+ </Step>
41
+
42
+ <Step>
43
+ ### Call it
44
+
45
+ The client sends `Idempotency-Key` on every non-GET call. Pass a string to reuse a key across
46
+ your own retries, or `false` to send none.
47
+
48
+ ```typescript
49
+ await api.payments.charge({ amount: 1000 });
50
+ await api.payments.charge({ amount: 1000 }, { idempotencyKey: "checkout-attempt-9f3a2c" });
51
+ ```
52
+
53
+ </Step>
54
+
55
+ <Step>
56
+ ### Read the replay
57
+
58
+ The first response is the live result. A later call with the same key, principal, and payload
59
+ returns that stored response and sets `Idempotent-Replayed: true`. The client copies that onto
60
+ `meta.idempotentReplayed`.
61
+
62
+ </Step>
63
+
64
+ </Steps>
65
+
66
+ ## When the header is honored
67
+
68
+ All of these are required. Otherwise the header is ignored.
69
+
70
+ | Check | Honored when |
71
+ | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
72
+ | Entry | HTTP, and the method is not GET. RPC counts. `fx.call` has no request, so it does not. |
73
+ | Effects | Inferred effects include a write, emit, send, call, fetch, ask, or embed — or the flow uses `fx.raw`. Secrets alone do not qualify. |
74
+ | Shape | Not `stream`, and not a live SSE feed. |
75
+ | Option | Omitted or `{ ttl }` is `auto` (header optional). `"required"` or `{ required: true }` demands the header. `false` ignores it. |
76
+
77
+ `QUERY` is not GET. It qualifies when the rest of the row matches.
78
+
79
+ | Option | Meaning |
80
+ | ------------------------------- | --------------------------------------------------- |
81
+ | omitted | `auto` when the flow qualifies. Default TTL `24h`. |
82
+ | `"required"` | Missing header is `400 IdempotencyKeyMissing`. |
83
+ | `{ ttl: "1h" }` | `auto`, with that TTL. `ms`, `s`, `m`, `h`, or `d`. |
84
+ | `{ required: true, ttl: "1h" }` | Required, with that TTL. |
85
+ | `false` | Header ignored. |
86
+
87
+ An unparseable TTL, or `required` on a flow that cannot honor the header, fails extract.
88
+
89
+ The key is 16–255 printable ASCII characters (`0x20`–`0x7E`). Anything else is
90
+ `400 IdempotencyKeyInvalid`.
91
+
92
+ The scope is tenant, principal, and flow. The principal is `user:<id>`, else `apikey:<id>`, else
93
+ `anon`. The same key from two people is two records. The fingerprint is a hash of the flow name
94
+ and the validated input. The same key with a different payload is `422 IdempotencyKeyReused`.
95
+
96
+ ## What a second request sees
97
+
98
+ The claim happens after the gate and after input validation. A `401`, `403`, or validation
99
+ failure stores nothing, so the same key can still succeed.
100
+
101
+ | Row | Response |
102
+ | ----------------------------- | -------------------------------------------------------------------------------------- |
103
+ | No row, or the TTL has passed | `do` runs. The response is stored until the TTL. |
104
+ | Same payload, completed | Stored status, body, `content-type`, and `location`, plus `Idempotent-Replayed: true`. |
105
+ | Same payload, still running | `409 IdempotencyInProgress` and `Retry-After` (seconds until the lease, at least 1). |
106
+ | Different payload | `422 IdempotencyKeyReused`. |
107
+
108
+ A stream, an SSE body, or a raw `Response` that is not a buffered JSON envelope is not stored.
109
+ The live response is returned and the row is deleted.
110
+
111
+ A throw before any non-read effect deletes the row. The same key may run again. A throw after a
112
+ write, emit, send, call, fetch, ask, embed, or `fx.raw` stores the `500 InternalError` envelope.
113
+ A new key is required. A declared `fx.fail(...)` is stored as that failure, not as a 500.
114
+
115
+ ## Crashes
116
+
117
+ The in-progress lease is 30 seconds and is renewed while `do` runs. A disconnect does not cancel
118
+ a claimed `do`. The lease is what notices a crash.
119
+
120
+ | Flow | After the lease expires |
121
+ | ----------- | ----------------------------------------------------------------------------- |
122
+ | Not durable | The next request with the same key runs `do` again. That is at-least-once. |
123
+ | Durable | The next request resumes that journal run. Completed steps are not run again. |
124
+
125
+ A durable sleep stores the `204` and parks the journal run. A retry replays the `204`. The
126
+ scheduler continues the run.
127
+
128
+ ## Where the body lives
129
+
130
+ Rows sit in `oke_idempotency` on the journal driver: memory in tests, `.oke/idempotency.json`
131
+ beside the journal file, and Postgres when that journal driver is bound.
132
+
133
+ The stored body can contain personal data and stays until the TTL. Expired rows are deleted on
134
+ the next claim and by a periodic sweep.
135
+
136
+ The Console flow contract shows an idempotency pill when the mode is `auto` or `required`.
137
+
138
+ ## Troubleshooting
139
+
140
+ <Accordions>
141
+ <Accordion title="400 IdempotencyKeyMissing">
142
+
143
+ The catalog message is `This call requires an Idempotency-Key header.` The flow is `required`
144
+ and this request did not send the header. Send a key, or drop `"required"` if the header should
145
+ stay optional.
146
+
147
+ </Accordion>
148
+ <Accordion title="400 IdempotencyKeyInvalid">
149
+
150
+ The catalog message is `Idempotency-Key must be 16–255 printable ASCII characters.` Lengthen the
151
+ key or drop characters outside printable ASCII.
152
+
153
+ </Accordion>
154
+ <Accordion title="422 IdempotencyKeyReused">
155
+
156
+ The catalog message is `This Idempotency-Key was already used with a different request.` The
157
+ payload changed. Mint a new key for the new payload.
158
+
159
+ </Accordion>
160
+ <Accordion title="409 IdempotencyInProgress">
161
+
162
+ The catalog message is `This Idempotency-Key is still running. Retry after the given delay.`
163
+ Wait for `Retry-After`, then send the same key. The client does this when `retry` is configured.
164
+
165
+ </Accordion>
166
+ </Accordions>
167
+
168
+ ## Learn more
169
+
170
+ - [Calling](/docs/client/calling) — `idempotencyKey` and the retry rule
171
+ - [Errors](/docs/reference/errors) — the four idempotency codes and their statuses
172
+ - [The Architecture](/docs/understand/the-architecture) — a retry of one call runs once
173
+
174
+ ## Next
175
+
176
+ <Cards>
177
+ <Card
178
+ title="Calling"
179
+ description="Client retry and Idempotency-Key."
180
+ href="/docs/client/calling"
181
+ />
182
+ <Card
183
+ title="Errors"
184
+ description="Statuses for the four idempotency codes."
185
+ href="/docs/reference/errors"
186
+ />
187
+ <Card
188
+ title="The Architecture"
189
+ description="One retry runs once. Two submits are two calls."
190
+ href="/docs/understand/the-architecture"
191
+ />
192
+ </Cards>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "Reference"
3
3
  description: "Lookup pages — CLI, config, fx, errors, security, and plugins."
4
- icon: "BookMarked"
4
+ icon: "BookBookmark"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
@@ -23,6 +23,11 @@ Dense tables and command lists. Reach for these when you already know what you a
23
23
  />
24
24
  <Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
25
25
  <Card title="Errors" description="OKE codes, denials, fixes." href="/docs/reference/errors" />
26
+ <Card
27
+ title="Idempotency"
28
+ description="One Idempotency-Key, one execution."
29
+ href="/docs/reference/idempotency"
30
+ />
26
31
  <Card
27
32
  title="Security"
28
33
  description="Host, Origin, planes, MCP posture."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "Reference",
3
- "icon": "BookMarked",
3
+ "icon": "BookBookmark",
4
4
  "pages": [
5
5
  "index",
6
6
  "cli",
@@ -8,6 +8,7 @@
8
8
  "environment-variables",
9
9
  "fx",
10
10
  "errors",
11
+ "idempotency",
11
12
  "security",
12
13
  "i18n",
13
14
  "plugins",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "Understand",
3
3
  "icon": "Compass",
4
- "pages": ["the-architecture", "try-it"]
4
+ "pages": ["the-architecture", "routing", "try-it"]
5
5
  }
@@ -588,28 +588,29 @@ plus a handwritten `http.get("/links")`.
588
588
 
589
589
  ## Learn more
590
590
 
591
+ - [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
592
+ - [Try it](/docs/understand/try-it) — scaffold, run, and hit a stamped path
591
593
  - [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
592
594
  - [Client](/docs/client/calling) — `api.links.get`, REST vs RPC, `$routes`
593
595
  - [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
594
- - [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
595
596
  - [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger
596
597
 
597
598
  ## Next
598
599
 
599
600
  <Cards>
600
601
  <Card
601
- title="HTTP"
602
- description="REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE on Flow."
603
- href="/docs/elements/flow/http"
602
+ title="The Architecture"
603
+ description="One law, eight elements, derived Manifest — where routes come from."
604
+ href="/docs/understand/the-architecture"
604
605
  />
605
606
  <Card
606
- title="Client"
607
- description="Typed caller — createClient, envelopes, REST from $routes."
608
- href="/docs/client/calling"
607
+ title="Try it"
608
+ description="Scaffold shorter, run oke dev, and call a stamped path."
609
+ href="/docs/understand/try-it"
609
610
  />
610
611
  <Card
611
- title="Consumers"
612
- description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
613
- href="/docs/elements/flow/consumers"
612
+ title="HTTP"
613
+ description="REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE on Flow."
614
+ href="/docs/elements/flow/http"
614
615
  />
615
616
  </Cards>
@@ -27,11 +27,11 @@ app.post("/signup", async (req, res) => {
27
27
 
28
28
  It works. It ships. Then a traffic spike, a silent failure, a compliance question, and a double-submit turn those four lines into six files — endpoint, queue, worker, Redis connection, mail client, audit table — that never agreed with each other about anything.
29
29
 
30
- **A payment webhook.** A provider confirms a charge. Mark the order paid. Simple — until the provider retries the same webhook twice during a network hiccup, and "mark the order paid" needs to somehow know it already ran.
30
+ **A payment webhook.** A provider confirms a charge. Mark the order paid. The provider retries the same event during a hiccup. That attempt is a second run, visible in `fx.runs`. The fix is one line: a unique constraint or upsert on the provider's event id.
31
31
 
32
32
  **A nightly report.** Summarize yesterday's activity and email it to managers. Trivial — until a manager's access gets revoked at 11:58pm and the report that runs at midnight has no idea the permission it checked when the feature was built isn't the permission that holds right now.
33
33
 
34
- Three teams. Three domains. Nobody on any of them talked to the other two. And all three land on the identical fork: **something has to happen later, exactly once, provably — and nothing in the original four lines said what "provably" would end up costing.**
34
+ Three teams. Three domains. Nobody on any of them talked to the other two. And all three land on the identical fork: **something has to happen later, and the original four lines never said where the retry, the receipt, or the duplicate would be visible.** The door makes that visible. The fix is one line.
35
35
 
36
36
  ### Follow one all the way through
37
37
 
@@ -49,7 +49,7 @@ The endpoint is fast again. It's also no longer one system — it's an endpoint,
49
49
 
50
50
  <SixSystemsDrift />
51
51
 
52
- From here the same pattern repeats on a longer clock. A support ticket reveals a job failed silently — nobody had configured retries, so you add them, and now a specific number (3? 5? with what backoff?) lives in a file that nobody will remember the reasoning for in six weeks. Compliance asks for proof of every email sent — you add a table written to from inside the worker, and now that worker has two jobs instead of one, quietly capable of disagreeing with itself if the second write fails. Someone double-clicks submit — two jobs enqueue, two emails send, and idempotency becomes a fact that has to live in two systems that were never introduced to each other.
52
+ From here the same pattern repeats on a longer clock. A support ticket reveals a job failed silently — nobody had configured retries, so you add them, and now a specific number (3? 5? with what backoff?) lives in a file that nobody will remember the reasoning for in six weeks. Compliance asks for proof of every email sent — you add a table written to from inside the worker, and now that worker has two jobs instead of one, quietly capable of disagreeing with itself if the second write fails. Someone double-clicks submit — two jobs enqueue, two emails send. The duplicate is two runs. The fix is one line, a unique constraint or upsert, not a second protocol shared by a queue and a database.
53
53
 
54
54
  None of these were mistakes. Each one was the correct call, made by a competent engineer, in direct response to something that actually happened.
55
55
 
@@ -74,7 +74,7 @@ OKE removes the disagreement by removing the choice. It's one rule, in two parts
74
74
 
75
75
  **First: every trigger reduces to the same shape.** An HTTP request, a scheduled tick, a queue message, a database change — whatever wakes the code up, what follows has one identical anatomy: `on(Trigger) → Effects`. Not four systems that happen to look similar. One system, with four ways to wake it up.
76
76
 
77
- **Second: every effect passes through one door.** Nothing is allowed to touch a database, send an email, check a permission, or read the clock on its own — all of it goes through a single surface. Not because that's tidier. Because it's the only way retries, auditing, idempotency, and permission checks stop being infrastructure every team reinvents at the exact moment they get burned by not having it.
77
+ **Second: every effect passes through one door.** Nothing is allowed to touch a database, send an email, check a permission, or read the clock on its own — all of it goes through a single surface. The Manifest lists every effect. Least privilege is enforced at runtime. Every run is recorded in `fx.runs`. Durability is one line in a known place — `signal.once` retries, or `durable` plus `fx.step` — not a new system.
78
78
 
79
79
  <FlowShape />
80
80
 
@@ -193,7 +193,9 @@ do: async (input, fx) => {
193
193
  };
194
194
  ```
195
195
 
196
- This one rule is what made the month-8 drift above avoidable: if `fx` is the only door, retries, auditing, and idempotency stop being separate systems teams build by hand, and become properties of the one boundary everything already passes through.
196
+ This one rule is what made the month-8 drift visible: every effect is in the Manifest, every run is in `fx.runs`, and a duplicate is a second run you can see.
197
+ A retried HTTP call can send one `Idempotency-Key`, so that retry runs once.
198
+ A provider event still needs a unique constraint, because the provider does not send that header.
197
199
 
198
200
  ### Putting the five pieces together
199
201
 
@@ -223,14 +225,14 @@ export const signup = on(
223
225
 
224
226
  Nothing about the code above looks more complicated than the four lines that started the drift — because it isn't. The difference only shows up when the same pressure from that timeline hits it. Every fork was really the same question — _is this safe to retry, safe to audit, safe to run twice?_ — and now it has one home:
225
227
 
226
- | Then: a new system per incident | Now: one door, fixed answer |
227
- | -------------------------------------------------------------------- | ----------------------------------------------------------------------- |
228
- | **Week 2 spike** — hand-build a queue + worker to run the send later | Running later, safely, is asked of the trigger or the effect itself |
229
- | **Month 2 silence** — a retry count in a worker nobody remembers | Retries are a property of the `fx` boundary every effect passes through |
230
- | **Month 4 audit** — a `sent_emails` table written by hand from a job | What was sent is already known — nothing sends outside `fx` |
231
- | **Month 6 double-submit** — dedup split across a queue and a DB | One call through one door — no second path for a duplicate |
228
+ | Then: a new system per incident | Now: the door makes it visible, and the fix is one line |
229
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
230
+ | **Week 2 spike** — hand-build a queue + worker to run the send later | The send is a Signal consumer. The Manifest lists it. The run is in `fx.runs`. |
231
+ | **Month 2 silence** — a retry count in a worker nobody remembers | The retry count lives on `signal.once` (default 3) or on `durable` + `fx.step`, which skips a step already journaled. A failed HTTP request is not retried by `fx`. |
232
+ | **Month 4 audit** — a `sent_emails` table written by hand from a job | The send is an effect in the Manifest and a run in `fx.runs`. Channel records a receipt. The default receipt ledger is process-local memory. |
233
+ | **Month 6 double-submit** — dedup split across a queue and a DB | A retry of one call runs once when it sends `Idempotency-Key`. Two submits are two calls unless the app passes the same key. Provider webhooks still need a unique constraint on the provider's event id, because providers do not send that header. `{ key }` serializes `once` per key. It does not deduplicate. |
232
234
 
233
- None of that required new code beyond what's above. It required the four lines to already be the kind of thing where those questions have a fixed answer, instead of a new one invented per team, per incident.
235
+ The door makes each of those problems visible, and the fix is one line in a known place. The signup sample is safe to run twice once the user row has a unique constraint or an upsert. The second attempt is a second run in `fx.runs`.
234
236
 
235
237
  ### Five kinds of trigger
236
238