okengine 0.16.0 → 0.17.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 (149) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +14 -12
  5. package/site/content/docs/elements/gate.mdx +58 -8
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +23 -17
  8. package/site/content/docs/elements/vault.mdx +23 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +72 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +15 -9
  15. package/src/auth/api-key-sql.ts +11 -4
  16. package/src/auth/api-keys.ts +3 -0
  17. package/src/auth/config.ts +19 -0
  18. package/src/auth/index.ts +37 -0
  19. package/src/auth/plugin.ts +6 -1
  20. package/src/auth/sessions.ts +18 -0
  21. package/src/auth/tables.ts +32 -0
  22. package/src/auth/tenant-config.ts +74 -0
  23. package/src/auth/tenant-tables.ts +11 -0
  24. package/src/auth/tenants.test.ts +63 -0
  25. package/src/auth/tenants.ts +360 -0
  26. package/src/cli/build.ts +2 -2
  27. package/src/client/budget.test.ts +1 -1
  28. package/src/client/create.ts +75 -5
  29. package/src/client/index.ts +13 -0
  30. package/src/client/live.test.ts +422 -0
  31. package/src/client/live.ts +389 -0
  32. package/src/client/notes-contract.test.ts +41 -0
  33. package/src/client/types.ts +85 -6
  34. package/src/client-react/index.ts +85 -1
  35. package/src/client-react/use-live.test.ts +129 -0
  36. package/src/compiler/effects-infer.ts +22 -2
  37. package/src/compiler/extract.test.ts +143 -11
  38. package/src/compiler/extract.ts +210 -30
  39. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  40. package/src/compiler/fixtures/skyport.expected.json +2 -3
  41. package/src/compiler/response.ts +41 -11
  42. package/src/console/server/app.ts +44 -11
  43. package/src/console/server/console.test.ts +6 -8
  44. package/src/console/server/gates.ts +2 -9
  45. package/src/console/server/store.test.ts +17 -0
  46. package/src/console/server/store.ts +43 -1
  47. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
  50. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  53. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
  55. package/src/console/ui-next/dist/index.html +1 -1
  56. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  57. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  58. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  59. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  60. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  61. package/src/drivers/index.ts +2 -0
  62. package/src/drivers/journal-postgres.ts +12 -3
  63. package/src/drivers/pg-rls.ts +26 -2
  64. package/src/drivers/pg-vault-rls.ts +68 -0
  65. package/src/drivers/signal-engine.ts +69 -15
  66. package/src/drivers/signal-live-iter.ts +65 -0
  67. package/src/drivers/signal-nats.ts +2 -1
  68. package/src/drivers/signal-postgres.ts +95 -12
  69. package/src/drivers/signal-redis.ts +2 -1
  70. package/src/drivers/signal-retention.ts +64 -0
  71. package/src/drivers/signal-types.ts +35 -5
  72. package/src/elements/clock/declare.ts +64 -2
  73. package/src/elements/clock/reconcile.ts +98 -25
  74. package/src/elements/clock/runtime.ts +13 -2
  75. package/src/elements/clock.test.ts +28 -0
  76. package/src/elements/clock.ts +9 -1
  77. package/src/elements/gate/permissions.ts +12 -0
  78. package/src/elements/gate.ts +6 -1
  79. package/src/elements/signal/declare.ts +44 -10
  80. package/src/elements/signal/delivery-modes.test.ts +90 -4
  81. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  82. package/src/elements/signal/runtime.ts +42 -0
  83. package/src/elements/signal.test.ts +21 -8
  84. package/src/elements/signal.ts +1 -1
  85. package/src/elements/store/declare.ts +7 -0
  86. package/src/elements/store/rls-identity.test.ts +29 -0
  87. package/src/elements/store/rls-identity.ts +3 -0
  88. package/src/elements/store/schema-decl.ts +63 -3
  89. package/src/elements/store/schema-tenant.ts +41 -0
  90. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  91. package/src/elements/store.ts +2 -0
  92. package/src/elements/vault/builtin-adapter.ts +15 -2
  93. package/src/elements/vault/declare.ts +8 -0
  94. package/src/elements/vault/runtime.ts +7 -0
  95. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  96. package/src/elements/vault/storage.ts +9 -0
  97. package/src/elements/vault/test-helpers.ts +2 -1
  98. package/src/i18n/catalogs/ar.ts +19 -0
  99. package/src/i18n/catalogs/en.ts +19 -0
  100. package/src/index.ts +1 -0
  101. package/src/kernel/adopt-routes.ts +56 -8
  102. package/src/kernel/app-tenant.ts +122 -0
  103. package/src/kernel/app.ts +183 -56
  104. package/src/kernel/auth-resolve.ts +3 -0
  105. package/src/kernel/boot-bind/clock.ts +9 -3
  106. package/src/kernel/boot.ts +2 -0
  107. package/src/kernel/budget.test.ts +1 -1
  108. package/src/kernel/clock-durable.ts +8 -0
  109. package/src/kernel/clock-per-tenant-name.ts +5 -0
  110. package/src/kernel/clock-reconcile.ts +8 -0
  111. package/src/kernel/errors-live-resume.ts +15 -0
  112. package/src/kernel/errors-tenant.ts +29 -0
  113. package/src/kernel/errors.registry.test.ts +31 -3
  114. package/src/kernel/errors.ts +43 -3
  115. package/src/kernel/flow.ts +26 -5
  116. package/src/kernel/fx-auth-keys.ts +6 -1
  117. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  118. package/src/kernel/fx-auth-tenants.ts +286 -0
  119. package/src/kernel/fx-live-stream.ts +149 -0
  120. package/src/kernel/fx-live.test.ts +157 -0
  121. package/src/kernel/fx-runtime.ts +15 -0
  122. package/src/kernel/fx-tenant-store.ts +213 -0
  123. package/src/kernel/fx.test.ts +91 -0
  124. package/src/kernel/fx.ts +173 -13
  125. package/src/kernel/hooks.ts +2 -2
  126. package/src/kernel/http-resource.ts +9 -18
  127. package/src/kernel/index.ts +2 -0
  128. package/src/kernel/journal.ts +12 -0
  129. package/src/kernel/live-http.test.ts +78 -0
  130. package/src/kernel/live-http.ts +114 -0
  131. package/src/kernel/live-resume.test.ts +125 -0
  132. package/src/kernel/on.ts +51 -0
  133. package/src/kernel/pipeline-tenant.ts +49 -0
  134. package/src/kernel/pipeline.test.ts +1 -1
  135. package/src/kernel/pipeline.ts +37 -2
  136. package/src/kernel/resource-mount.test.ts +10 -27
  137. package/src/kernel/tenant-resolve.test.ts +101 -0
  138. package/src/kernel/tenant-resolve.ts +124 -0
  139. package/src/kernel/tenant-roles.test.ts +87 -0
  140. package/src/kernel/triggers.ts +59 -21
  141. package/src/manifest/diff.test.ts +11 -2
  142. package/src/manifest/diff.ts +53 -11
  143. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  144. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  145. package/src/manifest/types.ts +52 -6
  146. package/src/release/build-lib.ts +13 -1
  147. package/src/release/limits.ts +2 -2
  148. package/src/release/measure.ts +55 -1
  149. package/src/client/live-gap.test.ts +0 -35
