okengine 0.23.0 → 0.23.2

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 (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -16,7 +16,7 @@ Two genuinely distinct things share the protocol in OKE:
16
16
 
17
17
  OKE does not create separate security models for users, operators, and agents. They enter the same execution model through different triggers and planes.
18
18
 
19
- The server on **6535** speaks JSON-RPC over HTTP (MCP protocol `2024-11-05`), requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead.
19
+ The server on **6535** speaks JSON-RPC over HTTP. It implements `initialize`, `ping`, `tools/list`, and `tools/call`, and advertises protocol `2024-11-05`. A client that sends another `protocolVersion` is rejected. An omitted version gets `2024-11-05`. It requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead. The outbound client that calls external MCP servers probes `2026-07-28` (`_meta` on each request) and falls back to a `2024-11-05` `initialize`. That probe is not what this server advertises.
20
20
 
21
21
  <Callout title="The one rule">
22
22
  MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
@@ -52,16 +52,16 @@ Two actions are write-class, and each call needs a **fresh, single-use** confirm
52
52
  <Steps>
53
53
 
54
54
  <Step>
55
- ### Request a confirmation token
55
+ ### Ask for confirmation
56
56
 
57
- `oke.action.confirm` with the target `tool`, the exact `args`, and a human `reason` — it returns a single-use token.
57
+ The write returns an opaque `confirmationId`. It does not return a token. Confirm from a **different auth session** (`claims.sid`) than the session that will invoke. The same principal is allowed. A confirmation issued on another process is unknown.
58
58
 
59
59
  </Step>
60
60
 
61
61
  <Step>
62
62
  ### Call the write tool
63
63
 
64
- Pass the token as `confirmToken` plus the phrase `CONFIRM` in `confirmation`. Token, phrase, args, and principal must all match what was confirmed.
64
+ `oke.action.confirm(confirmationId, reason)` returns a single-use token. Pass it as `confirmToken` plus the phrase `CONFIRM`. The invoking session must be the one that asked, and it must not be the session that confirmed.
65
65
 
66
66
  </Step>
67
67
 
@@ -247,7 +247,7 @@ overrides). Manifest stays free of copy.
247
247
  | Method | Capability / `sends` | Meaning |
248
248
  | -------------------------- | -------------------- | ------------------------------------------- |
249
249
  | `fx.send(template, opts?)` | template name | Deliver through the medium’s driver chain |
250
- | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP (Taqnyat Verify) |
250
+ | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP. |
251
251
  | `fx.verifyOtp(opts)` | `"sms-otp"` | Check a provider OTP code |
252
252
  | `fx.deliverOtp(opts)` | `"auth-otp"` | App-owned OTP across email / SMS / WhatsApp |
253
253
 
@@ -351,9 +351,9 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
351
351
  </Accordion>
352
352
 
353
353
  <Accordion title="Process-local suppression / receipts warning">
354
- Boot warns: `Channel suppression/consent/receipts default to process-local memory…` Opt-out on one
355
- instance is invisible to others until you inject shared stores or run a single Channel consumer.
356
- See [Receipts](/docs/elements/channel/receipts).
354
+ Boot warns: `Channel suppression/consent/receipts default to process-local memory…` That is
355
+ `test`, or a boot with no `DATABASE_URL`. Otherwise consent, suppression, and receipts are the
356
+ Postgres ledger. See [Receipts](/docs/elements/channel/receipts).
357
357
  </Accordion>
358
358
 
359
359
  </Accordions>
@@ -1,21 +1,23 @@
1
1
  ---
2
2
  title: "Receipts"
3
- description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression — process-local by default."
3
+ description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression."
4
4
  icon: "ReceiptText"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
8
  Every Channel send records a **receipt** — success (`sent` / `fallback`), suppression, or a
9
9
  classified failure. Provider bounces and complaints update that ledger through normalized
10
- outcomes. There is no `oke_receipts` SQL table and no `fx.channel.getReceipt` helper.
10
+ outcomes. There is no `fx.channel.getReceipt` helper. Outside `test`, boot stores consent,
11
+ suppression, and receipts in Postgres (`oke_channel_consent`, `oke_channel_bounce`,
12
+ `oke_channel_receipt`) when `DATABASE_URL` is set. `test` stays in memory.
11
13
 