@@ -298,7 +298,10 @@
298
298
  "enum": ["user", "operator"]
299
299
  },
300
300
  "durable": { "type": "boolean" },
301
- "live": { "type": "boolean" },
301
+ "live": {
302
+ "type": "string",
303
+ "description": "Live signal name when this flow streams delivery: live SSE."
304
+ },
302
305
  "cache": {
303
306
  "oneOf": [{ "type": "boolean" }, { "type": "string", "minLength": 1 }]
304
307
  },
@@ -342,7 +345,23 @@
342
345
  "retries": { "type": "integer", "minimum": 0 },
343
346
  "deadLetter": { "type": "boolean" },
344
347
  "schema": { "$ref": "#/$defs/JsonSchema" },
345
- "optional": { "type": "boolean" }
348
+ "optional": { "type": "boolean" },
349
+ "retention": {
350
+ "description": "Live-tape cap (delivery: live only). Omitted = unbounded.",
351
+ "type": "object",
352
+ "additionalProperties": false,
353
+ "properties": {
354
+ "maxAge": {
355
+ "type": "string",
356
+ "description": "Drop events older than this duration (7d, 1h, 30s, …)."
357
+ },
358
+ "maxCount": {
359
+ "type": "integer",
360
+ "minimum": 1,
361
+ "description": "Keep only the newest N live events."
362
+ }
363
+ }
364
+ }
346
365
  }
347
366
  },
348
367
  "ColumnClassification": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -102,6 +102,12 @@ difference.
102
102
  | `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
103
103
  | `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
104
104
  | `description` | string | — | Human title in the Console (falls back to the clock name) |
105
+ | `perTenant` | boolean | `false` | Expand to one `oke_crons` row per tenant (`{name}#{tenantId}`). Prefer `clock.perTenant(name, opts)` |
106
+
107
+ <Callout title="Per-tenant clocks never tick the template">
108
+ Bind `on(clock.perTenant("invoices", { every: "1h" }), flow(...))`. Rows are `invoices#acme`;
109
+ tenant create/delete adds or orphans them. Fire stamps `fx.tenant.id`.
110
+ </Callout>
105
111
 
106
112
  ### `fx.clock`
107
113
 
@@ -222,6 +228,7 @@ The registry is off in `test` and when there is no shared SQL URL. `dev`/`prod`
222
228
 
223
229
  - [Project structure](/docs/get-started/project-structure) — clock flows in the tree get a name, never a URL
224
230
  - [Flow](/docs/elements/flow) — `on(trigger, flow)` and the `fx` surface
231
+ - [Gate](/docs/elements/gate) — `clock.perTenant` when `gate.auth.tenant` is on
225
232
  - [fx · Runs](/docs/reference/fx#runs-observability-read) — native SLO checkers via `on(every(…))` + `fx.runs`
226
233
  - [Signal](/docs/elements/signal) — reacting to events instead of time
227
234
 
@@ -71,16 +71,17 @@ curl -X POST localhost:6530/orders -d '{"sku":"SKU-1","qty":2}' -H 'content-type
71
71
 
72
72
  ## Anatomy of a Flow
73
73
 
74
- | Part | Role |
75
- | ------------ | --------------------------------------------------------------------------------- |
76
- | trigger | What starts the flow — `http`, a signal, `every`, a row change |
77
- | `in` | Input contract — validated before `do` runs; bad input is a 422 |
78
- | `out` | Output contract — the return value is checked against it |
79
- | `errors` | Typed failures — **returned** with `fx.fail`, never thrown |
80
- | `retry` | Optional whole-`do` backoff on thrown errors (same journal) |
81
- | `cache` | Optional — `false` opts out; `"30s"` adds a TTL. Read-only flows cache by default |
82
- | `do` | The work every read, write, emit, and call goes through `fx` |
83
- | `compensate` | Durable onlyoptional hook after auto per-step `{ undo }` (same journal) |
74
+ | Part | Role |
75
+ | -------------- | ------------------------------------------------------------------------------------ |
76
+ | trigger | What starts the flow — `http`, a signal, `every`, a row change |
77
+ | `in` | Input contract — validated before `do` runs; bad input is a 422 |
78
+ | `out` | Output contract — the return value is checked against it |
79
+ | `errors` | Typed failures — **returned** with `fx.fail`, never thrown |
80
+ | `retry` | Optional whole-`do` backoff on thrown errors (same journal) |
81
+ | `cache` | Optional — `false` opts out; `"30s"` adds a TTL. Read-only flows cache by default |
82
+ | `tenantScoped` | Default `true` when `gate.auth.tenant` is on set `false` to skip tenant-role union |
83
+ | `do` | The workevery read, write, emit, and call goes through `fx` |
84
+ | `compensate` | Durable only — optional hook after auto per-step `{ undo }` (same journal) |
84
85
 
85
86
  Failures are values, not exceptions:
86
87
 
@@ -139,8 +140,8 @@ export const findOrder = on(
139
140
  ### signal — another flow emits
140
141
 
141
142
  The producer emits through `fx`. For `once` and `broadcast` the consumer is the same species —
142
- `on(signal, flow)`, no `subscribe()` registration. `live` replay is the exception: `bus.live()` on
143
- the server, not a Flow.
143
+ `on(signal, flow)`, no `subscribe()` registration. `live` replay is HTTP SSE: `.live(signal)` on a
144
+ GET trigger, `fx.live` as the stream carrier, `api.live` on the client.
144
145
 
145
146
  ```typescript
146
147
  await fx.emit(orderPlaced, { orderId: id }); // inside the producing flow
@@ -209,6 +210,7 @@ Everything a flow may touch, on one object:
209
210
  | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
210
211
  | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
211
212
  | `fx.deadLetters(signal)` | read | Dead-lettered messages for that signal |
213
+ | `fx.live(signal, { match? })` | read | Live signal SSE carrier (`JsonStreamResult`) |
212
214
  | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
213
215
  | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
214
216
  | `fx.run(agent, input)` | ask | Run a bounded agent |
@@ -236,6 +236,7 @@ export const app = oke({
236
236
  | `session.singleSessionPerUser` | `false` | Opt-in: issuing a session revokes other families for that user |
237
237
  | `cookies.enabled` | `false` | Opt-in HttpOnly cookie mirror (Bearer stays default) |
238
238
  | `secondaryStorage.enabled` | `false` | Hot auth data in `store.kv` when configured |
239
+ | `tenant` | unset | Opt-in identity dimension — `true` or `{ required, source, header, resolve, authoritative }` |
239
240
  | `user` / `session` / … | defaults | `modelName` / `fields` / `additionalFields` |
240
241
 
241
242
  HIBP via `createHibpBreachCheck` from `okengine/auth` (k-anonymity range API; requires
@@ -256,6 +257,51 @@ Customize tables, then `oke schema generate` (`.oke/schema/oke.ts`; `--check` in
256
257
  sign-in shapes: [Plugins](/docs/plugins). Call auth from
257
258
  [createClient](/docs/reference/client) — helpers in `okengine/client/auth`.
258
259
 
260
+ ## Tenants (identity dimension)
261
+
262
+ `gate.auth.tenant: true` (or an options bag) is **not** a second authorization system and not an
263
+ organizations product. Tenant is a dimension of identity, like `fx.auth` / `fx.operator`.
264
+
265
+ ```typescript
266
+ export const app = oke({
267
+ name: "shop",
268
+ gate: { auth: { tenant: true } },
269
+ });
270
+ ```
271
+
272
+ | Option | Default | Meaning |
273
+ | --------------- | -------------- | ------------------------------------------------------------------------------------------------ |
274
+ | `required` | `false` | Pure B2B: authenticated user-plane requests without a tenant are `Forbidden` / `tenant_required` |
275
+ | `source` | `"claim"` | `"claim"` · `"header"` · `"subdomain"` · `"resolve"` |
276
+ | `header` | `x-oke-tenant` | Header name when `source` is `"header"` |
277
+ | `resolve` | unset | Callback that returns an id; membership is still checked unless `authoritative` |
278
+ | `authoritative` | `false` | Trust `resolve` without a membership query |
279
+
280
+ Three tiers, fail-safe:
281
+
282
+ 1. **Claim** — signed JWT `tid` or API-key `tenantId`. The id is trusted; no membership query.
283
+ 2. **Header / subdomain** — never trust a client-supplied id without membership.
284
+ 3. **`resolve`** — still membership-checked unless `authoritative: true`.
285
+
286
+ `required: false` (default) is B2C+B2B: `fx.tenant.id` may be `null`. Internal / cron / `fx.call`
287
+ have no HTTP request — header and subdomain sources keep the stamped claim.
288
+
289
+ `fx.auth.switchTenant(id)` issues a **new** access+refresh pair and a **new** family (so tab A
290
+ rotation cannot reuse-detect tab B). It never Set-Cookies.
291
+
292
+ Refresh copies that row's `tid`; tenant-role scopes never melt into the JWT.
293
+
294
+ `fx.auth.listTenants` / `createTenant` / `addMember` / `upsertTenantRole` are session-only
295
+ (`auth:tenants`). Tenant roles may grant application scopes only — `console:*` fails like an
296
+ unknown name.
297
+
298
+ Live `fx.auth.scopes` unions those grants only when `fx.tenant.id` is set, the flow is
299
+ tenant-scoped (default on; `flow({ tenantScoped: false })` opts out), and user-plane.
300
+ `gate.scope()` still reads `auth.scopes.has(name)`.
301
+
302
+ SQL stamps `oke.tenant()` when tenancy is on — [Store](/docs/elements/store) and
303
+ built-in [Vault](/docs/elements/vault#tenant-isolation-built-in-store) ciphertext.
304
+
259
305
  ## API keys
260
306
 
261
307
  A key is the **issuer with fewer gates** — not a second permission system.
@@ -300,8 +346,8 @@ edit live today.
300
346
 
301
347
  <Callout title="RLS reads the stamped principal">
302
348
  After HTTP Gate passes, user-plane `fx.store` stamps `oke.gate()`, `oke.user()`, and
303
- `oke.has_scope()`. Rate, cron, CDC, and signal stay unstamped. Helpers:
304
- [Store](/docs/elements/store).
349
+ `oke.has_scope()`. With `gate.auth.tenant` on, a fourth GUC `oke.tenant()` is set. Rate, cron,
350
+ CDC, and signal stay unstamped. Helpers: [Store](/docs/elements/store).
305
351
  </Callout>
306
352
 
307
353
  ## Troubleshooting
@@ -324,6 +370,12 @@ decisions belong inside `do` as typed `errors`.
324
370
  `Forbidden` means authenticated but a policy said no. Check `verified` or a missing scope —
325
371
  the failure includes the gate name and reason.
326
372
 
373
+ </Accordion>
374
+ <Accordion title="Forbidden tenant_required / not_member">
375
+
376
+ `required: true` and no resolved tenant → `tenant_required`. Header/subdomain id that is not a
377
+ membership → `not_member`. Call `fx.auth.switchTenant(id)` or send a signed `tid`.
378
+
327
379
  </Accordion>
328
380
  <Accordion title="Prod boot: gate.auth: secret is required">
329
381
 
@@ -345,11 +397,9 @@ Keying an authenticated endpoint by IP punishes shared NAT.
345
397
  </Accordion>
346
398
  <Accordion title="API key Bearer returns 401">
347
399
 
348
- Expired, revoked, allowlist miss, or over the key's `rateLimit`. Check
349
- `ipAllowlist` against the same X-Forwarded-For hop as `ip-allowlist`.
350
- Entries are IPs or hostnames a host resolves at verify time and must
351
- include the client IP (lookup failure is closed). Rotate if the secret
352
- was shown once and then lost.
400
+ Expired, revoked, allowlist miss, or over `rateLimit`. `ipAllowlist` uses the
401
+ same X-Forwarded-For hop as `ip-allowlist` (IP or hostname; lookup failure is closed).
402
+ Rotate if the secret was shown once and then lost.
353
403
 
354
404
  </Accordion>
355
405
  <Accordion title="Keys vanish after an app restart">
@@ -372,7 +422,7 @@ predicates that ignore scopes still see `verified: true`.
372
422
 
373
423
  - [Project structure](/docs/get-started/project-structure) — `http.get()` and `flow({…})` fill path and name from the file tree
374
424
  - [Flow](/docs/elements/flow) — the trigger pipeline gates plug into
375
- - [fx](/docs/reference/fx) — `fx.auth`, `fx.operator`, `fx.principal`
425
+ - [fx](/docs/reference/fx) — `fx.auth`, `fx.operator`, `fx.principal`, `fx.tenant`
376
426
  - [Client](/docs/reference/client) — Bearer `createClient` + `okengine/client/auth`
377
427
  - [Plugins](/docs/plugins) — username, magic link, OTP, TOTP, passkeys
378
428
  - [Vault](/docs/elements/vault) — credentials your policies protect
@@ -72,7 +72,7 @@ export const sendConfirmation = on(
72
72
  </Steps>
73
73
 
74
74
  That's the loop for `once` and `broadcast`: declare → `fx.emit` → `on(signal, flow)`.
75
- `live` keeps the same emit; the listener is `bus.live()`, not a Flow.
75
+ `live` keeps the same emit; expose it over HTTP with `.live(signal)` and subscribe from `api.live`.
76
76
 
77
77
  ## The three delivery physics
78
78
 
@@ -90,6 +90,7 @@ The declaration is identical in shape for all three — switching physics later
90
90
  | `retries` | number | `3` | Retry budget for `once` — dead-letter when `attempts > retries` (`retries + 1` total invocations) |
91
91
  | `deadLetter` | boolean | `true` | Preserve exhausted messages in the DLQ (`once`) |
92
92
  | `optional` | boolean | `false` | Allow emitting while nobody subscribes (skip the orphan check) |
93
+ | `retention` | `{ maxAge?, maxCount? }` | unbounded | Live tape cap (`delivery: "live"` only). Both limits AND-combine when set |
93
94
 
94
95
  ## One order, three modes
95
96
 
@@ -124,6 +125,7 @@ export const orderStatus = signal("order-status", {
124
125
  }),
125
126
  delivery: "live",
126
127
  optional: true, // clients may not be connected yet
128
+ retention: { maxAge: "24h", maxCount: 500 },
127
129
  });
128
130
  ```
129
131
 
@@ -182,19 +184,31 @@ export const notifyCustomer = on(
182
184
  );
183
185
  ```
184
186
 
185
- `live` — retain the tape. A late subscriber replays **full history** (`placed → fulfilling → shipped`), then keeps going.
187
+ `live` — retain the tape. A late subscriber replays history (`placed → fulfilling → shipped`), then keeps going. Omit `retention` and that history is **unbounded** — declare `maxAge` / `maxCount` in production.
186
188
 
187
- The listener is **`bus.live()`**, not `on(orderStatus, flow)`. `on(signal, flow)` does not replay retained live payloads. Today that API is server-side; `createClient` has no SSE / WebSocket / `client.live` yet see [Client](/docs/reference/client#signal-and-live-queries).
189
+ The listener is **not** `on(orderStatus, flow)` that trigger does not replay retained payloads. Expose GET SSE with `.live(signal)`, then subscribe from the typed client. `optional: true` is required so emits succeed before anyone is connected.
188
190
 
189
191
  ```typescript
190
- const bus = app.bootResult?.signal?.bus;
191
- if (!bus) throw new Error("signal runtime not booted");
192
+ export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
192
193
 
193
- const unsub = await bus.live("order-status", (payload) => {
194
- /* placed → fulfilling → shipped */
195
- });
194
+ export const adminFeed = on(http.get("/admin/order-status").gate(admin).live(orderStatus));
195
+
196
+ const stop = api.live(
197
+ orderStatus,
198
+ { orderId: "ord_1" },
199
+ {
200
+ onEvent: (event) => {
201
+ /* placed → fulfilling → shipped */
202
+ },
203
+ },
204
+ );
205
+ stop();
196
206
  ```
197
207
 
208
+ Same signal, two audiences: member filter on `:orderId`, admin firehose. Duplicate `(signal, gates, match)` is **OKE1013**. Same HTTP path is still **OKE1011**. Firehose default: `on(http.live(orderStatus).gate(member))` → `GET /_oke/live/order-status`.
209
+
210
+ Server-side `fx.live` **is** the SSE carrier (`JsonStreamResult`). Custom `do` is allowed; authors do not wrap `fx.json.stream`. Reconnects send `Last-Event-ID`; a missing cursor is **OKE1014** / HTTP 410, not a silent full replay. `autoResubscribe: true` backs off 500ms…30s and, after a 410, replays the remaining tape.
211
+
198
212
  **Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
199
213
 
200
214
  ## When delivery fails
@@ -252,12 +266,16 @@ Committed `once` messages survive process death. A claim sets `lockedBy` and a v
252
266
 
253
267
  <SignalLiveReplay />
254
268
 
255
- `live` retains every delivered message and replays the **full history** to a late `bus.live()` subscriber. There is no TTL or max-count window on that retention today (the Console payload monitor shows only the newest 50 for display).
269
+ `live` retains delivered messages and replays them to a late subscriber. Omit `retention` for an unbounded tape (a production hazard). `maxAge` (clock duration) and `maxCount` AND-combine when both are set. Drivers prune on write and when `live()` opens no background sweeper.
270
+
271
+ Reconnects send SSE `Last-Event-ID`. If that id was pruned or never existed, the server returns **410** `{ error: { code: "LiveResumeGap" } }` (**OKE1014**) before opening the stream. The client clears the cursor; `autoResubscribe` then replays whatever remains.
256
272
 
257
- <Callout title="Client subscription is not shipped yet">
258
- `delivery: "live"` means the **driver** retains and replays. Listen with `bus.live()` on the
259
- server not `on(signal, flow)`. `createClient` has no SSE yet — poll HTTP. See
260
- [Client](/docs/reference/client#signal-and-live-queries).
273
+ The Console payload monitor still shows only the newest 50 for display — that cap is not the retained tape.
274
+
275
+ <Callout title="HTTP live is GET SSE">
276
+ Expose with `http.get(path).live(signal)` or `http.live(signal)`. Subscribe with `api.live(signal,
277
+ input?, {onEvent})` — callback + unsubscribe, not `for await`. See
278
+ [Client](/docs/reference/client#live-signals).
261
279
  </Callout>
262
280
 
263
281
  ## Orphaned signal config
@@ -291,7 +309,7 @@ Pairing an arbitrary Store insert with emit inside **one** shared SQL transactio
291
309
  You emitted a signal that nobody currently subscribes to. For `once` and `broadcast`, wire
292
310
  `on(signal, flow)` before emit.
293
311
 
294
- `live` listeners are `bus.live()`, not a Flow — set `optional: true` when they may connect later.
312
+ `live` listeners are HTTP SSE (`api.live` / `fx.live`), not a Flow — set `optional: true` when they may connect later.
295
313
 
296
314
  </Accordion>
297
315
  <Accordion title="Emit fails with OKE1043 (schema)">
@@ -306,13 +324,24 @@ When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`)
306
324
  </Accordion>
307
325
  <Accordion title="fx.deadLetters throws OKE1001">
308
326
 
309
- The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)`. A different handle needs its own read.
327
+ The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)` and `fx.live(signal)`. A different handle needs its own read.
328
+
329
+ </Accordion>
330
+ <Accordion title="Boot fails with OKE1013 (live exposure duplicate)">
331
+
332
+ Two GET routes expose the same live signal with the same gates and match.
333
+ Change the gate or path-param filter, or drop the extra route (same path is **OKE1011**).
334
+
335
+ </Accordion>
336
+ <Accordion title="Subscribe gets HTTP 410 LiveResumeGap (OKE1014)">
337
+
338
+ The `Last-Event-ID` cursor is not on the retained tape (pruned, never existed, or the memory bus restarted). The gap is visible in `onError`. With `autoResubscribe: true` the next request omits the header and replays what remains.
310
339
 