12
14
  For operators watching deliverability — Console projects the ledger; Flows only see send
13
15
  results via `fx.send`’s `{ ok: true }` gate.
14
16
 
15
17
  <Callout title="The one rule">
16
- Consent and prior hard bounces suppress **before** any driver runs. Default suppression / consent
17
- / receipts stores are **process-local memory** — inject shared stores for multi-instance, or run a
18
- single Channel consumer, until a durable driver ships.
18
+ Consent and prior hard bounces suppress **before** any driver runs. With `DATABASE_URL` outside
19
+ `test`, those rows are shared across instances. `test`, and any boot without a SQL URL, keep
20
+ process-local memory and still warn.
19
21
  </Callout>
20
22
 
21
23
  ## Smallest Example
@@ -7,7 +7,8 @@ source: "docs/spec/unified-theory.md"
7
7
 
8
8
  SMS (`channel.sms`) delivers short text and provider-managed one-time codes. Pin an SMS driver
9
9
  (no default in any env), declare templates for app-owned messages, or call `fx.sendOtp` when the
10
- vendor owns the code.
10
+ vendor owns the code. `fx.sendOtp` and `fx.verifyOtp` stay on `fx`. The `mena` plugin opens the
11
+ same providers when you want that packaging.
11
12
 
12
13
  For developers verifying phones — choose raw Channel OTP vs the [`otp`](/docs/plugins/otp) plugin.
13
14
 
@@ -220,17 +220,17 @@ Second argument to `flow(name, options)` — or the only argument to a nameless
220
220
  Invoke contracts (`in` / `out` / `errors` / `breaking`) belong on the exposure — see