311
340
  </Accordion>
312
341
  <Accordion title="on(liveSignal, flow) never replays history">
313
342
 
314
- `on(signal, flow)` listens for `once` and `broadcast`. `live` replay is `bus.live()` on the Signal
315
- bus — a Flow trigger does not replay retained payloads.
343
+ `on(signal, flow)` listens for `once` and `broadcast`. `live` replay is HTTP SSE (`api.live` /
344
+ `fx.live`) — a Flow trigger does not replay retained payloads.
316
345
 
317
346
  </Accordion>
318
347
  <Accordion title="once vs broadcast vs live — how do I choose?">
@@ -199,10 +199,10 @@ const notesR = store.resource(db, notes, {
199
199
  },
200
200
  });
201
201
 
202
- const mounted = on(http.resource("/notes", notesR.all()).public().live());
202
+ const mounted = on(http.resource("/notes", notesR.all()).public());
203
203
  ```
204
204
 
205
- `.gate(...)` / `.live()` chain like `http.get` — gates on every verb, live on list and get.
205
+ `.gate(...)` / `.public()` chain like `http.get` — gates on every verb. Live SSE is `.live(signal)` on a GET, not on `http.resource`.
206
206
 
207
207
  The list endpoint's URL is the whole query language:
208
208
 
@@ -304,17 +304,18 @@ await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
304
304
 
305
305
  The recommended path: declare tables ORM-agnostically, then let `oke db` emit real Drizzle (`pgTable` for Postgres / PGLite) into `src/db/schema.drizzle.ts`.
306
306
 
307
- | Field API | Meaning |
308
- | -------------------------------------------- | ---------------------------------------------------------------- |
309
- | `field.text()` / `field.integer()` | v1 column primitives |
310
- | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
311
- | `.default(v)` · `.defaultFn(id \| now)` | defaults |
312
- | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
313
- | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
314
- | `.describe("…")` | human title in the Console (falls back to key) |
315
- | `.references(() => col, { onDelete })` | foreign key |
316
- | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
317
- | `store.schema.policy.gate/owner/scope` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()`) |
307
+ | Field API | Meaning |
308
+ | --------------------------------------------- | --------------------------------------------------------------------------------- |
309
+ | `field.text()` / `field.integer()` | v1 column primitives |
310
+ | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
311
+ | `.default(v)` · `.defaultFn(id \| now)` | defaults |
312
+ | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
313
+ | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
314
+ | `.describe("…")` | human title in the Console (falls back to key) |
315
+ | `.references(() => col, { onDelete })` | foreign key |
316
+ | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
317
+ | `store.schema.policy.gate/owner/scope/tenant` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()` / `oke.tenant()`) |
318
+ | `store.schema.unscoped()` | Shared table — required when tenancy is on and there is no tenant policy |
318
319
 
319
320
  Third argument on `store.schema.table` is Drizzle-shaped extras:
320
321
 
@@ -329,6 +330,7 @@ export const bookings = store.schema.table(
329
330
  store.schema.policy.gate("member", { for: "select" }),
330
331
  store.schema.policy.owner("owner", { for: "all" }),
331
332
  store.schema.policy.scope("booking:create", { for: "insert" }),
333
+ store.schema.policy.tenant("tenant_id"),
332
334
  ],
333
335
  );
334
336
  ```