221
221
  [Contracts](#contracts) below.
222
222
 
223
- | Option | Type | Default | Meaning |
224
- | -------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- |
225
- | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
- | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
- | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
- | `cache` | `boolean \| string` | omitted (auto) | Read-only Flows cache automatically; `false` opts out; `"30s"` adds TTL |
229
- | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
- | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
- | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
- | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
- | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
223
+ | Option | Type | Default | Meaning |
224
+ | -------------- | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
225
+ | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
+ | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
+ | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
+ | `cache` | `boolean \| string` | omitted (on when pure) | Pure store reads cache; `false` disables; `"30s"` sets a TTL; app `cache: { auto: false }` turns the default off |
229
+ | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
+ | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
+ | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
+ | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
+ | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
234
234
 
235
235
  **Consequence:** `durable: true` disables automatic read-cache for that Flow.
236
236
 
@@ -438,12 +438,12 @@ durable Flow, or split with `fx.emit`. See [Workflows](/docs/elements/flow/workf
438
438
 
439
439
  <Tab value="Cache">
440
440
 
441
- Read-only Flows (Store `reads`, no `writes`, no `asks`, not durable) cache automatically.
442
- No `cache:` option required. Mutations and `durable: true` stay uncached:
441
+ A pure store-read Flow caches on its own. A send, emit, fetch, vault read, ask,
442
+ decide, or `fx.call` is never cached. The key includes the flow, the input, the
443
+ user, the tenant, the locale, and the caller's scopes and membership roles.
443
444
 
444
445
  ```typescript
445
446
  flow("catalog.get", {
446
- cache: "30s",
447
447
  do: async ({ id }, fx) => {
448
448
  const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
449
449
  return row;
@@ -451,7 +451,10 @@ flow("catalog.get", {
451
451
  });
452
452
  ```
453
453
 
454
- `cache: false` opts out. A duration string adds TTL on top of write invalidation.
454
+ `cache: false` always disables. A duration string adds TTL on top of write
455
+ invalidation. `oke({ cache: { auto: false } })` turns the default off; a Flow
456
+ can still set `cache: true` or `"30s"`. Cross-instance invalidation and writes
457
+ that skip `fx` are not covered.
455
458
 
456
459
  </Tab>
457
460
 
@@ -570,9 +573,10 @@ Default is `true` once `gate.auth.tenant` is on.
570
573
  </Accordion>
571
574
 
572
575
  <Accordion title="Read-only Flow never cache-hits">
573
- Auto-cache needs Store `reads`, no `writes`, no `asks`, and `durable` off. Empty effect sets stay
574
- uncached. Opt in with a duration (`cache: "30s"`) only after a real read is inferred — or pass
575
- `cache: false` to disable.
576
+ A pure Store read caches unless the Flow sets `cache: false` or the app sets
577
+ `cache: { auto: false }`. A send, emit, fetch, secret, ask, decide, call, or write
578
+ is never cached, and `durable: true` is never cached. The key includes tenant,
579
+ locale, scopes, and roles, so a hit for one caller is a miss for another.
576
580
  </Accordion>
577
581
 
578
582
  <Accordion title='cross-plane call: user flow "…" calls operator flow "…"'>
@@ -12,8 +12,10 @@ For developers invalidating caches or syncing in-process state — declare the S
12
12
  more `on(signal, flow)` subscribers, emit with `fx.emit`.
13
13
 
14
14
  <Callout title="The one rule">
15
- Broadcast is ephemeral. If a subscriber is offline or restarts during the emit, it does not
16
- receive past events. Use [`live`](/docs/elements/signal/live) when clients need replay. Use
15
+ Broadcast is ephemeral. Each running instance handles a message once. If a subscriber is offline
16
+ during the emit, it does not receive that event. Postgres delivery is best-effort within `lagMs`
17
+ (default 30 seconds): a transaction that commits later than that can be missed. Use
18
+ [`live`](/docs/elements/signal/live) when clients need replay. Use
17
19
  [`once`](/docs/elements/signal/once) when exactly one worker must claim the job.
18
20
  </Callout>
19
21
 
@@ -331,11 +331,11 @@ Omit `key` for a pure competing pool with no ordering.
331
331
  Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
332
332
  `drivers.signal`):
333
333
 
334
- | Env | Default | Boot today |
335
- | ------ | -------- | ----------------------------------------------------------------------- |
336
- | `dev` | `redis` | Emit relays to Redis; consume / live / drain use a process-local outbox |
337
- | `test` | `memory` | In-process bus |
338
- | `prod` | `redis` | Same redis honesty as `dev` |
334
+ | Env | Default | Boot today |
335
+ | ------ | -------- | -------------------------------------------------------------------------------- |
336
+ | `dev` | `redis` | Emit relays to Redis. `once` consume is a consumer group (`XREADGROUP` / `XACK`) |
337
+ | `test` | `memory` | In-process bus |
338
+ | `prod` | `redis` | Same redis path as `dev` |
339
339
 
340
340
  ```typescript title="oke.config.ts"
341
341
  export default defineConfig({
@@ -345,8 +345,10 @@ export default defineConfig({
345
345
  });
346
346
  ```
347
347
 
348
- `postgres` and `nats` fail loud at boot until a native bind ships — never silently fall back to
349
- `memory`. Prefer `memory` for tests; pin `redis` when Compose provides Redis.
348
+ `postgres` binds Bun.SQL and the scheduler polls `drain` (`FOR UPDATE SKIP LOCKED`). Bun.SQL has no
349
+ `LISTEN` / `NOTIFY`. `nats` still fails loud at boot — never silently fall back to `memory`. Prefer
350
+ `memory` for tests; pin `redis` when Compose provides Redis. Live history stays on the emitting
351
+ process; `once` is the competing path.
350
352
 
351
353
  ## Troubleshooting
352
354
 
@@ -383,15 +385,14 @@ export default defineConfig({
383
385
  consumers](#competing-consumers-once-vs-broadcast).
384
386
  </Accordion>
385
387
 
386
- <Accordion title='oke boot: signal driver "postgres" / "nats"'>
387
- Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
388
- custom `elements.signal` runtime.
388
+ <Accordion title='oke boot: signal driver "postgres" needs DATABASE_URL'>
389
+ `drivers.signal` is `postgres` and neither `DATABASE_URL` nor `OKE_STORE_SQL_URL` is set. Boot
390
+ polls `drain` on that connection. There is no `LISTEN` / `NOTIFY` requirement.
389
391
  </Accordion>
390
392
 
391
- <Accordion title="redis Signal — process-local consume">
392
- Boot warns that redis emit relays to Redis while consume / live / drain stay process-local.
393
- Multi-instance competing consumers need a shared durable outbox path (or a single consumer
394
- instance) until Redis Streams consume ships.
393
+ <Accordion title='oke boot: signal driver "nats"'>
394
+ `nats` has no production client yet. Use `"memory"`, `"redis"`, or `"postgres"`, or inject a
395
+ custom `elements.signal` runtime.
395
396
  </Accordion>
396
397
 
397
398
  </Accordions>
@@ -22,9 +22,9 @@ app-owned delivery across the channels you declare.
22
22
  </Callout>
23
23
 
24
24
  <Callout type="info" title="fx.sendOtp vs otp()">
25
- `fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
26
- build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
27
- it and call the raw methods in your own flow without losing the provider connection.
25
+ `fx.sendOtp` / `fx.verifyOtp` / `fx.deliverOtp` are supported Channel methods. Direct use means
26
+ you build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path.
27
+ The `mena` plugin (`menaChannels`) is an additional way to open the same providers.
28
28
  </Callout>
29
29
 
30
30
  ## Quick start
@@ -237,6 +237,22 @@ Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated t
237
237
  | `console` | `6533` | Console |
238
238
  | `mcp` | `6535` | MCP |
239
239
 
240
+ ## App options
241
+
242
+ These sit on `oke({ ... })`, not in `oke.config.ts`.
243
+
244
+ | Option | Default | Meaning |
245
+ | -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
246
+ | `codeVersion` | unset | Opt-in durable stamp `app:<version>`. Unset checks nothing. A stored `0.23.1`-style stamp is compatible and restamped. A different `app:` stamp fails the run with OKE1076. |
247
+ | `maxRequestBodySize` | `1048576` | Body cap in bytes. A route `maxBodySize` overrides it. Bun's listen backstop uses the same number. |
248
+ | `cache.maxEntries` | `10000` | Auto-cache entry cap. |
249
+ | `cache.defaultTtlMs` | `60000` | Auto-cache TTL when the flow does not set `cache: "5m"`. |
250
+ | `cache.auto` | on | `false` turns automatic caching off. A flow can still opt in. |
251
+
252
+ Postgres broadcast and live poll with `lagMs`, default `30000`. That window is best-effort, not a gap-free cursor.
253
+
254
+ A write drops the auto-cache on every instance. With Redis configured, the notice is published on `oke:cache:invalidate`. Otherwise a Postgres app polls `oke_cache_invalidations`. One process does not broadcast.
255
+
240
256
  ## console
241
257
 
242
258
  | Option | Type | Default | Meaning |
@@ -117,25 +117,37 @@ The typed client always includes built-in codes. A declared key still wins.
117
117
  A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON envelope
118
118
  at **404**. Clients distinguish by envelope, not status. Domain codes stay **400**.
119
119
 
120
- | Code | Status | Helper |
121
- | ------------------------------------------------ | ----------- | ------------------------------------------------------ |
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 |
127
- | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
128
- | `Forbidden` | `403` | `forbidden` · also a gate denial |
129
- | `NotFound` | `404` | `notFound` |
130
- | `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
131
- | `UnsupportedMediaType` | `415` | none — QUERY `Content-Type` is not `application/json` |
132
- | `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
133
- | `InvalidQuery` | `400` | none — QUERY missing `Content-Type` or body isn't JSON |
134
- | `AuthFailed` | `400` | none — credentials / policy, not “no session” |
135
- | `DatabaseError` | by `reason` | `database` |
136
- | `ServiceUnavailable` | `503` | `serviceUnavailable` |
137
- | `InternalError` | `500` | `internal` — never copies a thrown `message` |
138
- | Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
120
+ | Code | Status | Helper |
121
+ | ------------------------------------------------ | ----------- | ---------------------------------------------------------------------------- |
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 |
127
+ | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
128
+ | `Forbidden` | `403` | `forbidden` · also a gate denial |
129
+ | `NotFound` | `404` | `notFound` |
130
+ | `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
131
+ | `UnsupportedMediaType` | `415` | none — QUERY, or a JSON-shaped body that is not `application/json` / `+json` |
132
+ | `PayloadTooLarge` | `413` | none — body exceeds `maxRequestBodySize` or the route `maxBodySize` |
133
+ | `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
134
+ | `InvalidQuery` | `400` | none — QUERY or JSON body that does not parse (`malformed_body`) |
135
+ | `AuthFailed` | `400` | none — credentials / policy, not “no session” |
136
+ | `DatabaseError` | by `reason` | `database` |
137
+ | `ServiceUnavailable` | `503` | `serviceUnavailable` |
138
+ | `InternalError` | `500` | `internal` — never copies a thrown `message` |
139
+ | Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
140
+
141
+ `UnsupportedMediaType` (415) is this rule, for a non-empty body. Let `mt` be the media type before `;`:
142
+
143
+ 1. `application/json` or `application/*+json` is parsed. A parse failure is 400 `InvalidQuery` with reason `malformed_body`.
144
+ 2. A route with `jsonContentType: "any"` tries JSON and otherwise keeps the raw string.
145
+ 3. `text/plain`, form-urlencoded, multipart, or a missing type, when the trimmed body starts with `{` or `[` and parses as JSON, is 415.
146
+ 4. Anything else is the raw string. `curl -d` needs `-H 'content-type: application/json'`.
147
+
148
+ `OKE1074` means the durable run lost its lease. The step result is not written and compensation does not run. The caller sees a busy lease, not a new failure of the work.
149
+
150
+ `OKE1076` means `codeVersion` was set and the stored stamp is a different `app:` value. Leave the old value pinned until sleeping runs drain, then change it. Unset `codeVersion` checks nothing. A stored stamp from 0.23.1 (no `app:` prefix) is restamped, not failed.
139
151
 
140
152
  ### Client chrome
141
153
 
@@ -158,7 +158,13 @@ on(
158
158
  | `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
159
159
 
160
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`.
161
+ Read `fx.principal` for audit — it does not copy into `fx.auth`. Passing a Flow handle types
162
+ `input` and the result. A string name stays `Promise<unknown>`.
163
+
164
+ `fx.store(db).transaction(async (tx) => { … })` pins one SQL connection. `fx.emit` inside the
165
+ callback stages on the signal outbox and publishes after commit. A throw rolls that batch back.
166
+ `fx.store(db).run(builder)` runs a Drizzle builder through `toSQL()`. Joins stay on that builder;
167
+ the hand-rolled chain does not grow a join API. Inference still records every joined table.
162
168
 
163
169
  <Callout>
164
170
  Gates never consult `fx.principal`. `return fx.fail(...)` is a `FlowFailure`; an unhandled `throw`
@@ -281,9 +287,11 @@ Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400
281
287
 
282
288
  ## Cache
283
289
 
284
- Read-only flows cache automatically from inferred or ledgered `reads` — no
285
- `fx.cache` call and no `cache:` default on the flow. Writes invalidate those
286
- keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.
290
+ Pure store-read flows cache by default. A send, emit, fetch, vault read, ask,
291
+ decide, or `fx.call` is never cached. The key includes tenant, locale, scopes,
292
+ and membership roles. Writes through `fx` invalidate those keys. `cache: false`
293
+ always disables. `oke({ cache: { auto: false } })` turns the default off.
294
+ Cross-instance invalidation and out-of-band writes are not covered.
287
295
 
288
296
  `fx.cache` is the manual (tier-3) surface:
289
297
 
@@ -359,3 +359,20 @@ export function tenantScopesForMember(
359
359
  const role = store.roles.get(roleKey(tenantId, member.role));
360
360
  return role?.scopes ?? [];
361
361
  }
362
+
363
+ /**
364
+ * Membership role name for the tier-1 cache key.
365
+ *
366
+ * Same member lookup as {@link tenantScopesForMember}. Not an authorization input.
367
+ *
368
+ * @param store - Tenant store
369
+ * @param tenantId - Tenant id
370
+ * @param userId - User id
371
+ */
372
+ export function tenantRoleForMember(
373
+ store: TenantStore,
374
+ tenantId: string,
375
+ userId: string,
376
+ ): string | null {
377
+ return getMember(store, tenantId, userId)?.role ?? null;
378
+ }
@@ -7,7 +7,7 @@ import { mkdir, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import type { Manifest } from "../manifest/types.ts";
9
9
  import { parseManifest } from "../manifest/validate.ts";
10
- import { runDoctorDiff } from "./doctor-diff.ts";
10
+ import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
11
11
 
12
12
  const baseUrl = new URL("../manifest/fixtures/base.manifest.json", import.meta.url);
13
13
 
@@ -84,4 +84,32 @@ describe("oke doctor --diff", () => {
84
84
  await rm(dir, { recursive: true, force: true });
85
85
  }
86
86
  });
87
+
88
+ test("comment lists contract, permission, and effect changes", () => {
89
+ const body = formatManifestDiffComment([
90
+ {
91
+ path: "flows.a",
92
+ category: "contract-breaking",
93
+ kind: "changed",
94
+ summary: "out schema changed",
95
+ },
96
+ {
97
+ path: "flows.b",
98
+ category: "permission-widening",
99
+ kind: "changed",
100
+ summary: "gate removed",
101
+ },
102
+ {
103
+ path: "flows.c",
104
+ category: "effect-widening",
105
+ kind: "changed",
106
+ summary: "writes grew",
107
+ },
108
+ ]);
109
+ expect(body).toContain("Contract breaks");
110
+ expect(body).toContain("Permission widening");
111
+ expect(body).toContain("Effect widening");
112
+ expect(body).toContain("out schema changed");
113
+ expect(body).toContain("widening are comments only");
114
+ });
87
115
  });
@@ -55,6 +55,41 @@ export interface DoctorDiffResult {
55
55
  readonly allChanges: readonly ManifestChange[];
56
56
  }
57
57
 
58
+ const COMMENT_CATEGORIES = ["contract-breaking", "permission-widening", "effect-widening"] as const;
59
+
60
+ const CATEGORY_LABEL: Record<(typeof COMMENT_CATEGORIES)[number], string> = {
61
+ "contract-breaking": "Contract breaks",
62
+ "permission-widening": "Permission widening",
63
+ "effect-widening": "Effect widening",
64
+ };
65
+
66
+ /**
67
+ * Plain-language pull-request comment for the three behavioural categories.
68
+ * Widening is informational. Contract breaks are the lines that fail CI
69
+ * when they are undeclared.
70
+ *
71
+ * @param changes - {@link diffManifest} changes
72
+ */
73
+ export function formatManifestDiffComment(changes: readonly ManifestChange[]): string {
74
+ const lines = ["## Manifest diff", ""];
75
+ for (const category of COMMENT_CATEGORIES) {
76
+ const rows = changes.filter((change) => change.category === category);
77
+ lines.push(`### ${CATEGORY_LABEL[category]}`);
78
+ if (rows.length === 0) {
79
+ lines.push("None.");
80
+ } else {
81
+ for (const row of rows) {
82
+ lines.push(`- \`${row.path}\` — ${row.summary}`);
83
+ }
84
+ }
85
+ lines.push("");
86
+ }
87
+ lines.push(
88
+ "Undeclared contract breaks fail the check. Permission and effect widening are comments only. Acknowledge an intentional break with `breaking: true` on the HTTP, call, MCP, or resource contract.",
89
+ );
90
+ return lines.join("\n");
91
+ }
92
+
58
93
  /**
59
94
  * Diff two manifests and fail on undeclared contract breaks.
60
95
  *
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pull-request manifest diff.
3
+ *
4
+ * Writes the comment body, then exits non-zero only for undeclared
5
+ * contract breaks. Widening changes stay in the comment.
6
+ *
7
+ * bun src/cli/manifest-pr-diff.ts comment.md
8
+ */
9
+
10
+ import { writeFileSync } from "node:fs";
11
+ import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
12
+
13
+ const out = process.argv[2] ?? "manifest-diff.md";
14
+ const result = await runDoctorDiff({ write: () => {} });
15
+ writeFileSync(out, formatManifestDiffComment(result.allChanges));
16
+ process.exit(result.code);
@@ -97,6 +97,7 @@ describe("compileAot", () => {
97
97
  const bad = await compiled.parseValidate(
98
98
  new Request("http://localhost/bookings", {
99
99
  method: "POST",
100
+ headers: { "content-type": "application/json" },
100
101
  body: JSON.stringify({ flightId: "SK1", seats: 0 }),
101
102
  }),
102
103
  {},
@@ -235,9 +236,9 @@ describe("AoT throughput ≥ 1.5× dynamic", () => {
235
236
  }
236
237
 
237
238
  const iterations = 4_000;
238
- // Best of trials — single wall-clock ratio is noisy under full-suite load.
239
+ // Best of trials — a single wall-clock ratio moves under load.
239
240
  let best = 0;
240
- for (let trial = 0; trial < 3; trial++) {
241
+ for (let trial = 0; trial < 8; trial++) {
241
242
  const t0 = performance.now();
242
243
  for (let i = 0; i < iterations; i++) {
243
244
  await aot.parseValidate(makeReq(), {});
@@ -6,9 +6,11 @@
6
6
  * {@link compileDynamic} / `aot: false` on edge runtimes that ban `eval`.
7
7
  */
8
8
 
9
+ import { fail } from "../kernel/errors.ts";
9
10
  import { validate, type SchemaInput } from "../validation/standard-schema.ts";
10
11
  import {
11
12
  assembleInput,
13
+ HttpBodyRejected,
12
14
  parseBody,
13
15
  parseCookie,
14
16
  parseHeaders,
@@ -33,6 +35,8 @@ export interface CompileRouteOptions {
33
35
  readonly hooks?: ReadonlyArray<(...args: never[]) => unknown>;
34
36
  /** Input schema (Standard Schema when present). */
35
37
  readonly schema?: SchemaInput | undefined;
38
+ /** Body cap and JSON content-type policy. */
39
+ readonly body?: import("./http-parse.ts").ParseBodyOptions;
36
40
  }
37
41
 
38
42
  /** Bundle returned by the compilers. */
@@ -55,6 +59,16 @@ interface AotHelpers {
55
59
  parseCookie: typeof parseCookie;
56
60
  assembleInput: typeof assembleInput;
57
61
  validate: typeof validate;
62
+ readonly body?: import("./http-parse.ts").ParseBodyOptions;
63
+ readBody: (
64
+ request: Request,
65
+ ) => Promise<
66
+ { ok: true; value: unknown } | { ok: false; failure: import("../kernel/errors.ts").FlowFailure }
67
+ >;
68
+ bodyFailure: (
69
+ err: unknown,
70
+ request: Request,
71
+ ) => { ok: false; failure: import("../kernel/errors.ts").FlowFailure } | undefined;
58
72
  }
59
73
 
60
74
  /**
@@ -82,6 +96,34 @@ export function compileAot(options: CompileRouteOptions): CompiledRoute {
82
96
  parseCookie,
83
97
  assembleInput,
84
98
  validate,
99
+ body: options.body,
100
+ async readBody(request: Request) {
101
+ try {
102
+ return { ok: true as const, value: await parseBody(request, options.body) };
103
+ } catch (err) {
104
+ const rejected = this.bodyFailure(err, request);
105
+ if (rejected) return rejected;
106
+ throw err;
107
+ }
108
+ },
109
+ bodyFailure(err, request) {
110
+ if (!(err instanceof HttpBodyRejected)) return undefined;
111
+ if (err.code === "InvalidQuery") {
112
+ return {
113
+ ok: false,
114
+ failure: fail("InvalidQuery", { reason: err.reason ?? "malformed_body" }),
115
+ };
116
+ }
117
+ if (err.code === "UnsupportedMediaType") {
118
+ return {
119
+ ok: false,
120
+ failure: fail("UnsupportedMediaType", {
121
+ contentType: request.headers.get("content-type") ?? "",
122
+ }),
123
+ };
124
+ }
125
+ return { ok: false, failure: fail("PayloadTooLarge", {}) };
126
+ },
85
127
  };
86
128
 
87
129
  try {
@@ -122,7 +164,9 @@ function generateParseValidate(
122
164
  lines.push("parts.cookie = helpers.parseCookie(request);");
123
165
  }
124
166
  if (inference.body) {
125
- lines.push("parts.body = await helpers.parseBody(request);");
167
+ lines.push("const bodyResult = await helpers.readBody(request);");
168
+ lines.push("if (bodyResult.ok === false) return bodyResult;");
169
+ lines.push("parts.body = bodyResult.value;");
126
170
  }
127
171
 
128
172
  lines.push("const raw = helpers.assembleInput(parts);");
@@ -28,7 +28,7 @@ function loadAot(): typeof import("./aot.ts") {
28
28
  export function compileDynamic(options: CompileRouteOptions): CompiledRoute {
29
29
  return {
30
30
  inference: FULL_INFERENCE,
31
- parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema),
31
+ parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema, options.body),
32
32
  aot: false,
33
33
  };
34
34
  }