@@ -338,9 +340,9 @@ Helpers emit stable names (`gate_member_select`). Raw
338
340
  escape hatch — predicates use `oke.*`, never `current_setting`.
339
341
 
340
342
  <Callout title="User-plane fx.store applies RLS">
341
- HTTP / resource flows stamp Gate identity onto every postgres / pglite statement (`SET LOCAL ROLE
342
- oke_app` + `set_config` in one pinned transaction). Table-owner bypass ends there; operator / cron
343
- / CDC / signal / catalog stay unstamped.
343
+ HTTP / resource flows stamp Gate identity (`SET LOCAL ROLE oke_app` + `set_config`). Operator /
344
+ cron / CDC / signal stay unstamped. Tenancy on: also `oke.tenant()`; tables need `policy.tenant`
345
+ or `unscoped()`.
344
346
  </Callout>
345
347
 
346
348
  #### Foreign keys and relations
@@ -690,6 +692,10 @@ export const drafts = store.kv("drafts", { durable: true, description: "Compose
690
692
  **Consequence:** `oke db seed` into cache Redis looks fine until compose recreates the
691
693
  container — Console Store then shows **No rows.** Seeded namespaces need `{ durable: true }`.
692
694
 
695
+ With `gate.auth.tenant` on, KV keys are prefixed `{tenantId}:` (logical keys in `do` stay
696
+ unprefixed). Missing `fx.tenant.id` throws **OKE1015**. Opt out with
697
+ `store.kv("sessions", { tenantScoped: false })`.
698
+
693
699
  Gate rates and Signal stay on `REDIS_URL`. Missing `DATABASE_URL` with the postgres driver
694
700
  fails boot: `oke boot: durable store.kv needs DATABASE_URL`.
695
701
 
@@ -1128,7 +1134,7 @@ Default `store.kv` is cache Redis with no AOF — a recreate drops keys. SQL see
1128
1134
  - [Project structure](/docs/get-started/project-structure) — `list.ts` + `http.get()` is `GET /notes` named `notes.list`
1129
1135
  - [Flow](/docs/elements/flow) — the `fx.store` session inside `do`
1130
1136
  - [AI](/docs/elements/ai) — `ai.embed` into a vector `store.index`, searched via `fx.search`
1131
- - [Gate](/docs/elements/gate) — `pii:reveal` and other permissions on flows
1137
+ - [Gate](/docs/elements/gate) — `pii:reveal`, `gate.auth.tenant`, and `oke.tenant()`
1132
1138
  - [CLI Reference](/docs/reference/cli) — `oke db push` · `generate` · `migrate`
1133
1139
  - [Configuration](/docs/reference/configuration) — `drivers.store` maps and `images` pins
1134
1140
  - [Environment variables](/docs/reference/environment-variables) — Redis · S3 · meilisearch URLs
@@ -151,6 +151,7 @@ Every name passed to `vault.env.required` is registered, so boot reports missing
151
151
  | `schema` | zod / Standard Schema | Validated at boot; a bad value fails boot like a missing one |
152
152
  | `dev` | string | Local-only fallback when no source provides a value (never used in prod) |
153
153
  | `sensitive` | boolean | Override the default (`true` for secrets, `false` for config) |
154
+ | `perTenant` | boolean | Resolve at request time under `{tenantId}/{contract}`. Default `true` when `gate.auth.tenant` is on. Boot skips these contracts. |
154
155
 
155
156
  ### Zero-setup Compose fallback
156
157
 
@@ -297,6 +298,20 @@ For production built-in stores, set `vault.encryption.masterKey` to a KMS source
297
298
  `@aws-sdk/client-kms`) or use `managed` with a provider below. `oke doctor` warns when prod would
298
299
  take the master key from the environment.
299
300
 
301
+ ## Tenant isolation (built-in store)
302
+
303
+ When `gate.auth.tenant` is on, `perTenant` contracts resolve at request time under
304
+ `{tenantId}/{contract}`. Boot skips those contracts.
305
+
306
+ The built-in Postgres table `oke_vault_secrets` carries `tenant_id` and the same
307
+ `oke.tenant()` helper as domain tables — not a second GUC.
308
+
309
+ <Callout title="RLS on ciphertext rows">
310
+ `tenant_id = oke.tenant() OR tenant_id IS NULL`. ENABLE without FORCE: the adapter (owner) still
311
+ sees every row; Console / `oke_app` cannot list another tenant's ciphertext. Global `NULL` rows
312
+ stay visible to every stamped tenant.
313
+ </Callout>
314
+
300
315
  ## Managed providers (official)
301
316
 
302
317
  Set `drivers.vault` to `"managed"` and `OKE_VAULT_PROVIDER` to an id below. Omit the provider when
@@ -349,6 +364,13 @@ radius before you rotate, and `is:overdue` when a secret is past its
349
364
  Prefer `oke vault unseal --key -` and pipe the key on stdin, or omit `--key` on a
350
365
  TTY and type it at the hidden prompt. Avoid `--key <base64>` on shared hosts.
351
366
 
367
+ </Accordion>
368
+ <Accordion title="Console SQL lists another tenant's vault rows">
369
+
370
+ User-plane SQL is stamped `oke.tenant()`. The policy hides other tenants' ciphertext
371
+ and keeps global `NULL` rows. The Vault adapter is table owner, so ENABLE (not FORCE)
372
+ does not hide rows from itself.
373
+
352
374
  </Accordion>
353
375
  <Accordion title="Expired secrets still take space in Postgres">
354
376
 
@@ -368,6 +390,7 @@ Schedule the live command with cron when you want automatic cleanup.
368
390
 
369
391
  - [Project structure](/docs/get-started/project-structure) — `flow({…})` names as `unit.export`; `flow("billing.charge")` still wins
370
392
  - [Flow](/docs/elements/flow) — how `fx.vault.get` reads secrets inside `do`
393
+ - [Gate](/docs/elements/gate) — `perTenant` contracts when `gate.auth.tenant` is on
371
394
  - [CLI Reference](/docs/reference/cli) — `oke vault set` · `list` · `import`
372
395
 
373
396
  ## Next
@@ -701,7 +701,7 @@ Plugin `.binding()` is never inferred. File tree is app flows only.
701
701
 
702
702
  ```typescript title="flows/my/index.ts"
703
703
  export const tasks = on(
704
- http.get("/me/tasks").gate(member).live(),
704
+ http.get("/me/tasks").gate(member),
705
705
  flow("my.tasks", {
706
706
  do: async (_input, fx) => fx.store(db).select().from(tasks),
707
707
  }),
@@ -773,9 +773,9 @@ import { noteCreated } from "./signals";
773
773
 
774
774
  ### `http.resource` in a tree
775
775
 
776
- Five reserved leaves reproduce `GET|POST /notes` and `GET|PATCH|DELETE /notes/:id`, but not one shared `.gate()` / `.live()` and not `store.resource().all()` in a single declaration.
776
+ Five reserved leaves reproduce `GET|POST /notes` and `GET|PATCH|DELETE /notes/:id`, but not one shared `.gate()` and not `store.resource().all()` in a single declaration.
777
777
 
778
- Put `on(http.resource("/notes", ops).public().live())` in `route.ts`. Inference is skipped — the five verbs already declare those paths. Extra actions (`[id]/archive.ts`) sit beside it. Pathless `http.resource()` is not a thing.
778
+ Put `on(http.resource("/notes", ops).public())` in `route.ts`. Inference is skipped — the five verbs already declare those paths. Extra actions (`[id]/archive.ts`) sit beside it. Pathless `http.resource()` is not a thing.
779
779
 
780
780
  | Verb | Path |
781
781
  | -------- | ------------------- |
@@ -790,7 +790,7 @@ Put `on(http.resource("/notes", ops).public().live())` in `route.ts`. Inference
790
790
 
791
791
  ```typescript title="flows/notes/route.ts"
792
792
  const notesR = store.resource(db, notes, { in: NewNote, out: Note });
793
- export const mounted = on(http.resource("/notes", notesR.all()).public().live());
793
+ export const mounted = on(http.resource("/notes", notesR.all()).public());
794
794
  ```
795
795
 
796
796
  </Tab>
@@ -65,7 +65,7 @@ oke docker clean --yes # non-TTY: current project only
65
65
  oke docker clean --all --yes # non-TTY: every oke-dev-* project on this machine
66
66
  oke images pin # tags → digests in oke.images.lock
67
67
 
68
- oke build --target edge # < 15 kB kernel profile
68
+ oke build --target edge # < 16 kB kernel profile
69
69
  oke eval # run prompt eval sets; fails CI on regression
70
70
  oke ai setup # configure AI driver + models (TTY wizard or flags)
71
71
  oke branch prod --at "yesterday" # fork journaled state into a sandbox