# Documentation (/docs)

Welcome to the OKEngine handbook. Keep the model small; let the backend grow without growing the mental model.

<Callout title="The Law">
  Every backend behavior is a Flow: `on(Trigger) → Effects`. One species; triggers are typed values.
</Callout>

```ts
on(http.post(), createBooking); // path + name from the file tree
on(expireStaleClock, expireStale);
on(orderPlaced, sendReceipt);
```

## 01 Understand

Master the mental model before exploring features:

<Cards>
  <Card
    title="The Architecture"
    description="Why backends drift apart, the one rule that stops it, and the five pieces behind every Flow."
    href="/docs/understand/the-architecture"
  />
  <Card
    title="Try It"
    description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
    href="/docs/understand/try-it"
  />
</Cards>

## 02 Elements

Deep primitives and execution law:

<Cards>
  <Card
    title="Elements"
    description="Flow, Signal, Store, Clock, Gate, Vault, Channel, AI."
    href="/docs/elements"
  />
  <Card
    title="Reference"
    description="CLI, configuration, fx, errors, security, and plugins."
    href="/docs/reference"
  />
  <Card
    title="AI Resources"
    description="Runtime MCP (:6535), docs MCP (:6536), agent skills, and machine-readable docs."
    href="/docs/ai"
  />
</Cards>

## 03 Extend

Connect to infrastructure and extend the model:

<Cards>
  <Card
    title="Plugins"
    description="Official extensions that add capabilities without creating a ninth element."
    href="/docs/plugins"
  />
  <Card
    title="Providers"
    description="Managed cloud infrastructure behind protocol-named drivers."
    href="/docs/providers"
  />
  <Card
    title="Recipes"
    description="Self-hosted Docker compose recipes for SQL, KV, search, and local AI."
    href="/docs/recipes"
  />
</Cards>

## 04 Client

Call your Flows from a browser, CLI, or another service — typed, zero separate codegen:

<Cards>
  <Card
    title="Overview"
    description="createClient, envelopes as values, and how Client relates to the eight elements."
    href="/docs/client"
  />
  <Card
    title="Calling"
    description="REST vs RPC, options, result helpers, resources, and remote types."
    href="/docs/client/calling"
  />
  <Card
    title="Auth"
    description="Bearer sessions, refresh once on 401, and gate denials as values."
    href="/docs/client/auth"
  />
  <Card
    title="Live"
    description="api.live SSE, resume, and live resource queries."
    href="/docs/client/live"
  />
  <Card
    title="React"
    description="useSession, useLive, and useLiveQuery from okengine/client-react."
    href="/docs/client/react"
  />
</Cards>


# AI Resources (/docs/ai)

Resources for AI agents that inspect, understand, or operate an OKEngine application.

<Callout title="Two directions, one model">
  **MCP is a surface of the model, not the model itself.** Gate governs agents calling in; the AI
  element governs applications calling out to models.
</Callout>

```text
External Agents → MCP (:6535) → Gate Check → Flow (inbound)
Flow → fx.ask / fx.stream → AI Runtime → Models & External Tools (outbound)
```

## AI & Agent Surfaces

<Cards>
  <Card
    title="MCP"
    description="Runtime MCP on :6535 for running apps; Docs MCP on :6536 for handbook search."
    href="/docs/ai/mcp"
  />
  <Card
    title="Agent Skills"
    description="AGENTS.md contract and autonomous maintenance skills."
    href="/docs/ai/skills"
  />
  <Card
    title="llms.txt"
    description="Standardized machine-readable feeds: /llms.txt, /llms.json, /llms-full.txt, and markdown twins."
    href="/docs/ai/llms-txt"
  />
  <Card
    title="AI Element"
    description="Calling out: models, prompts, agents, and RAG inside your application."
    href="/docs/elements/ai"
  />
</Cards>


# llms.txt (/docs/ai/llms-txt)

This documentation site serves machine-readable twins of every page, following the `llms.txt` convention — so an agent can pull exactly the docs it needs (or all of them) instead of scraping HTML.

| Endpoint                   | Contains                                                | Pull it when            |
| -------------------------- | ------------------------------------------------------- | ----------------------- |
| `/llms.txt`                | Spec map — H1, summary, curated links, Optional rest    | The agent needs the map |
| `/llms.json`               | Same catalogue as JSON (`slug`, `html`, `markdown`)     | Structured clients      |
| `/llms-full.txt`           | All pages concatenated; teaching figures as blockquotes | Whole-docs context      |
| `/llms.mdx/docs/⟨slug⟩.md` | One page as source markdown                             | One page — e.g. Vault   |
| `/changelog`               | Index of minor-version release-note pages               | Recent history          |
| `/llms/agents`             | The `AGENTS.md` contract                                | Session bootstrap       |
| `/robots.txt`              | Allow-all, including named AI crawlers                  | Crawler policy          |
| `/sitemap.xml`             | HTML pages plus the machine endpoints                   | Crawler map             |

## Use them

```bash
curl https://oke.omqkhafi.dev/llms.txt
curl https://oke.omqkhafi.dev/llms.json
curl https://oke.omqkhafi.dev/llms-full.txt
curl https://oke.omqkhafi.dev/llms.mdx/docs/elements/vault.md
curl -H "Accept: text/markdown" https://oke.omqkhafi.dev/docs/elements/vault
curl https://oke.omqkhafi.dev/llms/agents
```

<Callout title="Which one first?">
  Start with `/llms.txt`. Fetch `/llms-full.txt` only when breadth matters more than tokens.
  Per-page `/llms.mdx` is the precise instrument. `/llms.mdx/docs/⟨slug⟩/content.md` still works.
</Callout>

The homepage **Onboard AI** button copies a one-line prompt that points an agent at
`/llms.txt` plus `/llms/agents` on this origin — project-wide context, not a single docs page.

HTML docs pages advertise `rel="alternate"` `type="text/markdown"` to the twin.
`Accept: text/markdown` on the same URL returns that twin and sets `Vary: Accept`.

`/llms.txt` opens with **When to use this** — The Problem, The Model, AGENTS.md,
MCP, CLI, errors, npm, GitHub — then the curated map.

Crawlers that honor `robots.txt` are allowed; `/sitemap.xml` lists the handbook and the machine entry points.

## How it pairs with the docs MCP

These endpoints serve the _published_ docs of the framework. During `oke dev`, the [docs MCP server](/docs/ai/mcp) also runs on :6536 (`oke.docs.search` / `oke.docs.get`), answering from the same content over the MCP protocol — pick the plain HTTP endpoints for one-shot context, the MCP tools when the agent is already connected as a client.

## Learn more

- [MCP](/docs/ai/mcp) — runtime and docs MCP servers
- [Agent contracts](/docs/ai/skills) — teaching agents the vocabulary these pages use
- [Reference](/docs/reference/configuration) — the most-cited pages for config questions

## Next

<Cards>
  <Card title="MCP" description="Runtime :6535 · docs :6536." href="/docs/ai/mcp" />
  <Card
    title="Skills"
    description="AGENTS.md and the shipped agent skills."
    href="/docs/ai/skills"
  />
  <Card title="AI element" description="Models inside your app." href="/docs/elements/ai" />
</Cards>


# MCP (/docs/ai/mcp)

## Two directions, same execution model

> **MCP is a surface of the model, not the model itself.**

Two genuinely distinct things share the protocol in OKE:

1. **Gate Element (MCP Provider role):** Your app exposes Flows as MCP tools on port **6535** (or via `mcp.tool()` + OAuth 2.1 Authorization Server) so external AI agents (Claude, ChatGPT) can read the Manifest and call declared Flows.
2. **AI Element (MCP Client role):** Your app consumes _external_ MCP tool servers via `ai.mcpServer(...)`, routing tool calls through `fx.call` inside prompts and agents.

OKE does not create separate security models for users, operators, and agents. They enter the same execution model through different triggers and planes.

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.

<Callout title="The one rule">
  MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
  exactly where the confused-deputy problem lives, so access descends to the tool, the operation,
  and each parameter.
</Callout>

## Read tools

Default-safe — they return inert data envelopes, never live handles:

| Tool               | Returns                                        | Scope (any of)                                |
| ------------------ | ---------------------------------------------- | --------------------------------------------- |
| `oke.manifest.get` | The current Manifest catalogue                 | `mcp:manifest:read` · `console:manifest:read` |
| `oke.schema.get`   | In/out/error schemas for one flow (`flowId`)   | `mcp:schema:read` · `console:manifest:read`   |
| `oke.effects.get`  | Declared effects for one flow (`flowId`)       | `mcp:effects:read` · `console:manifest:read`  |
| `oke.traces.list`  | Recent runs (`limit` ≤ 200, optional `flowId`) | `mcp:traces:read` · `console:runs:read`       |
| `oke.traces.get`   | One run/trace record (`runId`)                 | `mcp:traces:read` · `console:runs:read`       |

## Write tools — confirmed, every single call

Two actions are write-class, and each call needs a **fresh, single-use** confirmation — there is no session-level consent cache to leak:

| Tool                            | Does                                                    | Scope (any of)                                         |
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------ |
| `oke.action.invoke`             | Invoke a flow by id (sensitive)                         | `mcp:action:invoke` · `console:flows:invoke`           |
| `oke.action.structural_propose` | Propose a structural diff — reviewable, **not applied** | `mcp:action:structural` · `console:structural:propose` |

<Steps>

<Step>
### Request a confirmation token

`oke.action.confirm` with the target `tool`, the exact `args`, and a human `reason` — it returns a single-use token.

</Step>

<Step>
### Call the write tool

Pass the token as `confirmToken` plus the phrase `CONFIRM` in `confirmation`. Token, phrase, args, and principal must all match what was confirmed.

</Step>

<Step>
### The token dies

Consumed tokens cannot be replayed; the next write needs a new confirmation. A mismatch fails with `write tool requires fresh human confirmation` and the `confirmVia` hint.

</Step>

</Steps>

## Structural changes arrive as diffs

`oke.action.structural_propose` is how an agent suggests a file change: it takes `title`, `relativePath`, and `contents`, and produces a **reviewable diff** for a human — it is never applied to the tree. This is the boundary that lets an agent propose boldly while a human stays the one who merges.

## The security model

| Layer               | Enforcement                                                 |
| ------------------- | ----------------------------------------------------------- |
| Authentication      | Bearer session token required — even on `127.0.0.1`         |
| Scope inheritance   | `console:*` / `mcp:*` expand to every declared tool scope   |
| Per-tool ACL        | Each tool declares required scopes + read/write class       |
| Per-parameter rules | `maxLength`, enum allow-lists, forbidden parameters         |
| Token hygiene       | Caller token never forwarded — structured operator ids only |
| Confirmation        | Single-use, per call, phrase + token + args bound           |

## Docs MCP — a second, docs-only server

`oke dev` also boots `okengine-docs-mcp` on port **6536** — the same protocol, but exposing the documentation itself instead of a live Manifest. It is how an agent answers "how do I … in OKE?" from the real pages rather than its training data.

| Fact     | Value                                  |
| -------- | -------------------------------------- |
| Port     | `6536` (moves upward when busy)        |
| Auth     | None — public documentation, read-only |
| Endpoint | `POST http://127.0.0.1:6536/mcp`       |
| Health   | `GET http://127.0.0.1:6536/health`     |
| Tools    | `oke.docs.search` · `oke.docs.get`     |

```json
{
  "mcpServers": {
    "okengine-docs": { "url": "http://127.0.0.1:6536/mcp" }
  }
}
```

The docs content ships inside the `okengine` package, so the index your agent searches is exactly the version you have installed. If the surface cannot boot (missing content, busy port), `oke dev` prints `Docs MCP skipped — …` and continues — docs search never takes your dev session down.

## Consume — `ai.mcpServer`

Your app can _call_ other MCP servers. Those tools are not a second loop — they join `fx.ask` / `ai.agent` the same way a Flow tool does.

```typescript
export const github = ai.mcpServer("github", {
  url: "https://mcp.example/github",
  auth: { bearer: githubToken },
  tools: ["create_issue"], // required allowlist
});

await fx.ask(triage, input, { tools: [github.tool("create_issue")] });
```

| Rule       | Meaning                                                                                           |
| ---------- | ------------------------------------------------------------------------------------------------- |
| Allowlist  | `tools` is required. Extra names from `tools/list` are dropped.                                   |
| Capability | `mcp:<server>/<tool>` on `effects.calls` — undeclared throws **OKE1007**.                         |
| Transport  | `url` (Streamable HTTP) **or** `command` + `args` (stdio). Not both.                              |
| Cancel     | HTTP aborts the fetch / SSE stream. stdio sends `notifications/cancelled` then kills the process. |

Console draws each declared server as one **AI** node on the flow graph; Units chips read `Call github → create_issue`; traces label the effect **MCP call**. There is no separate MCP page or connect UI.

## Learn more

- [Agent contracts](/docs/ai/skills) — what agents are taught about the system they operate
- [Flow](/docs/elements/flow) — the effects the MCP reads back

## Next

<Cards>
  <Card
    title="Skills"
    description="AGENTS.md and the shipped agent skills."
    href="/docs/ai/skills"
  />
  <Card
    title="llms.txt"
    description="llms.txt, llms.json, per-page markdown, /llms/agents."
    href="/docs/ai/llms-txt"
  />
  <Card
    title="AI element"
    description="Models inside your app — the other direction."
    href="/docs/elements/ai"
  />
</Cards>


# Skills (/docs/ai/skills)

Tools alone don't make a good operator — an agent also needs to know the _vocabulary_: what a Flow is, why `fx` is the only door, which driver ids are legal. OKE ships that knowledge as contracts and skills that agents load automatically, so sessions start aligned instead of drifting and getting corrected.

## The layers

| Layer              | Path                         | Loaded when                                       | Teaches                                                                |
| ------------------ | ---------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------- |
| **Agent contract** | `AGENTS.md` (repo root)      | Every agent session, automatically                | The one law, eight elements, one contract, the fx rule, ports, budgets |
| **Element skill**  | `.agents/skills/oke/`        | Building or changing an okengine app              | The element contract in depth — declaration patterns per element       |
| **Docs skill**     | `.agents/skills/oke-docs/`   | Writing or editing docs under `site/content/docs` | The documentation information-architecture standard and its gates      |
| **Ship skill**     | `.agents/skills/oke-ship/`   | After any implementation, before claiming done    | Changelog under `## Unreleased` + docs sync via `oke-docs`             |
| **Deps skill**     | `.agents/skills/oke-deps/`   | Updating `package.json` dependencies              | Scoped bumps, Bun install, pins (Drizzle RC, fumadocs alias, …)        |
| **Images skill**   | `.agents/skills/oke-images/` | Updating Compose image pins                       | Registry probe, pin style, catalog + recipes + Keel + docs lockstep    |

Contributor-only skills in the same tree (not required for app authors):
`oke-ci`, `oke-docs-update`, `oke-docs-visuals`, `oke-console-style`.

## AGENTS.md — the root contract

Every OKE app's repo carries an `AGENTS.md` that agents (Cursor, Claude Code, and peers) read at session start. It is deliberately short and absolute: every backend behavior is a Flow (`on(Trigger) → Effects`), there are eight elements bound to one contract, all world access goes through `fx`, drivers are named after protocols, and the ports/budgets are fixed. Its closing rule is the one that keeps agents honest: **if the documentation is silent, stop and ask** — never invent the API.

## Skills — installable know-how

Skills are `SKILL.md` packages an agent loads when the work matches their description. OKE ships:

| Skill        | Use it for                                   | Inside                                                                    |
| ------------ | -------------------------------------------- | ------------------------------------------------------------------------- |
| `oke`        | App work — flows, elements, drivers          | The agent contract, element patterns, the fx invariants                   |
| `oke-docs`   | Docs work — new pages, rewrites              | The page skeleton, verification sources, the density gate                 |
| `oke-ship`   | Closing an implementation — changelog + docs | Append under `## Unreleased`; `bun run bump` promotes it into `## vX.Y.Z` |
| `oke-deps`   | Dependency updates — one package or all      | Scope map, `ncu` + Bun, reject downgrades / protect RC and aliases        |
| `oke-images` | Compose image pins — one image or all        | Registry probe, keep pin style, never `:latest`, sync catalog → docs      |

All live in the repo under `.agents/skills/`, so they travel with the code and stay versioned with what they describe.

## How contracts compose with MCP

Contracts teach the _vocabulary_; [MCP](/docs/ai/mcp) grants the _hands_. An agent that knows the eight elements from `AGENTS.md` reads the Manifest through `oke.manifest.get` with the right mental model — and its write attempts still pass through [per-call human confirmation](/docs/ai/mcp), because knowing the system is not the same as being trusted by it.

## Learn more

- [MCP](/docs/ai/mcp) — the runtime surface these contracts pair with
- [The Architecture](/docs/understand/the-architecture) — the eight elements in human terms
- [llms.txt](/docs/ai/llms-txt) — `/llms.txt`, `/llms.json`, `/llms/agents`, per-page markdown

## Next

<Cards>
  <Card
    title="llms.txt"
    description="Machine-readable documentation surfaces."
    href="/docs/ai/llms-txt"
  />
  <Card title="MCP" description="Runtime server :6535." href="/docs/ai/mcp" />
  <Card title="AI element" description="Models inside your app." href="/docs/elements/ai" />
</Cards>


# Auth (/docs/client/auth)

`createClient(url, { auth })` attaches `api.auth` — session orchestration over the same `/auth/*`
Flows. Prefer that one call. `createAuthClient` + `bind` stays as a compose escape hatch.

For storefronts and SPAs attaching identity to typed calls.

<Callout title="The one rule">
  Gate on Flows is real authorization. `authorize` / `hasScope` / `Can` are **UI-only**. Cookie mode
  needs the `csrf` plugin (server soft-requires it when `gate.auth.cookies.enabled`); never persist
  tokens to Storage when cookies own the session.
</Callout>

## Smallest Example

<Steps>

<Step>
### One `createClient` with session auth

```typescript title="web/src/api.ts"
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "../../src/app";

export const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "", {
  auth: { mode: "cookie", csrfConfigured: true },
});
```

`api.auth` is an `AuthClient`. Unit Flows remain reachable (`api.auth.me`, …) under the same
namespace.

</Step>

<Step>
### Sign in and authorize chrome

```typescript
const result = await api.auth.signIn.email({ email: "alex@acme.co", password });
if (!result.ok) {
  if (result.twoFactor) {
    await api.auth.completeChallenge({
      challengeId: result.twoFactor.challengeId,
      code: totp,
    });
  }
  return;
}

const gate = api.auth.authorize({ all: ["orders:write"] });
if (gate.status === "allowed") showFulfillment();
await api.auth.signOut();
```

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Authorize", "Cookie mode", "SSR", "Methods", "Escape hatch"]}>

<Tab value="Authorize">

```typescript
const gate = api.auth.authorize({ all: ["orders:write"] });
// or: api.auth.authorize({ any: ["orders:write", "admin"] })

if (gate.status === "unauthenticated") redirectToSignIn();
if (gate.status === "loading") return;
if (gate.status === "denied") showMissing(gate.missing);
// gate.status === "allowed"
```

React: `<Can auth={api.auth} all={["orders:write"]} fallback={<Denied />}>`. Prefer
`authorize` / `Can` over bare `hasScope`. On server `Forbidden`, use `isForbidden` /
`forbiddenScopes` and refresh `getSession()` if scopes look stale.

</Tab>

<Tab value="Cookie mode">

When `gate.auth.cookies.enabled`, prefer HttpOnly cookies:

```typescript
export const api = createClient(app, base, {
  auth: { mode: "cookie", csrfConfigured: true },
});
```

Install `csrf({ allowNoHeader: false })`. Cross-origin also needs
`cors({ origin: [...], credentials: true })`. Prod boots **refuse** without `csrf` when cookies
are on; dev/test warn.

</Tab>

<Tab value="SSR">

```typescript
import { createClient } from "okengine/client";
import { tokenFromRequestCookies, createServerClient } from "okengine/client/auth";

// Explicit
const api = createClient(base, {
  credentials: "include",
  auth: {
    mode: "cookie",
    getToken: () => tokenFromRequestCookies(req),
  },
});

// Or the thin wrapper (same createClient)
const api2 = createServerClient(req, base, { $routes });
```

</Tab>

<Tab value="Methods">

```typescript
await api.auth.signIn.social({ provider: "google" });
await api.auth.signIn.passkey({ email });
await api.auth.signIn.magicLink.request({ email });
await api.auth.signIn.otp.verify({ email, code });
await api.auth.signIn.anonymous();
await api.auth.signUp.email({ email, password, name });
```

Helpers call existing plugin Flows only — PKCE / WebAuthn UV stay server-side.

</Tab>

<Tab value="Escape hatch">

```typescript
import { createClient } from "okengine/client";
import { createAuthClient } from "okengine/client/auth";

const shell = createClient(app, base);
const auth = createAuthClient(shell, { mode: "bearer", persist: "memory" });
const api = createClient(app, base, { ...auth.clientOptions });
auth.bind(api);
```

</Tab>

</Tabs>

## Helpers

| Helper                                           | Package                 | Role                             |
| ------------------------------------------------ | ----------------------- | -------------------------------- |
| `createClient({ auth })`                         | `okengine/client`       | Happy path — attaches `api.auth` |
| `createAuthClient`                               | `okengine/client/auth`  | Compose / bind escape hatch      |
| `tokenFromRequestCookies` / `createServerClient` | same                    | SSR cookie → token               |
| `authorize` / `hasScope` / `can`                 | on `AuthClient`         | UI-only chrome                   |
| `isUnauthorized` / `isCsrf` / `forbiddenScopes`  | same                    | Envelope narrowers               |
| `useSession` / `useAuthorize` / `Can`            | `okengine/client-react` | React session + chrome           |

## Denial codes

| Code           | HTTP | Typical fix                                    |
| -------------- | ---- | ---------------------------------------------- |
| `Unauthorized` | 401  | Sign in or refresh; re-login if still denied   |
| `Forbidden`    | 403  | Wrong scopes / CSRF — read `error.data.reason` |
| `RateLimited`  | 429  | Wait `retryAfterMs`                            |

Also see [CORS](/docs/plugins/cors) and [CSRF](/docs/plugins/csrf). Advanced create-oke starter
demos cookie + passkey + `<Can>`.

## Troubleshooting

<Accordions>

<Accordion title="Cookie mode warns about CSRF / prod refuses boot">
  Plug `csrf({ allowNoHeader: false })` when `gate.auth.cookies.enabled`. Set
  `csrfConfigured: true` on the client only after the plugin is installed.
</Accordion>

<Accordion title="authorize allowed but Flow returns Forbidden">
  Expected — client authorize is UI-only. Add `gate.scope` / `gate.policy` on the Flow.
</Accordion>

<Accordion title="localStorage warning">
  Prefer cookie mode or memory / `sessionStorage`. XSS can read Storage until family revoke.
</Accordion>

</Accordions>

## Learn more

- [Calling](/docs/client/calling) — envelopes, binary `{ response: "blob" }`, REST
- [React](/docs/client/react) — `useSession` / `Can` / `useAuthorize`
- [Gate · Auth](/docs/elements/gate/auth) — server cookies and scopes

## Next

<Cards>
  <Card title="Live" href="/docs/client/live" />
  <Card title="React" href="/docs/client/react" />
  <Card title="Gate Auth" href="/docs/elements/gate/auth" />
</Cards>


# Calling (/docs/client/calling)

`createClient` builds a typed proxy over your adopted Flows. HTTP triggers go out as REST from
`$routes`; untriggered Flows fall back to RPC. Every call returns `{ data, error }` — never a
thrown domain failure.

For developers wiring a storefront, mobile app, or partner service to an okengine backend (port
**6530**).

<Callout title="The one rule">
  Treat every call as a result envelope: `{ data, error }`. Switch on `error.code`. Only transport
  / protocol problems use `code: "TransportError"`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Create the client from the app

```typescript title="web/src/api.ts"
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "../../src/app";

export const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
```

</Step>

<Step>
### Confirm a booking and narrow the result

```typescript
const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });

if (error) {
  // show “booking not found” or a network banner
  return;
}

renderConfirmation(data.confirmationCode, data.seats);
```

Envelope:

```json
{
  "data": {
    "id": "bkg_7f3a",
    "confirmationCode": "SK-4812",
    "seats": 2,
    "status": "confirmed"
  },
  "error": null
}
```

</Step>

</Steps>

## Base URL (Vault + env)

Do not paste origins into source. Declare the public API origin as Vault config, then read it from
env in each runtime:

```typescript title="src/core/vault.ts"
import { vault } from "okengine/vault";
import { z } from "zod";

export const publicApiUrl = vault.config("PUBLIC_API_URL", {
  description: "Browser-facing API origin",
  schema: z.string().url(),
});
```

| Runtime                                    | Base URL for `createClient`                                     |
| ------------------------------------------ | --------------------------------------------------------------- |
| Browser / Bun (create-oke web)             | `vault.env("PUBLIC_API_URL") ?? ""` — empty = same-origin proxy |
| Node / worker / CLI                        | `vault.env.required("PUBLIC_API_URL")`                          |
| Separate storefront after `oke client add` | Same `vault.env("PUBLIC_API_URL") ?? ""`                        |

Set `PUBLIC_API_URL` via environment or `oke vault set`. Prefer `vault.env` over raw
`process.env` — same helpers as the rest of the measure. See
[Vault · Config](/docs/elements/vault/config).

## Progressive Patterns

From a simple read to typed domain failures and transport errors:

<Tabs items={["Minimal", "Narrow", "Failures", "Transport", "Binary"]}>

<Tab value="Minimal">

```typescript
const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });
if (error) return;
showSeatMap(data.seats);
```

</Tab>

<Tab value="Narrow">

```typescript
import { isOk } from "okengine/client";

const result = await api.orders.get({ id: "ord_9c2e" });

if (isOk(result)) {
  result.data.trackingNumber;
  result.data.eta;
}
```

</Tab>

<Tab value="Failures">

```typescript
import { isOk, isErrorCode } from "okengine/client";

const result = await api.bookings.create({
  flightId: "SK481",
  seats: 2,
  cabin: "economy",
});

if (isOk(result)) {
  result.data.confirmationCode;
} else if (result.error.code === "FlightFull") {
  offerWaitlist(result.error.data.seatsLeft);
} else if (isErrorCode(result.error, "NotFound")) {
  // shared helper narrowing
}
```

Prefer `error?.code === "FlightFull"` for inference; use `isErrorCode` in shared helpers.

</Tab>

<Tab value="Transport">

```typescript
import { isTransportError } from "okengine/client";

const result = await api.orders.list({ limit: 20, status: "shipped" });

if (result.error && isTransportError(result.error)) {
  showOfflineBanner(result.error.data.message);
  // optional: result.error.data.status
}
```

Network failure, abort (`timeout`), non-JSON body, or HTTP status without a `{ data, error }`
envelope — never a declared Flow code.

</Tab>

<Tab value="Binary">

```typescript
const { data, error } = await api.files.download({ id: "file_1" }, { response: "blob" });
if (error || !data) return;
a.href = URL.createObjectURL(data);
a.download = "report.pdf";
```

Same `{ data, error }` envelope; `{ response: "arrayBuffer" }` for raw bytes. Default remains
JSON envelopes.

</Tab>

</Tabs>

## `createClient` forms

| Form                            | Types from                             | Wire                                             |
| ------------------------------- | -------------------------------------- | ------------------------------------------------ |
| `createClient(app, url, opts?)` | `typeof app`                           | REST from `app.$routes`; untriggered flows → RPC |
| `createClient<App>(url, opts?)` | Explicit `App` type                    | RPC unless `opts.$routes` or `opts.routes`       |
| `createClient(url, opts?)`      | Ambient `Register` (`oke-client.d.ts`) | Same — pass routes for REST                      |

`url` is the app origin from `vault.env("PUBLIC_API_URL")` — empty string means same-origin.
See [Base URL](#base-url-vault--env).

**Consequence:** `createClient<App>(url)` alone types the proxy but still posts
`POST /_oke/{unit}/{flow}` until you pass the app value, `$routes`, or `routes`.

## REST vs RPC

| Situation                                             | Request                                                                                                                                                        |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP trigger with method + path on `$routes`          | That method and path (`:id` filled from input; leftover fields → query on GET/HEAD, JSON body otherwise). QUERY always sends JSON (`{}` when only path params) |
| Adopted flow with no HTTP trigger                     | `POST {base}/_oke/{unit}/{flow}` with JSON body                                                                                                                |
| Incomplete proxy path (`api.bookings()` with no flow) | Result error: `Incomplete path: api.bookings(…)`                                                                                                               |

```typescript
// REST — method/path from the bound HTTP trigger (file tree or explicit)
await api.bookings.get({ id: "bkg_7f3a" }); // GET /bookings/bkg_7f3a

// RPC — untriggered flow named bookings.manifest (internal tally)
await api.bookings.manifest({ flightId: "SK481" }); // POST /_oke/bookings/manifest
```

Full file-tree rules: [Routing](/docs/elements/flow/routing).

## How input becomes a request

Before the wire call, the client maps `input` onto the REST template or the RPC body:

1. **Path params** — `:id` (and friends) filled from matching input keys.
2. **Leftover fields** — query string on GET/HEAD; JSON body otherwise.
3. **QUERY** — always JSON (`{}` when only path params remain).
4. **RPC** — whole input as JSON on `POST /_oke/{unit}/{flow}`.

```typescript
await api.orders.get({ id: "ord_9c2e" });
// GET /orders/ord_9c2e

await api.orders.list({ limit: 20, q: "stockholm" });
// GET /orders?limit=20&q=stockholm  (when list is GET on $routes)

await api.bookings.create({ flightId: "SK481", seats: 2 });
// POST /bookings  with JSON body
```

## Call shapes

<Tabs items={["unit.flow", "List pager", "for await", "Empty 204"]}>

<Tab value="unit.flow">

```typescript
const { data, error } = await api.orders.get({ id: "ord_9c2e" });
if (error) return;
data.trackingNumber;
```

</Tab>

<Tab value="List pager">

```typescript
const page = await api.orders.list({ limit: 20, status: "open" });
const more = await page.next();

// meta.next / meta.prev are { cursor } bags — spread them, or use page.next()
```

TanStack `useInfiniteQuery` takes the bag, not the methods:

```typescript
useInfiniteQuery({
  queryKey: ["orders", status],
  queryFn: ({ pageParam }) => api.orders.list({ limit: 20, status, ...pageParam }),
  initialPageParam: {} as { cursor?: string },
  getNextPageParam: (last) => last.meta.next ?? undefined,
  getPreviousPageParam: (last) => last.meta.prev ?? undefined,
});
```

</Tab>

<Tab value="for await">

```typescript
for await (const page of api.orders.list({ limit: 20, status: "shipped" })) {
  for (const order of page.data) renderRow(order);
}
```

</Tab>

<Tab value="Empty 204">

```typescript
await api.orders.remove({ id: "ord_9c2e" }); // DELETE → 204, data undefined
```

</Tab>

</Tabs>

## Resources

Mount a resource, adopt the returned ops, then call the five Flows like any other:

```typescript
const ordersR = store.resource(db, orders, {/* in, out, list */});
const mounted = on(http.resource("/orders", ordersR.all()).gate(member));
// .adopt({ orders: mounted }) →
const page = await api.orders.list({ limit: 20, status: "open" });
await api.orders.get({ id: "ord_9c2e" }); // GET /orders/:id — NotFound when missing
await api.orders.remove({ id: "ord_9c2e" }); // DELETE → 204
```

See [Store](/docs/elements/store) for the list query language. Handwritten lists use
`fx.json.withQuery(rows, input)` for the same envelope. Auth posture:
[Gate](/docs/elements/gate). Live mounts: [Live](/docs/client/live).

## Options

| Option          | Type                                           | Default            | Meaning                                                        |
| --------------- | ---------------------------------------------- | ------------------ | -------------------------------------------------------------- |
| `fetch`         | `(input, init?) => Promise<Response>`          | `globalThis.fetch` | Inject a fetch implementation                                  |
| `headers`       | `Record<string, string>` \| pairs \| `() => …` | —                  | Static headers, or a getter per request                        |
| `timeout`       | `number` (ms)                                  | —                  | Abort after this many milliseconds                             |
| `retry.retries` | `number`                                       | `0`                | Extra attempts after the first (network / 5xx)                 |
| `retry.delay`   | `number` (ms)                                  | `50`               | Initial backoff delay                                          |
| `retry.backoff` | `number`                                       | `2`                | Multiplier after each retry                                    |
| `auth.getToken` | `() => string \| null \| …`                    | —                  | Bearer access token (or null) — see [Auth](/docs/client/auth)  |
| `auth.refresh`  | `() => Promise<string \| null \| …>`           | —                  | Runs once on HTTP 401, then the request retries                |
| `$routes`       | `ClientRouteMap`                               | —                  | Runtime map from `app.$routes` (REST when method+path present) |
| `routes`        | `Record<"unit.flow", { method, path }>`        | —                  | Flat REST table; wins over flattening `$routes`                |

## Result envelopes

Success may include optional top-level `meta` (for example pagination). Declared flow errors and
transport failures share the failure arm:

```typescript
import { isOk, isFail, isErrorCode, isTransportError } from "okengine/client";

const result = await api.bookings.create({ flightId: "SK481", seats: 2 });

if (isOk(result)) {
  result.data.confirmationCode;
} else if (result.error.code === "FlightFull") {
  result.error.data.seatsLeft;
} else if (isTransportError(result.error)) {
  result.error.data.message;
}

isFail(result); // true when error !== null
```

## Remote types

`oke dev` regenerates `oke-client.d.ts` from `GET /_oke/client.json`. A separate storefront repo —
pass the origin from Vault / env (never a hardcoded host in source):

```bash
oke client add "$PUBLIC_API_URL"
oke client add "$PUBLIC_API_URL" --out ./types/oke-client.d.ts
# also writes oke-client.routes.ts (or types/oke-client.routes.ts)
```

```typescript
import { createClient } from "okengine/client";
import { routes } from "./oke-client.routes.ts";

const api = createClient(import.meta.env.PUBLIC_API_URL ?? "", { $routes: routes });
```

Ambient `.d.ts` types `in` / `out` / live / stream stamps. The routes module supplies wire REST.

create-oke starters ship `web/`: Vite proxies Flow paths plus `/auth` and `/_oke` to the app.
Leave `PUBLIC_API_URL` unset in local web so `createClient("")` stays same-origin.

CLI details: [CLI Reference](/docs/reference/cli).

## Exports

| Export                                    | Kind      | Role                                                |
| ----------------------------------------- | --------- | --------------------------------------------------- |
| `createClient`                            | function  | Typed proxy `api.unit.flow(input?)` plus `api.live` |
| `flattenRoutes`                           | function  | `$routes` → flat `unit.flow` REST table             |
| `createTransport`                         | function  | Low-level HTTP transport (timeout / retry / auth)   |
| `isOk` / `isFail`                         | function  | Envelope predicates                                 |
| `isErrorCode` / `isTransportError`        | function  | Error narrowing                                     |
| `Client`, `ClientCall`, `ClientResult`, … | types     | Contracts, `page.next()` / `for await` of `list()`  |
| `Register`                                | interface | Module-augmentation slot for ambient App types      |
| `AppOf`                                   | type      | Brand a bare route map as an App                    |

Budget: the `./client` export stays under the measured client-runtime cap (hard gate in CI).

## Troubleshooting

<Accordions>

<Accordion title="api.bookings.get is not a function / type error">
  Confirm the Flow is `export`ed from a generated unit (`import "@/flows/generated"` then
  `oke({ name })`), or from a module you still `.adopt({ bookings })`.

Type `createClient` with that `App` (or ambient `Register` after `oke-client.d.ts` regenerates).
Restart `oke dev` after renaming exports.

</Accordion>

<Accordion title="Calls hit /_oke/… instead of my HTTP path">
  Types alone do not choose REST. Pass `createClient(app, url)`, or
  `createClient(url, { $routes: app.$routes })`, or an explicit `routes` map.
</Accordion>

<Accordion title='error.code is "TransportError"'>
  Network failure, abort (`timeout`), non-JSON body, empty error response, or HTTP status without a
  `{ data, error }` envelope. Declared flow codes (`NotFound`, `FlightFull`, …) never use this code.
  Message text lives in `error.data.message`; HTTP status may appear as `error.data.status`.
</Accordion>

<Accordion title="Failed to fetch …/_oke/client.json">
  `oke client add` needs a running app that serves the descriptor. Start the app (`oke dev` /
  `oke start`), check the URL, then retry. Usage when the URL is missing:
  `Usage: oke client add <url> [--out oke-client.d.ts]`.
</Accordion>

<Accordion title="Incomplete path: api.bookings(…)">
  The proxy path stopped at a unit. Call a Flow: `api.bookings.get({id})`, not `api.bookings()`.
</Accordion>

</Accordions>

## Learn more

- [Overview](/docs/client) — ClientLoop, Flows only
- [Auth](/docs/client/auth) — Bearer, refresh, denials
- [Live](/docs/client/live) — `api.live` and live queries
- [React](/docs/client/react) — hooks package
- [Vault · Config](/docs/elements/vault/config) — `PUBLIC_API_URL`, `vault.env`
- [Routing](/docs/elements/flow/routing) — `$routes` stamps
- [HTTP](/docs/elements/flow/http) — verbs and envelopes on the server
- [Errors](/docs/reference/errors) — framework codes vs failure values
- [CLI](/docs/reference/cli) — `oke client add`, `oke dev`

## Next

<Cards>
  <Card
    title="Auth"
    description="Sessions and gate denials on the client."
    href="/docs/client/auth"
  />
  <Card
    title="Live"
    description="SSE subscribe and live resource queries."
    href="/docs/client/live"
  />
  <Card
    title="Routing"
    description="Folders stamp HTTP paths and client units."
    href="/docs/elements/flow/routing"
  />
</Cards>


# Overview (/docs/client)

`okengine/client` is how a browser, CLI, or another service calls your app's Flows. Import the
generated barrel, take `typeof app`, and `api.bookings.get({ id })` is fully typed — same contracts
the server already has, no separate schema project.

For developers consuming an okengine app from the outside — create the client, call a Flow, narrow
the envelope.

<Callout title="The one rule">
  Treat every call as a result envelope: `{ data, error }`. Flow failures are values you switch on
  (`error.code`); they are never thrown. Only transport / protocol problems use
  `code: "TransportError"`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Export `App` from the server

```typescript title="src/app.ts"
import "@/core";
import "@/flows/generated";
import { oke } from "okengine/http";

export const app = oke({ name: "commerce" });
export type App = typeof app;
```

`.adopt({ bookings })` is optional and additive when a unit is not already in the generated barrel.

</Step>

<Step>
### Load a booking from the client

```typescript title="web/src/api.ts"
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "../../src/app";

// Base URL via vault.env (empty = same-origin proxy in create-oke web)
const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });

if (error) {
  // TransportError or a declared flow code (NotFound, …)
  return;
}

console.log(data.confirmationCode, data.seats);
```

With `$routes` wired that is `GET /bookings/bkg_7f3a` on the backend (port **6530**):

```json
{
  "data": {
    "id": "bkg_7f3a",
    "confirmationCode": "SK-4812",
    "seats": 2,
    "status": "confirmed"
  },
  "error": null
}
```

</Step>

</Steps>

## Progressive Patterns

Same typed proxy from same-repo REST to a separate frontend repo:

<Tabs items={["Same-repo", "Type-only", "Ambient Register"]}>

<Tab value="Same-repo">

Pass the app value so HTTP triggers hit REST (method + path from `$routes`):

```typescript
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "./app";

const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
await api.bookings.get({ id: "bkg_7f3a" }); // GET /bookings/bkg_7f3a
```

</Tab>

<Tab value="Type-only">

Types from an explicit `App` — still RPC until you pass routes:

```typescript
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import type { App } from "./app";
import { app } from "./app";

const api = createClient<App>(vault.env("PUBLIC_API_URL") ?? "", {
  $routes: app.$routes,
});
```

</Tab>

<Tab value="Ambient Register">

`oke dev` regenerates `oke-client.d.ts` from `GET /_oke/client.json`. A separate storefront repo
runs `oke client add` against your public API origin (from Vault / env):

```typescript
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";

const api = createClient(vault.env("PUBLIC_API_URL") ?? "");
// types from ambient Register in oke-client.d.ts
```

Declare the origin on the server with [Vault · Config](/docs/elements/vault/config)
(`PUBLIC_API_URL`), then set it via env or `oke vault set`. Leave it unset in local web
dev so `createClient("")` stays same-origin behind the Vite proxy.

</Tab>

</Tabs>

<ClientLoop />

## Flows only

<Callout title="Flows only">
  The client calls **Flows**. Every other element runs on the server through `fx`. You reach its
  outcome by calling a Flow that uses it — or by handling a gate denial on that call.
</Callout>

| Element                           | On the client | How                                                                                        |
| --------------------------------- | ------------- | ------------------------------------------------------------------------------------------ |
| [Flow](/docs/elements/flow)       | Direct        | `api.unit.flow(input)` — the only public surface                                           |
| [Gate](/docs/elements/gate)       | Indirect      | Bearer via `auth`; denials as `Unauthorized` / `Forbidden` / `RateLimited`                 |
| [Store](/docs/elements/store)     | Via Flows     | `fx.store` inside Flows; `store.resource` + `on(http.resource…)` → five Flows on `$routes` |
| [Signal](/docs/elements/signal)   | Live SSE      | `api.live(signal, input?, { onEvent })` — HTTP GET, callback + unsubscribe                 |
| [Clock](/docs/elements/clock)     | Via Flows     | Schedules fire on the server — the client never ticks a clock                              |
| [Vault](/docs/elements/vault)     | Via Flows     | Secrets stay server-side; never ship them to the browser package                           |
| [Channel](/docs/elements/channel) | Via Flows     | `fx.send` in a Flow — the client does not send email/SMS/push                              |
| [AI](/docs/elements/ai)           | Via Flows     | `fx.ask` / `fx.run` inside a Flow; the client gets that Flow’s `out`                       |

## Pages

<Cards>
  <Card
    title="Calling"
    description="createClient forms, REST vs RPC, options, envelopes, resources, remote types."
    href="/docs/client/calling"
  />
  <Card
    title="Auth"
    description="memorySession, Bearer refresh, and gate denials as values."
    href="/docs/client/auth"
  />
  <Card
    title="Live"
    description="api.live SSE, resume gaps, and live resource queries."
    href="/docs/client/live"
  />
  <Card
    title="React"
    description="useSession, useLive, useLiveQuery from okengine/client-react."
    href="/docs/client/react"
  />
</Cards>

## Learn more

- [Routing](/docs/elements/flow/routing) — folders are the URL; `$routes` without `.adopt()`
- [HTTP](/docs/elements/flow/http) — verbs, `http.resource`, live SSE
- [The Architecture](/docs/understand/the-architecture) — generated barrel → client → test loop; same contract for Client, Console, MCP
- [Errors](/docs/reference/errors) — framework codes vs failure values

## Next

<Cards>
  <Card
    title="Calling"
    description="REST vs RPC, options, helpers, and remote types."
    href="/docs/client/calling"
  />
  <Card
    title="Auth"
    description="Sessions and gate denials on the client."
    href="/docs/client/auth"
  />
  <Card
    title="Live"
    description="SSE subscribe and live resource queries."
    href="/docs/client/live"
  />
</Cards>


# Live (/docs/client/live)

`signal.live` is HTTP SSE. Expose with `.live(signal)` on GET (or `http.live(signal)` for
`GET /_oke/live/{name}`), then subscribe with a callback. `for await` stays on the server.

For developers streaming shipment status, checkout progress, or live inbox rows into a browser or
ops console.

<Callout title="The one rule">
  Subscribe with a callback and an unsubscribe function. Reconnects send `Last-Event-ID` from the
  last `id:` the client actually received. A **410** `LiveResumeGap` (**OKE1210**) means that cursor
  is gone.
</Callout>

## Smallest Example

<Steps>

<Step>
### Subscribe to a shipment feed

```typescript
import { shipmentStatus } from "@/signals/orders";

const stop = api.live(
  shipmentStatus,
  { orderId: "ord_9c2e" },
  {
    onEvent: (event) => {
      // { orderId, status: "packed" | "shipped" | "delivered", eta? }
      updateTrackingCard(event.status, event.eta);
    },
    onError: (err) => {
      showTrackingBanner(err);
    },
    autoResubscribe: false, // default — true reopens after a drop (500ms…30s backoff)
  },
);
```

</Step>

<Step>
### Clean up when the shopper leaves the page

```typescript
stop(); // useEffect cleanup / process exit
```

`api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing Flow.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Callback", "Flow-shaped", "autoResubscribe", "Live query"]}>

<Tab value="Callback">

```typescript
const stop = api.live(
  shipmentStatus,
  { orderId: "ord_9c2e" },
  {
    onEvent: (event) => updateTrackingCard(event.status, event.eta),
  },
);
stop();
```

</Tab>

<Tab value="Flow-shaped">

When the route is the exposing Flow itself:

```typescript
const stop = api.orders.events(
  { orderId: "ord_9c2e" },
  {
    onEvent: (event) => {
      updateTrackingCard(event.status, event.eta);
    },
  },
);
stop();
```

</Tab>

<Tab value="autoResubscribe">

```typescript
const stop = api.live(
  shipmentStatus,
  { orderId: "ord_9c2e" },
  {
    onEvent: (event) => updateTrackingCard(event.status, event.eta),
    onError: (err) => showTrackingBanner(err),
    autoResubscribe: true, // reopen after a drop — 500ms…30s backoff
  },
);
```

Reconnects send `Last-Event-ID` from the last `id:` the client actually received.

</Tab>

<Tab value="Live query">

When a resource opts into `live: true`, the compiler mounts `GET <path>/live` next to the CRUD
verbs. That route streams **classified** row events — not a shared tape — so each subscriber only
sees rows that still pass their RLS stamp + list filters (e.g. open tickets for this tenant).

Prefer [React · useLiveQuery](/docs/client/react) for UI. Core transport helpers live on
`okengine/client`.

</Tab>

</Tabs>

## Live handlers

| Option            | Type                 | Default | Meaning                                       |
| ----------------- | -------------------- | ------- | --------------------------------------------- |
| `onEvent`         | `(event) => void`    | —       | Required — each SSE payload                   |
| `onError`         | `(err) => void`      | —       | 4xx, envelope, network drop                   |
| `onOpen`          | `() => void`         | —       | Stream connected                              |
| `autoResubscribe` | `boolean`            | `false` | Reopen after drop (500ms…30s backoff)         |
| `via`             | `"unit.flow"` string | —       | Disambiguate when two exposures match equally |

## Exposure matching

The client picks the unique exposure whose `matchKey` fields are a subset of the input, preferring
the largest match (`{ orderId }` beats firehose). A tie needs `via: "unit.flow"`.

```typescript
api.live(
  shipmentStatus,
  { orderId: "ord_9c2e" },
  {
    onEvent: updateTrackingCard,
    via: "orders.events",
  },
);
```

Or call the exposing Flow directly: `api.orders.events(input, { onEvent })`.

## Resume and LiveResumeGap

Reconnects send `Last-Event-ID` from the last `id:` the client actually received.

A **410** `LiveResumeGap` (**OKE1210**) means that cursor is gone — `onError` fires, the cursor is
dropped, and `autoResubscribe` replays the remaining tape after backoff.

Server exposure: [Signal · Live](/docs/elements/signal/live) and
[HTTP · Live Streams](/docs/elements/flow/http#live-streams).

## Live queries (`store.resource({ live: true })`)

| `kind`    | Meaning                                                        |
| --------- | -------------------------------------------------------------- |
| `upsert`  | Row visible under stamp + query — merge/replace by primary key |
| `revoked` | Row left visibility (`reason: "rls"` \| `"query"`) — remove    |
| `delete`  | Row deleted in CDC — remove                                    |

Every `mutate` from `useLiveQuery` generates a client UUID sent as the `X-Oke-Mutation-Id`
header — the server echoes it onto that write's CDC events, so:

- Your own late SSE echoes never double-apply (pending-set dedupe).
- Reconnects replay-guard by event `seq` (`isReplayedEvent`).
- Manual `refetch()` re-runs only the HTTP list read; reconnects always do a full
  subscribe-protocol cycle (new snapshot + replay).

| State            | Meaning                                                             |
| ---------------- | ------------------------------------------------------------------- |
| `isLoading`      | Waiting for the first snapshot — no data yet                        |
| `isConnected`    | SSE stream is open                                                  |
| `isReconnecting` | Stream dropped after a successful load; reconnect backoff in flight |

**Consequence:** optimistic patches roll back automatically when the Flow returns `error !== null`.
Server CDC / the successful response clear the override so the next upsert is authoritative.

Full React wiring: [React](/docs/client/react).

## Troubleshooting

<Accordions>

<Accordion title="api.live throws Multiple live exposures">
  Two routes share the same match shape. Pass `via: "unit.flow"` or call the exposing flow
  (`api.orders.events(input, {onEvent})`).
</Accordion>

<Accordion title="onError sees LiveResumeGap / HTTP 410">
  The last `id:` is not on the server tape. The client drops the cursor. With `autoResubscribe:
  true` the next request omits `Last-Event-ID` and replays what remains.
</Accordion>

<Accordion title="No events after subscribe">
  Confirm the server exposed the signal (`.live(signal)` on GET or `http.live(signal)`), the
  `matchKey` fields are present in `input`, and gates allow the identity. See [Signal ·
  Live](/docs/elements/signal/live).
</Accordion>

</Accordions>

## Learn more

- [React](/docs/client/react) — `useLive`, `useLiveQuery`
- [Calling](/docs/client/calling) — typed proxy and envelopes
- [Signal · Live](/docs/elements/signal/live) — tape, resume, OKE1210
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — server exposure
- [Store](/docs/elements/store) — `live: true` on resources

## Next

<Cards>
  <Card title="React" description="useLive and useLiveQuery hooks." href="/docs/client/react" />
  <Card
    title="Signal · Live"
    description="Server tape, Last-Event-ID, resume gaps."
    href="/docs/elements/signal/live"
  />
  <Card title="Calling" description="createClient and REST vs RPC." href="/docs/client/calling" />
</Cards>


# React (/docs/client/react)

`okengine/client-react` is a separate package so core `okengine/client` stays under the size
budget. Hooks wrap the same typed client: session status, live SSE, and live resource lists with
optimistic `mutate`.

For developers building React storefronts and ops consoles on top of `createClient`.

<Callout title="The one rule">
  Pass the same `api` you built with `createClient`. Hooks never open a second client factory — they
  subscribe, call Flows, and clean up on unmount.
</Callout>

## Smallest Example

<Steps>

<Step>
### Create the client and session

```typescript title="web/src/session.ts"
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "../../src/app";

export const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "", {
  auth: { mode: "cookie", csrfConfigured: true },
});
```

</Step>

<Step>
### Show the signed-in shopper in the header

```typescript title="web/src/AccountMenu.tsx"
import { Can, useSession } from "okengine/client-react";
import { api } from "./session";

export function AccountMenu() {
  const { status, user, signOut } = useSession(api.auth!);

  if (status === "loading") return null;
  if (status === "unauthenticated") return <a href="/sign-in">Sign in</a>;

  return (
    <>
      <Can auth={api.auth!} all={["orders:write"]} fallback={null}>
        <a href="/fulfillment">Fulfillment</a>
      </Can>
      <button type="button" onClick={() => signOut()}>
        {user?.email}
      </button>
    </>
  );
}
```

`Can` / `useAuthorize` are UI-only — Gate on Flows remains real authz.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["useSession", "Can / useAuthorize", "useLive", "useLiveQuery", "mutate"]}>

<Tab value="useSession">

```typescript
import { useSession } from "okengine/client-react";

const { status, user, accessToken, refresh, signOut } = useSession(api.auth!);
```

| Field         | Meaning                                                 |
| ------------- | ------------------------------------------------------- |
| `status`      | `"loading"` \| `"authenticated"` \| `"unauthenticated"` |
| `user`        | `auth.me` payload or `null`                             |
| `accessToken` | Current Bearer from `memorySession` (or `null`)         |
| `refresh()`   | Re-run `auth.me`                                        |
| `signOut()`   | `session.clear()` + unauthenticated                     |

</Tab>

<Tab value="Can / useAuthorize">

```tsx
import { Can, useAuthorize } from "okengine/client-react";

<Can auth={api.auth!} all={["orders:write"]} fallback={<Denied />}>
  <FulfillmentLink />
</Can>;

const { status, missing } = useAuthorize(api.auth!, { all: ["orders:write"] });
```

UI chrome only — Gate on Flows is the security boundary. Prefer these over bare `useScope` /
`useCan`.

</Tab>

<Tab value="useLive">

```typescript
import { useLive } from "okengine/client-react";
import { shipmentStatus } from "@/signals/orders";

const { events, latest, error, isConnected } = useLive(
  api,
  shipmentStatus,
  { orderId: "ord_9c2e" },
  {
    autoResubscribe: true,
  },
);

// latest?.status → "packed" | "shipped" | "delivered"
```

Cleanup calls `stop()` on unmount. Changing `signal` / `input` / `via` resets `events`.

| Option            | Meaning                                |
| ----------------- | -------------------------------------- |
| `autoResubscribe` | Reopen after drop (same as `api.live`) |
| `via`             | Disambiguate equal exposure matches    |

</Tab>

<Tab value="useLiveQuery">

```typescript
import { useLiveQuery } from "okengine/client-react";

const { data, error, isLoading, isConnected, isReconnecting, refetch, mutate } = useLiveQuery({
  api,
  listFlow: api.inbox.list,
  query: { status: "open", assignee: "me" },
  live: { method: "GET", path: "/inbox/live" }, // from app.$routes
  options: {
    enabled: session.status === "authenticated", // default true — idle when false
    refreshKey: workspaceId, // switch workspace → full re-subscribe
    onAuthRefresh: onAuthRefreshed, // auth.refresh() → new snapshot + replay
  },
});
```

| State            | Meaning                                                             |
| ---------------- | ------------------------------------------------------------------- |
| `isLoading`      | Waiting for the first snapshot — no data yet                        |
| `isConnected`    | SSE stream is open                                                  |
| `isReconnecting` | Stream dropped after a successful load; reconnect backoff in flight |

</Tab>

<Tab value="mutate">

```typescript
await mutate(
  api.inbox.update,
  { id: ticketId, status: "resolved" },
  {
    optimistic: (rows) =>
      rows.map((row) => (row.id === ticketId ? { ...row, status: "resolved" } : row)),
    pkOf: (input) => input.id,
  },
);
```

Every `mutate` call generates a client UUID sent as the `X-Oke-Mutation-Id` header — the server
echoes it onto that write's CDC events for pending-set dedupe and replay guards.

**Consequence:** optimistic patches roll back automatically when the Flow returns `error !== null`.
Server CDC / the successful response clear the override so the next upsert is authoritative.

</Tab>

</Tabs>

## Package boundary

| Export                                    | Package                 | Role                                        |
| ----------------------------------------- | ----------------------- | ------------------------------------------- |
| `createClient` / `api.live`               | `okengine/client`       | Typed proxy + SSE subscribe                 |
| `createAuthClient` / `memorySession`      | `okengine/client/auth`  | Secure session orchestration                |
| `vault` / `vault.env`                     | `okengine/vault`        | Env + config contracts (subpath — not root) |
| `useSession` / `useLive` / `useLiveQuery` | `okengine/client-react` | React hooks (`react` optional peer)         |
| `subscribeLiveResource`                   | `okengine/client-react` | Non-hook live resource stream helper        |

Prefer [`createAuthClient`](/docs/client/auth) for cookie/Bearer sessions and method helpers.
`useLiveQuery` accepts `listPath` to derive `GET ${listPath}/live` when `live` is omitted.

## `useLiveQuery` options

| Option          | Meaning                                        |
| --------------- | ---------------------------------------------- |
| `enabled`       | Default `true` — idle when `false`             |
| `refreshKey`    | Identity change → full re-subscribe            |
| `onAuthRefresh` | After `auth.refresh()` → new snapshot + replay |

`refetch()` re-runs only the HTTP list read; reconnects always do a full subscribe-protocol cycle
(new snapshot + replay). Event kinds and resume physics: [Live](/docs/client/live).

## Troubleshooting

<Accordions>

<Accordion title="Cannot find module okengine/client-react">
  Install / import the React package separately. Core `okengine/client` does not re-export hooks —
  that keeps the client runtime under budget.
</Accordion>

<Accordion title="useSession stuck on loading">
  Confirm `auth.me` is adopted, Bearer `getToken` returns a token, and the me Flow is reachable.
  Without a token, status becomes `"unauthenticated"`.
</Accordion>

<Accordion title="useLiveQuery never connects">
  Pass `live: { method, path }` from `app.$routes` for the resource’s `/live` route. Set
  `enabled: true` (or omit). See [Live](/docs/client/live) for exposure and resume errors.
</Accordion>

</Accordions>

## Learn more

- [Auth](/docs/client/auth) — `memorySession` and gate denials
- [Live](/docs/client/live) — `api.live`, resume, live query kinds
- [Calling](/docs/client/calling) — envelopes and list pager
- [Signal · Live](/docs/elements/signal/live) — server tape
- [Store](/docs/elements/store) — `live: true` resources

## Next

<Cards>
  <Card
    title="Live"
    description="SSE subscribe and live resource physics."
    href="/docs/client/live"
  />
  <Card title="Auth" description="Bearer sessions behind the hooks." href="/docs/client/auth" />
  <Card title="Calling" description="createClient forms and options." href="/docs/client/calling" />
</Cards>


# Elements (/docs/elements)

Forty backend concerns collapse into eight semantic primitives. An element earns its place only with **irreducible physics**.

<Callout title="Closed primitive set">
  There is no ninth element. Every backend capability belongs to one of the eight elements. New
  infrastructure creates a protocol driver or plugin, preserving a small mental model.
</Callout>

```text
Elements define the model.
Plugins extend the model's capabilities.
Drivers connect the model to infrastructure.
Providers and Recipes choose where that infrastructure runs.
```

## The Structural Anatomy

Every backend concern is mapped across three conceptual tiers:

| Tier             | Question Answered                   | Definition & Scope                                                          |
| ---------------- | ----------------------------------- | --------------------------------------------------------------------------- |
| **Elements**     | _What is the thing?_                | The 8 closed primitives representing irreducible backend physics.           |
| **Capabilities** | _What can it do?_                   | Explicit facets, triggers, and execution mechanics within each element.     |
| **Composition**  | _What happens when they cooperate?_ | Emergent system properties (Realtime, Live Queries, Multi-Tenancy, Agents). |

## The Eight Primitives

<Cards>
  <Card
    title="Flow"
    description="Behavior — on(Trigger) → Effects via fx. Unified endpoints, jobs, consumers, and durable sagas."
    href="/docs/elements/flow"
  />
  <Card
    title="Signal"
    description="Data in motion — once, broadcast, and live with mandatory delivery physics."
    href="/docs/elements/signal"
  />
  <Card
    title="Store"
    description="Data at rest — SQL, KV, files with image transforms, and search behind one handle."
    href="/docs/elements/store"
  />
  <Card
    title="Clock"
    description="Time — schedules, intervals, durable sleep, and deterministic time travel."
    href="/docs/elements/clock"
  />
  <Card
    title="Gate"
    description="Permission to act — auth, tenancy, ABAC/RBAC, and rate limits at the trigger."
    href="/docs/elements/gate"
  />
  <Card
    title="Vault"
    description="Protected knowledge — fail-loud secret contracts, dynamic config, and redacted values."
    href="/docs/elements/vault"
  />
  <Card
    title="Channel"
    description="Reaching humans — email, SMS, WhatsApp, and push with built-in consent."
    href="/docs/elements/channel"
  />
  <Card
    title="AI"
    description="Reaching machine intelligence — models, prompts, agents, and MCP tool boundaries."
    href="/docs/elements/ai"
  />
</Cards>

## See How They Compose

The closed elements compose into full backend capabilities:

| Composition Target | Formula                                         | How It Works                                              |
| ------------------ | ----------------------------------------------- | --------------------------------------------------------- |
| **Realtime**       | Store + Gate + Signal + Flow → Live Query       | CDC changes re-checked against Gate RLS, streamed as SSE  |
| **Security**       | Gate + Store/RLS + Tenant + fx → Secure runtime | Tenant-isolated queries with least-privilege capability   |
| **Agents**         | MCP + Gate + OAuth + Flow → Agent-ready backend | Declared Flows exposed on :6535 behind auth confirmation  |
| **Operations**     | Manifest + Effects + Runs → Inspectable backend | Inferred effects feed Console (:6533) and wide-event logs |


# Azure Cache for Redis (/docs/providers/azure-redis)

Azure Cache for Redis spans Basic → Enterprise. New caches expect TLS.
`drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Build `rediss://` from **Overview** hostname + **Access keys** primary key + SSL port **6380**. Do
  not use 6379 unless you explicitly allowed non-TLS.
</Callout>

## Find credentials (current portal)

1. Azure Portal → your **Azure Cache for Redis** resource.
2. **Overview** → copy **Host name** (`….redis.cache.windows.net`).
3. **Settings → Authentication** → **Access keys** tab (or **Overview → Show access
   keys**) → copy **Primary** key.
4. Ports: Overview link next to **Ports**, or docs defaults — **6380** TLS, **6379**
   non-TLS (disabled by default on new caches).
5. Enterprise tiers may use port **10000** — check Overview for that SKU.

```bash title="process env"
REDIS_URL=rediss://:PRIMARY_KEY@my-cache.redis.cache.windows.net:6380
```

Azure's “connection string” blade often shows StackExchange-style
`host:6380,password=…,ssl=True` — translate that to `rediss://:password@host:6380` for
oke.

## Production guidance

| Tier                 | Notes                                   |
| -------------------- | --------------------------------------- |
| Basic                | Shared, **no SLA** — not for production |
| Standard             | Replicated                              |
| Premium / Enterprise | Clustering, persistence, VNet           |

Non-TLS: **Settings → Advanced settings → Allow access only via SSL = No** — avoid in
production. Entra ID auth exists on newer tiers; oke's URL driver expects access-key
auth in `REDIS_URL`.

## Real gotcha — port 6380 vs 6379

Using `redis://…:6379` against a TLS-only cache fails. New caches disable non-TLS;
always start from **6380** + `rediss://` unless you intentionally opened 6379.

## Troubleshooting

<Accordions>
<Accordion title="Connection reset / SSL errors on 6379">

TLS-only cache. Switch to port **6380** and `rediss://`. Confirm Advanced settings still
require SSL.

</Accordion>
<Accordion title="WRONGPASS invalid username-password pair">

Primary key rotated or secondary key pasted by mistake. Re-copy **Primary** from
**Authentication → Access keys** and update `REDIS_URL` (empty username, key as
password).

</Accordion>
</Accordions>

## Learn more

- [Redis (image)](/docs/recipes/redis)
- [ElastiCache](/docs/providers/elasticache) · [Memorystore](/docs/providers/memorystore)
- [Store · KV](/docs/elements/store#kv)

## Next

<Cards>
  <Card title="ElastiCache" description="AWS's equivalent." href="/docs/providers/elasticache" />
  <Card title="Memorystore" description="GCP's equivalent." href="/docs/providers/memorystore" />
  <Card
    title="CockroachDB"
    description="Managed SQL-side pair."
    href="/docs/providers/cockroachdb"
  />
</Cards>


# CockroachDB (/docs/providers/cockroachdb)

CockroachDB speaks Postgres wire closely enough that `drivers.store.sql` stays
`postgres`. Multi-region and distributed transactions are Cockroach's concern beneath
that protocol.

<Callout title="The one rule">
  Use the Cloud Console **Connect** dialog and keep `sslmode=verify-full` with the downloaded CA —
  `sslmode=require` alone is rejected for secure clusters.
</Callout>

## Find credentials (current console)

1. Open [CockroachDB Cloud](https://cockroachlabs.cloud) → your **cluster**.
2. Click **Connect** (cluster overview / top right).
3. Open the **Connection string** tab (or **General connection string**).
4. Expand **Download CA Cert** — run the provided command so `root.crt` lands in the
   default Postgres cert directory (or note the path).
5. Copy the `postgresql://…` string; paste the password when prompted (shown once for
   new SQL users — reset under **SQL Users** if lost).

```bash title="process env"
DATABASE_URL=postgresql://user:password@….cockroachlabs.cloud:26257/defaultdb?sslmode=verify-full&sslrootcert=/path/to/root.crt
```

Default SQL port is **26257**, not 5432.

## Production guidance

| Topic   | Guidance                                                                                |
| ------- | --------------------------------------------------------------------------------------- |
| TLS     | `verify-full` + `sslrootcert` — required for Cloud                                      |
| Pooling | Prefer Cockroach's guidance / built-in limits; do not assume Neon-style `-pooler` hosts |
| Regions | Configure survivability in the Console — opaque to the `postgres` driver                |

CockroachDB Software License (since Nov 2024): Core open-source discontinued; free for
orgs under $10M ARR with community support; paid tiers add dedicated support. Not OSI
open source.

## Query performance

<Callout title="Unsupported">
  CockroachDB does not expose `pg_stat_statements`. Store → **Performance** returns
  `PgStatStatementsUnsupported`. oke does not shim `statement_statistics`.
</Callout>

| Step    | What you do                                      |
| ------- | ------------------------------------------------ |
| Preload | Not applicable                                   |
| Create  | Not applicable                                   |
| Console | Structured unavailable — use Cockroach's console |

## Real gotcha — CA cert path

Copying the connection string without downloading the CA yields TLS verify failures
even when the password is correct. The Connect dialog's download command and
`sslrootcert=` must agree on the same file path in the environment that runs the app.

## Troubleshooting

<Accordions>
<Accordion title="certificate verify failed / SSL connection error">

Missing or wrong `sslrootcert`. Re-download from **Connect → Download CA Cert**, point
`sslrootcert` at that file, keep `sslmode=verify-full`. Using `require` without verify
is not accepted for Cloud's default secure posture.

</Accordion>
<Accordion title="password authentication failed for user">

Password is shown once at user creation. Reset under the cluster **SQL Users** page,
then update `DATABASE_URL`.

</Accordion>
</Accordions>

## Learn more

- [YugabyteDB](/docs/providers/yugabytedb) — Apache 2.0 distributed alternative
- [CockroachDB (self-hosted)](/docs/recipes/cockroachdb) — single-node Docker recipe
- [Postgres (image)](/docs/recipes/postgres) — driver this backs
- [Store · SQL](/docs/elements/store#sql) — schema workflows

## Next

<Cards>
  <Card
    title="YugabyteDB"
    description="Apache-2.0 licensed alternative."
    href="/docs/providers/yugabytedb"
  />
  <Card title="Neon" description="Serverless single-region option." href="/docs/providers/neon" />
  <Card title="PgDog" description="Self-hosted pooling." href="/docs/recipes/pgdog" />
</Cards>


# DigitalOcean Managed Caching (/docs/providers/digitalocean-caching)

DigitalOcean's managed Redis offering moved to **Valkey** (Managed Caching) after Aiven
stepped back from Redis. Wire protocol unchanged — `drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Databases → cluster → **Overview → Connection Details**. Copy the `rediss://` URI (public or
  private). Add the client to **Trusted Sources** or you get connection refused.
</Callout>

## Find credentials (current control panel)

1. [cloud.digitalocean.com/databases](https://cloud.digitalocean.com/databases) → click
   the **Valkey / Caching** cluster.
2. **Overview** → **Connection Details**:
   - Toggle **Public network** vs **Private network** (VPC)
   - Copy host, port, user, password, or the assembled connection string
3. Password is hidden until you reveal/copy it in that panel.
4. **Network Access** / trusted sources: add app Droplet, App Platform app, or VPC CIDR
   (from Overview **VPC Network**) before connecting.

```bash title="process env"
REDIS_URL=rediss://default:PASSWORD@db-valkey-nyc1-….db.ondigitalocean.com:25061
```

Port is a **random high port** from Connection Details — not `6379`. TLS is required;
there is no plaintext option.

## Production guidance

| Topic           | Guidance                                                                              |
| --------------- | ------------------------------------------------------------------------------------- |
| Engine          | **Valkey** on new clusters; older “Managed Redis” capped / unsupported past Redis 7.2 |
| TLS             | Always — use `rediss://`                                                              |
| Trusted sources | Hard firewall — missing entry ⇒ refused even with correct password                    |
| VPC             | Prefer private connection string + VPC CIDR as a single trusted source                |

Standalone **Upstash-on-DigitalOcean marketplace** listings are a different, deprecated
product — not this Databases service.

## Real gotcha — trusted sources

Correct `REDIS_URL` from a laptop not in Trusted Sources fails with connection refused.
Add your IP (or use a Droplet already allowed / VPC CIDR) under **Network Access**.

## Troubleshooting

<Accordions>
<Accordion title="Connection refused (hostname)">

Wrong port, or client IP not in trusted sources. Re-copy port from **Connection
Details**; add the client under **Network Access → Trusted Sources**.

</Accordion>
<Accordion title="Connection refused (IP) on private hostname">

Private URL used from outside the VPC. Switch to **Public network** details (and allow
your IP), or run the app on a Droplet in the same VPC with the private URI.

</Accordion>
</Accordions>

## Learn more

- [Valkey (image)](/docs/recipes/valkey) — self-hosted peer
- [Store · KV](/docs/elements/store#kv)
- [Dragonfly Cloud](/docs/providers/dragonfly-cloud) — alternative managed KV

## Next

<Cards>
  <Card
    title="Dragonfly Cloud"
    description="Another managed alternative."
    href="/docs/providers/dragonfly-cloud"
  />
  <Card
    title="Redis Cloud"
    description="Vendor-native alternative."
    href="/docs/providers/redis-cloud"
  />
  <Card title="Caddy" description="Local proxy pairing." href="/docs/recipes/caddy" />
</Cards>


# Dragonfly Cloud (/docs/providers/dragonfly-cloud)

Dragonfly Cloud runs the same multi-threaded engine as the
[Dragonfly image](/docs/recipes/dragonfly), with a managed endpoint.
`drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Click the **data store row** to open the side drawer — copy the **Redis-compatible Connection
  URI** (includes passkey). Paste it as `REDIS_URL`.
</Callout>

## Find credentials (current console)

1. Open Dragonfly Cloud → **Data Stores**.
2. Wait until **Status** is **Active**.
3. Click the data store **row** (opens the configuration drawer).
4. Copy:
   - **Redis-compatible Connection URI** (e.g. `rediss://default:PASS@….dragonflydb.cloud:6385`)
   - or host / port / `default` user / passkey separately
5. Paste the URI into `REDIS_URL` unchanged when it already uses `rediss://`.

```bash title="process env"
REDIS_URL=rediss://default:PASSKEY@xxxxx.dragonflydb.cloud:6385
```

Smoke-check: `redis-cli -u "$REDIS_URL" PING`.

## Production guidance

| Topic           | Guidance                                                                     |
| --------------- | ---------------------------------------------------------------------------- |
| Public network  | TLS + passkey **on** by default — leave TLS enabled                          |
| Private network | Recommended for production (peering) — passkey still required on public      |
| HA              | Enable high availability in the data store settings before you need failover |
| Driver          | Same `redis` driver as self-hosted Dragonfly                                 |
| Port            | Drawer URI often uses **6385** (not 6379) — copy the URI wholesale           |

Self-hosting comparison: the [Dragonfly image](/docs/recipes/dragonfly) needs
`OKE_STORE_KV_PASSWORD` + `memlock`; Cloud replaces that with the drawer passkey.

Do not reuse a self-hosted `REDIS_URL` without `rediss://` when the drawer shows TLS.

## Real gotcha — connecting before Active

The drawer is visible while the store is still provisioning; clients get connection
errors until **Status = Active**. Wait for Active before wiring CI or app boot.

## Troubleshooting

<Accordions>
<Accordion title="Connection refused / timeout while Status is not Active">

Provisioning. Retry when the console shows **Active**. Do not rotate the passkey yet —
confirm status first.

</Accordion>
<Accordion title="NOAUTH / WRONGPASS">

URI missing the passkey, or passkey rotated in the drawer without updating env. Re-copy
the Connection URI from the data store drawer after any rotation.

</Accordion>
</Accordions>

## Learn more

- [Dragonfly (image)](/docs/recipes/dragonfly) — self-hosted peer
- [Store · KV](/docs/elements/store#kv)
- [Upstash](/docs/providers/upstash) — serverless alternative

## Next

<Cards>
  <Card title="Upstash" description="Serverless-friendly option." href="/docs/providers/upstash" />
  <Card title="Redis Cloud" description="Vendor-native Redis." href="/docs/providers/redis-cloud" />
  <Card title="YugabyteDB" description="Managed SQL-side pair." href="/docs/providers/yugabytedb" />
</Cards>


# Amazon ElastiCache (/docs/providers/elasticache)

ElastiCache runs Redis OSS-compatible or Valkey engines inside your VPC. Both speak the
same wire protocol — `drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Copy the **Primary Endpoint** (cluster mode disabled) from the ElastiCache console — not a replica
  reader endpoint. Use `rediss://` when encryption in transit is on.
</Callout>

## Find credentials (current console)

1. AWS Console → **ElastiCache** → **Valkey caches** or **Redis OSS caches**.
2. Click the **cluster name** (not only the radio button).
3. On the cluster detail page, copy:
   - **Primary Endpoint** (+ port, usually `6379`) for cluster-mode **disabled**
   - **Configuration Endpoint** for cluster-mode **enabled** (needs a cluster-aware
     client — oke's single-URL driver expects non-cluster / primary)
4. Auth: cluster **Connectivity** / **Auth token** (or Secrets Manager reference) —
   set at creation or rotation; there is no “password eye” identical to Redis Cloud.
5. Build `REDIS_URL` yourself:

```bash title="process env"
# Encryption in transit ON (default on many new clusters)
REDIS_URL=rediss://:AUTH_TOKEN@my-cluster.xxxxx.ng.0001.use1.cache.amazonaws.com:6379

# Transit encryption OFF
# REDIS_URL=redis://:AUTH_TOKEN@my-cluster.xxxxx.ng.0001.use1.cache.amazonaws.com:6379
```

## Production guidance

| Topic              | Guidance                                                                            |
| ------------------ | ----------------------------------------------------------------------------------- |
| Networking         | **VPC-only** — app must share VPC, peering, or PrivateLink; no public hostname      |
| Engine             | Redis OSS **or** Valkey — same `redis` driver                                       |
| Cluster mode       | Prefer **disabled** + Primary Endpoint for oke's URL-shaped client                  |
| Transit encryption | When enabled, scheme must be `rediss://`                                            |
| AUTH               | Set at create/rotate — ElastiCache does not show a Redis-Cloud-style “eye” password |

Reader endpoints exist for read scaling; oke's single `REDIS_URL` should target the
**Primary Endpoint** so writes and Gate/Signal counters hit the primary.

## Real gotcha — Redis OSS version cap

ElastiCache **Redis OSS tops out at 7.1**. Versions **7.2+ are Valkey-only**. You can
in-place upgrade Redis OSS → Valkey 7.2; planning a “Redis 7.2” engine on ElastiCache
is a category error — pick Valkey or stay ≤7.1 on Redis OSS.

## Troubleshooting

<Accordions>
<Accordion title="Connection timed out from laptop / CI">

Expected — no public endpoint. Run the app in the VPC (ECS/EKS/EC2) or use a bastion /
VPN. Security groups must allow the app SG → cache port.

</Accordion>
<Accordion title="WRONGPASS / NOAUTH after enabling AUTH">

Token not embedded in `REDIS_URL`, or still using `redis://` against a TLS-required
endpoint. Match scheme to **Encryption in-transit**, put the token after `rediss://:`.

</Accordion>
</Accordions>

## Learn more

- [Redis (image)](/docs/recipes/redis) · [Valkey (image)](/docs/recipes/valkey)
- [Memorystore](/docs/providers/memorystore) — GCP equivalent
- [Store · KV](/docs/elements/store#kv)

## Next

<Cards>
  <Card title="Memorystore" description="GCP's equivalent." href="/docs/providers/memorystore" />
  <Card
    title="Azure Cache for Redis"
    description="Azure's equivalent."
    href="/docs/providers/azure-redis"
  />
  <Card
    title="Redis Cloud"
    description="Vendor-neutral managed option."
    href="/docs/providers/redis-cloud"
  />
</Cards>


# Providers (/docs/providers)

Every provider below speaks a protocol oke already drives — Postgres wire or Redis wire.
Hand the connection string to that driver; no new driver id, no Flow code changes.

These managed providers are real, working infrastructure choices behind the same two templates (`standard`, `advanced`) and the same eight elements — not alternative products or separate frameworks.

<Callout title="The one rule">
  Vendor choice lives in a connection URL (`DATABASE_URL` / `REDIS_URL`) — never in
  `drivers.store.sql` / `drivers.store.kv`, which only ever say `postgres` or `redis`.
</Callout>

## SQL providers

Managed Postgres-wire databases — set `DATABASE_URL`, driver stays `postgres`.

<Cards>
  <Card
    title="Neon"
    description="Connect widget · pooled vs direct · cold start."
    href="/docs/providers/neon"
  />
  <Card
    title="Supabase"
    description="Connect modes · :6543 vs :5432 · free pause."
    href="/docs/providers/supabase"
  />
  <Card
    title="CockroachDB"
    description="Connect dialog · verify-full · CA cert."
    href="/docs/providers/cockroachdb"
  />
  <Card
    title="YugabyteDB"
    description="YSQL on :5433 · Connect → Application."
    href="/docs/providers/yugabytedb"
  />
</Cards>

## Redis providers

Managed Redis-wire caches — set `REDIS_URL`, driver stays `redis`.
`{ durable: true }` KV lives in your SQL database, not a second Redis.

<Cards>
  <Card
    title="Redis Cloud"
    description="Configuration tab · Connect wizard · rediss://"
    href="/docs/providers/redis-cloud"
  />
  <Card
    title="Amazon ElastiCache"
    description="Primary Endpoint · Redis OSS ≤7.1."
    href="/docs/providers/elasticache"
  />
  <Card
    title="Google Memorystore"
    description="Primary Endpoint IP · TLS on 6378."
    href="/docs/providers/memorystore"
  />
  <Card
    title="Azure Cache for Redis"
    description="Access keys · TLS port 6380."
    href="/docs/providers/azure-redis"
  />
  <Card
    title="Upstash"
    description="TCP rediss:// — not REST tokens."
    href="/docs/providers/upstash"
  />
  <Card
    title="Dragonfly Cloud"
    description="Data store drawer · Connection URI."
    href="/docs/providers/dragonfly-cloud"
  />
  <Card
    title="DigitalOcean Managed Caching"
    description="Connection Details · trusted sources."
    href="/docs/providers/digitalocean-caching"
  />
</Cards>

## Learn more

- [Recipes](/docs/recipes) — self-hosted recipes for the same protocols
- [Store](/docs/elements/store) — `sql` / `kv` facets
- [Environment variables](/docs/reference/environment-variables) — URL precedence

## Next

<Cards>
  <Card
    title="Neon"
    description="Start with a serverless SQL option."
    href="/docs/providers/neon"
  />
  <Card title="Recipes" description="Run the same protocols yourself." href="/docs/recipes" />
  <Card title="Store" description="The facets these drivers back." href="/docs/elements/store" />
</Cards>


# Google Cloud Memorystore (/docs/providers/memorystore)

Memorystore for Redis (and the Valkey offering) is private-IP only inside your VPC.
`drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Open the instance → **Connections** for **Primary Endpoint** + port; **Security** for the AUTH
  string. There is no public hostname to paste.
</Callout>

## Find credentials (current console)

1. Google Cloud Console → **Memorystore** → **Redis** (or Valkey) → click **Instance ID**.
2. **Connections** section → copy **Primary Endpoint** (private IP) and **Port**
   (usually `6379`).
3. If AUTH is enabled: **Security** section → **AUTH string** (or
   `gcloud redis instances get-auth-string INSTANCE --region=REGION`).
4. If **in-transit encryption** is on: download the **TLS server certificate** from the
   instance page; clients use port **6378** with that CA (not plain 6379).

```bash title="process env"
# AUTH on, transit encryption off (common default)
REDIS_URL=redis://:AUTH_STRING@10.0.0.3:6379

# Transit encryption on → port 6378 + rediss:// + CA configured in the client
# REDIS_URL=rediss://:AUTH_STRING@10.0.0.3:6378
```

Cloud Run / Cloud Functions need a **Serverless VPC Access** connector on the same
network.

## Production guidance

| Topic      | Guidance                                                                         |
| ---------- | -------------------------------------------------------------------------------- |
| Networking | Private IP only — never a public endpoint option                                 |
| AUTH       | Optional but recommended; string is on the instance Security panel               |
| TLS        | **Not** default — enable in-transit encryption explicitly; port becomes **6378** |
| Tiers      | Basic vs Standard (replica) — pick before you need HA                            |

## Real gotcha — TLS port flip

Enabling in-transit encryption changes the client port to **6378** and requires the
server CA. Leaving `REDIS_URL` on `:6379` after enabling TLS looks like a mysterious
timeout — update port and scheme together.

## Troubleshooting

<Accordions>
<Accordion title="Connection refused / timeout from Cloud Run">

Missing Serverless VPC Access connector, or connector on the wrong VPC. Memorystore is
unreachable from the public internet by design.

</Accordion>
<Accordion title="NOAUTH Authentication required">

AUTH enabled but password omitted from `REDIS_URL`. Copy the AUTH string from
**Security** and use `redis://:AUTH@ip:port`.

</Accordion>
</Accordions>

## Learn more

- [Redis (image)](/docs/recipes/redis) · [Valkey (image)](/docs/recipes/valkey)
- [ElastiCache](/docs/providers/elasticache) — AWS equivalent
- [Store · KV](/docs/elements/store#kv)

## Next

<Cards>
  <Card title="ElastiCache" description="AWS's equivalent." href="/docs/providers/elasticache" />
  <Card
    title="Azure Cache for Redis"
    description="Azure's equivalent."
    href="/docs/providers/azure-redis"
  />
  <Card
    title="Dragonfly Cloud"
    description="Public TLS endpoint option."
    href="/docs/providers/dragonfly-cloud"
  />
</Cards>


# Neon (/docs/providers/neon)

Neon separates storage from compute: branches are cheap copy-on-write metadata, and idle
compute scales to zero. Wire protocol is plain Postgres — `drivers.store.sql` stays
`postgres`.

<Callout title="The one rule">
  Copy the connection string from Neon's **Connect** widget. Prefer the pooled hostname for app
  traffic and the direct hostname for migrations — never invent the `-pooler` suffix by hand.
</Callout>

## Find credentials (current console)

1. Open [console.neon.tech](https://console.neon.tech) → your **project**.
2. On the **Project Dashboard**, click **Connect**.
3. In **Connect to your database**, pick **Branch**, **Compute**, **Database**, and
   **Role**.
4. Toggle **Connection pooling** **on** for the pooled string (hostname gains `-pooler`),
   or **off** for the direct string.
5. Copy the connection string into `DATABASE_URL` (add `?sslmode=require` if missing).

```bash title="process env"
# Runtime (pooled) — ep-….…-pooler.…neon.tech
DATABASE_URL=postgresql://neondb_owner:…@ep-xxxx-pooler.region.aws.neon.tech/neondb?sslmode=require

# Migrations / pg_dump / LISTEN — same host without -pooler
# OKE_STORE_SQL_URL=postgresql://neondb_owner:…@ep-xxxx.region.aws.neon.tech/neondb?sslmode=require
```

No `images["store.sql"]` pin — Neon runs the server.

## Pooled vs direct

| Use                                                           | Which string              | Why                                                      |
| ------------------------------------------------------------- | ------------------------- | -------------------------------------------------------- |
| App / serverless / Drizzle runtime                            | **Pooled** (`-pooler`)    | PgBouncer transaction mode; up to 10k client connections |
| `oke db migrate` / `pg_dump` / logical replication / `LISTEN` | **Direct** (no `-pooler`) | Session features break under transaction pooling         |

Do **not** also put [PgDog](/docs/recipes/pgdog) in front of Neon — one pooler only.

**Consequence:** seeding or migrations through the pooled URL often fail with prepared-statement
errors; switch those jobs to the direct URL.

## pgvector and roles

`CREATE EXTENSION vector;` works on every plan — no add-on. Roles created in the Neon
Console / CLI / API get `neon_superuser` membership and can install supported extensions.

Roles you create only with raw SQL (`CREATE ROLE`) do **not** get `neon_superuser` — they
hit permission errors on extension install. Create app roles in the Console (or grant
deliberately) when you need `pgvector` setup from that role.

## Free tier / production limits

| Limit         | Free plan                                 |
| ------------- | ----------------------------------------- |
| Scale to zero | After **5 min** idle — **cannot disable** |
| Compute       | **100 CU-hours** / project / month        |
| Storage       | **0.5 GB** / project                      |
| Autoscaling   | Up to 2 CU                                |

Hitting CU-hours or storage **suspends compute** until the next billing period or an
upgrade. Always-on production needs Launch/Scale with scale-to-zero disabled.

## Query performance

<Callout title="Create the extension — do not set command">
  Neon already preloads `pg_stat_statements`. `CREATE EXTENSION` is enough. There is no oke
  `command` overlay and no `online_advisor` shim.
</Callout>

| Step    | What you do                                         |
| ------- | --------------------------------------------------- |
| Preload | Neon compute (already on)                           |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements` |
| Console | Store → SQL band → **Performance**                  |

Scale-to-zero **wipes** statement stats when compute sleeps. Index Advisor is
not in the default Neon catalog — Performance shows a CTA, not a failing Enable.

## Real gotcha — cold start

After scale-to-zero, the first query pays a wake-up (hundreds of ms to a few seconds).
Health checks that expect sub-100ms on an idle Free project will flap. Paid plans can
disable scale-to-zero; Free cannot.

## Troubleshooting

<Accordions>
<Accordion title="remaining connection slots are reserved / pooler wait timeout">

Too many **direct** connections, or pooler server pool exhausted for your compute size.
Move runtime traffic to the `-pooler` hostname; keep a small number of direct admin
sessions. Neon reserves connections for the superuser account on each compute size.

</Accordion>
<Accordion title="Performance KPIs reset after idle">

Free (and any scale-to-zero) compute drops `pg_stat_statements` memory when the
endpoint sleeps. Re-run traffic after wake — this is not a Console bug.

</Accordion>
<Accordion title='permission denied to create extension "vector"'>

The role is not a Console-created / `neon_superuser` member. Re-run as `neondb_owner` (or
another Console role), or grant appropriately — raw SQL roles start with public-schema
privileges only.

</Accordion>
</Accordions>

## Learn more

- [Postgres (image)](/docs/recipes/postgres) — self-hosted peer
- [Store · Index](/docs/elements/store#index) — `pgvector` usage
- [PgDog](/docs/recipes/pgdog) — when you self-host pooling instead

## Next

<Cards>
  <Card
    title="Supabase"
    description="Another managed Postgres option."
    href="/docs/providers/supabase"
  />
  <Card
    title="CockroachDB"
    description="Distributed SQL alternative."
    href="/docs/providers/cockroachdb"
  />
  <Card title="PgDog" description="Self-hosted pooler comparison." href="/docs/recipes/pgdog" />
</Cards>


# Redis Cloud (/docs/providers/redis-cloud)

Redis Cloud is Redis Ltd.'s first-party managed service — RESP over TCP/TLS, no separate
REST layer. `drivers.store.kv` stays `redis`.

<Callout title="The one rule">
  Use `rediss://` (double **s**) with the endpoint from **Configuration** or the **Connect** wizard.
  Prefer **dynamic** endpoints when both are offered.
</Callout>

## Find credentials (current console)

1. Open Redis Cloud → your **database**.
2. Open the **Configuration** tab:
   - **Essentials:** endpoint under **Access**
   - **Pro:** endpoint under **General** (expand **Dynamic endpoints** if shown)
3. Credentials:
   - **Essentials:** **Default user → Configure** → eye icon for password (`default`)
   - **Pro:** **Security** section on Configuration → eye icon for default user password
4. Or click **Connect** → connection wizard → copy the ready-made client / `redis-cli`
   snippet (fills host, port, user, password).

```bash title="process env"
REDIS_URL=rediss://default:PASSWORD@redis-12345.c1.region.cloud.redislabs.com:12345
# or dynamic: …@horse-battery-staple-12345.db.redis.io:…
```

Port is a **per-database high port**, not always `6379` — copy it from the panel.

## Production guidance

| Topic         | Guidance                                                                          |
| ------------- | --------------------------------------------------------------------------------- |
| TLS           | Public endpoints expect TLS → `rediss://`                                         |
| Endpoint type | **Dynamic** (`*.db.redis.io`) can be redirected later; static `redis-….c…` cannot |
| Private       | Pro private endpoint needs VPC peering / PrivateLink / PSC first                  |
| Modules       | RediSearch / RedisJSON optional — unused by oke's KV driver                       |
| Port          | Per-database high port from Configuration — never assume `6379`                   |

Essentials vs Pro panels differ (Access vs General), but **Connect** always opens the
wizard with a filled client snippet — prefer that when the Configuration layout feels
unfamiliar after a UI refresh.

## Real gotcha — dynamic vs static

Apps hard-coded to a **static** `redis-….c…` host cannot follow a later “redirect
dynamic endpoint” migration. Copy the **dynamic** endpoint from Configuration when both
exist so you can repoint without code changes.

## Troubleshooting

<Accordions>
<Accordion title="NOAUTH / WRONGPASS">

Password not in the URL, or default user disabled under RBAC. Format:
`rediss://default:PASSWORD@host:port`. If default user is off, use a data-access role
username/password from Access Control.

</Accordion>
<Accordion title="Connection timeout to public endpoint">

Pro databases can block public endpoints. Use the private endpoint after peering, or
re-enable public access in security settings.

</Accordion>
</Accordions>

## Learn more

- [Redis (image)](/docs/recipes/redis) — self-hosted peer
- [Store · KV](/docs/elements/store#kv) — TTL physics
- [Upstash](/docs/providers/upstash) — serverless alternative

## Next

<Cards>
  <Card
    title="Upstash"
    description="Serverless-friendly alternative."
    href="/docs/providers/upstash"
  />
  <Card
    title="ElastiCache"
    description="AWS-native alternative."
    href="/docs/providers/elasticache"
  />
  <Card title="Neon" description="Managed SQL-side pair." href="/docs/providers/neon" />
</Cards>


# Supabase (/docs/providers/supabase)

Supabase wraps Postgres with Auth, Storage, Realtime, and generated APIs. oke only needs
the Postgres connection underneath — `drivers.store.sql` stays `postgres`.

<Callout title="The one rule">
  Use the dashboard **Connect** button and pick the mode that matches your runtime — Transaction
  pooler for serverless, Direct for migrations. Do not guess ports.
</Callout>

## Find credentials (current dashboard)

1. Open your project in [supabase.com/dashboard](https://supabase.com/dashboard).
2. Click **Connect** in the project top bar
   (`?showConnect=true` on the project URL).
3. Choose a method:
   - **Transaction pooler** — `aws-[region].pooler.supabase.com:6543` (Supavisor
     transaction mode)
   - **Session pooler** — same host, port **5432** (IPv4-friendly persistent clients)
   - **Direct connection** — `db.[project-ref].supabase.co:5432`
4. Copy the URI; substitute the database password from **Project Settings → Database**
   if the string still shows a placeholder.

Alternate path: **Project Settings → Database** → connection string / connection info
panels (same values as Connect).

```bash title="process env"
# Serverless / many short-lived clients
DATABASE_URL=postgres://postgres.[ref]:…@aws-0-[region].pooler.supabase.com:6543/postgres

# Migrations / pg_dump / long session features
# Direct (IPv6 by default on Free unless IPv4 add-on):
# DATABASE_URL=postgres://postgres:…@db.[ref].supabase.co:5432/postgres
```

## Pooled vs direct

| Mode                       | Host:port                   | Best for                                       |
| -------------------------- | --------------------------- | ---------------------------------------------- |
| Transaction (Supavisor)    | `…pooler.supabase.com:6543` | Serverless / edge — default app `DATABASE_URL` |
| Session (Supavisor)        | `…pooler.supabase.com:5432` | Persistent backends on **IPv4-only** networks  |
| Direct                     | `db.[ref].supabase.co:5432` | Migrations, `pg_dump`, replication             |
| Dedicated PgBouncer (paid) | `db.[ref].supabase.co:6543` | High-performance pooled traffic on paid tiers  |

**Do not stack** [PgDog](/docs/recipes/pgdog) on top of Supavisor. Transaction mode does
not support prepared statements — turn them off in the client if you see related errors.

**IPv6 gotcha:** Direct (and dedicated pooler) are IPv6 unless you buy the IPv4 add-on.
IPv4-only app hosts must use the shared pooler hostnames.

## Query performance

<Callout title="Use the direct port for engine views">
  `pg_stat_statements` is preloaded. Create the extension, then open Store → **Performance** on the
  **direct** `:5432` URL — not the transaction pooler.
</Callout>

| Step    | What you do                                         |
| ------- | --------------------------------------------------- |
| Preload | Platform (already on)                               |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements` |
| Locks   | Direct `db.[ref].supabase.co:5432`                  |
| Advisor | Dashboard **Database → Extensions** or `CASCADE`    |

Transaction pooler (`:6543`) is fine for app traffic. Live lock pairs and
advisor DDL need a session on the database host.

## pgvector

Available on every plan. Enable from the dashboard **Database → Extensions** (or
`CREATE EXTENSION vector;`). Same `store.index` + `pgvector` driver path as self-hosted
Postgres.

## Free tier — breaks real production

| Limit                      | Free                                                   |
| -------------------------- | ------------------------------------------------------ |
| Inactivity pause           | **7 days** without enough DB activity → project paused |
| Active projects            | **2**                                                  |
| DB size                    | **500 MB**                                             |
| Restore window after pause | Finite (platform retention)                            |

Paused projects wake slowly and will fail uptime checks. Paid plans do not auto-pause.
Do not run user-facing production on Free.

## Real gotcha — Connect port mix-ups

Since Feb 2025, **6543 is transaction-only** on the shared pooler; session mode is
**5432** on the pooler host. An old snippet that assumed “6543 = session” will break
session features or auth in subtle ways — re-copy from **Connect** today.

## Troubleshooting

<Accordions>
<Accordion title="Connection refused / timeout to db.[ref].supabase.co">

Often IPv4 client → IPv6-only direct endpoint. Switch `DATABASE_URL` to the **Session**
or **Transaction** pooler host (`pooler.supabase.com`), or enable the IPv4 add-on.

</Accordion>
<Accordion title="Project paused after a quiet week">

Free-tier inactivity pause. Restore from the dashboard, then upgrade or keep real DB
traffic above the pause threshold. Dashboard page views alone may not count.

</Accordion>
</Accordions>

## Learn more

- [Supabase recipe](/docs/recipes/supabase-docker) — Postgres image only, no platform
- [Store · Index](/docs/elements/store#index) — `pgvector`
- [Environment variables](/docs/reference/environment-variables) — `DATABASE_URL`

## Next

<Cards>
  <Card
    title="Supabase (image)"
    description="Run just the Postgres image."
    href="/docs/recipes/supabase-docker"
  />
  <Card title="Neon" description="Serverless alternative." href="/docs/providers/neon" />
  <Card
    title="YugabyteDB"
    description="Distributed Apache-2.0 option."
    href="/docs/providers/yugabytedb"
  />
</Cards>


# Upstash (/docs/providers/upstash)

Upstash exposes the **same database** two ways: Redis protocol over TLS (TCP) and a
separate HTTP REST API. oke's `redis` driver speaks **TCP only**.

<Callout title="The one rule">
  From the database **Details** / **Connect** panel, copy the **Redis** / TCP connection
  (`rediss://…`), never `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`.
</Callout>

## Find credentials (current console)

1. Open [console.upstash.com](https://console.upstash.com) → **Redis** → your database.
2. On the database page (**Details**):
   - **Endpoint**, **Port**, **Password** (token) for TCP clients
   - Ready-made `redis-cli` / `rediss://` snippets under **Connect** / **Redis**
3. Ignore the **REST** tab values (`UPSTASH_REDIS_REST_URL`,
   `UPSTASH_REDIS_REST_TOKEN`) for oke — those are for `@upstash/redis` / HTTP only.

```bash title="process env"
REDIS_URL=rediss://default:PASSWORD@usw1-example-12345.upstash.io:6379
```

TLS is mandatory — `redis://` without TLS fails.

## Production guidance

| Topic       | Guidance                                                                    |
| ----------- | --------------------------------------------------------------------------- |
| Transport   | TCP `rediss://` for Bun / long-running servers                              |
| REST        | Edge/serverless SDKs only — **not** wired to `fx.store`                     |
| Pricing     | Per-request — watch command fan-out in hot KV paths                         |
| Eviction    | Plan max size / eviction; serverless still has quotas                       |
| Consistency | Global replication options are product-specific — confirm region in Details |

Do not put [PgDog](/docs/recipes/pgdog)-style thinking on Redis: there is no separate
“pooled hostname” toggle. Connection count pressure shows up as Upstash plan limits /
timeouts, not a Neon-style `-pooler` suffix.

When debugging, open **Details** and confirm you are reading the **Redis** column, not
the REST env var block that sits beside it on the same page.

## Real gotcha — REST token ≠ Redis password

Using `UPSTASH_REDIS_REST_TOKEN` inside a `rediss://` URL (or the reverse) yields
`WRONGPASS` / `NOAUTH`. REST token and TCP password are different credentials on the
same console page — copy from the **Redis/TCP** section.

## Troubleshooting

<Accordions>
<Accordion title="WRONGPASS invalid or missing auth token">

Often a REST token pasted into a TCP client. Re-copy **Password** from Details for
Redis protocol, build `rediss://:PASSWORD@ENDPOINT:PORT`.

</Accordion>
<Accordion title="NOAUTH Authentication required">

Password missing from the URL. ioredis-style URLs need the colon before the password:
`rediss://:PASSWORD@host:port`.

</Accordion>
</Accordions>

## Learn more

- [Redis (image)](/docs/recipes/redis)
- [Redis Cloud](/docs/providers/redis-cloud) — fixed-instance alternative
- [Store · KV](/docs/elements/store#kv)

## Next

<Cards>
  <Card
    title="Redis Cloud"
    description="Fixed-instance alternative."
    href="/docs/providers/redis-cloud"
  />
  <Card
    title="Dragonfly Cloud"
    description="Another managed option."
    href="/docs/providers/dragonfly-cloud"
  />
  <Card title="Neon" description="Managed SQL-side pair." href="/docs/providers/neon" />
</Cards>


# YugabyteDB (/docs/providers/yugabytedb)

YugabyteDB Aeon exposes a Postgres-compatible query layer (**YSQL**). That is the only
API oke's `postgres` driver targets — not YCQL.

<Callout title="The one rule">
  In **Connect → Connect to your Application**, choose **YSQL** and download the CA cert. Port
  **5433** is the YSQL default — not Postgres's 5432.
</Callout>

## Find credentials (current console)

1. YugabyteDB Aeon → **Clusters** → select the cluster.
2. Click **Connect**.
3. Click **Connect to your Application**.
4. Click **Download CA Cert** and install it where the app can read it.
5. Select API **YSQL** (not YCQL).
6. Pick address mode: **Private Address** / **Private Service Endpoint** if VPC-peered;
   **Public Address** only if **Settings → Network Access → Public Access** is enabled
   (not recommended for production).
7. Copy **Connection String** (or Parameters) into `DATABASE_URL`.

```bash title="process env"
DATABASE_URL=postgresql://admin:…@….aws.yugabyte.cloud:5433/yugabyte?ssl=true&sslmode=verify-full&sslrootcert=/path/to/root.crt
```

Also add your app's IPs (or peered VPC) to the cluster **IP allow list** before
connecting.

## Production guidance

| Topic   | Guidance                                                                      |
| ------- | ----------------------------------------------------------------------------- |
| API     | **YSQL only** — YCQL is Cassandra-wire, useless to `postgres`                 |
| TLS     | `sslmode=verify-full` + downloaded CA                                         |
| Network | Prefer private / PSE; public access is an explicit opt-in                     |
| License | Core is **Apache 2.0** (including once-enterprise features in the core build) |

No Neon-style pooled hostname toggle — connection limits and load balancing are
cluster/VPC topology concerns. Smart drivers want a peered VPC; otherwise they probe
unreachable nodes first and add latency.

## Query performance

<Callout title="Already preloaded">
  YSQL exposes `pg_stat_statements` without an oke `command`. Create the extension, then open Store
  → **Performance**.
</Callout>

| Step    | What you do                                         |
| ------- | --------------------------------------------------- |
| Preload | Yugabyte (already on)                               |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements` |
| Advisor | Not in the default catalog — CTA only               |

Do not invent a Postgres `command` overlay for Aeon or the Docker recipe.

## Real gotcha — wrong API tab

Copying **YCQL** parameters (port **9042**) into `DATABASE_URL` fails immediately —
different protocol. Always confirm the Connect dialog shows **YSQL** and port **5433**.

## Troubleshooting

<Accordions>
<Accordion title="Connection timed out / could not connect to server">

IP allow list or Public Access. Add the client IP under network settings, or use the
private address from a peered VPC. Public hostname stays dark until Public Access is on.

</Accordion>
<Accordion title="SSL error with verify-full">

CA not downloaded or `sslrootcert` path wrong. Re-run **Download CA Cert** from the
Connect dialog and point the URI at that file.

</Accordion>
</Accordions>

## Learn more

- [CockroachDB](/docs/providers/cockroachdb) — license / feature comparison
- [YugabyteDB (self-hosted)](/docs/recipes/yugabytedb) — single-node Docker recipe
- [Postgres (image)](/docs/recipes/postgres) — driver this backs
- [Store · SQL](/docs/elements/store#sql) — schema workflows

## Next

<Cards>
  <Card
    title="CockroachDB"
    description="Compare licensing and features."
    href="/docs/providers/cockroachdb"
  />
  <Card
    title="Supabase"
    description="Managed single-node option."
    href="/docs/providers/supabase"
  />
  <Card
    title="Redis Cloud"
    description="Managed Redis-side pair."
    href="/docs/providers/redis-cloud"
  />
</Cards>


# Caddy (/docs/recipes/caddy)

Caddy is the simplest TLS path — automatic HTTPS from a generated `Caddyfile`, no
certificate management by hand. Right choice for a single app instance without `--scale`.

<Callout title="The one rule">
  Leave `images.proxy` unset until you need HTTPS at the edge or `--scale app=N`. Once set, `app`
  stops publishing its host port directly — Caddy (or Traefik) does.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the proxy

```typescript title="oke.config.ts"
images: {
  proxy: "caddy:2-alpine",
},
```

</Step>

<Step>
### Set the public hostname

```bash title=".env.local"
OKE_PROXY_HOST=app.example.com
```

Unset defaults to `localhost` — Caddy issues a **local** TLS cert instead of Let's Encrypt.

</Step>

<Step>
### Include the proxy layer

```bash
docker compose -f docker-compose.yml up -d
```

Generated `Caddyfile`: `{$OKE_PROXY_HOST:localhost} { reverse_proxy app:6530 }`.

</Step>

</Steps>

## Required env

| Variable                | Required?   | Meaning                                                                     |
| ----------------------- | ----------- | --------------------------------------------------------------------------- |
| `OKE_PROXY_HOST`        | Recommended | Public hostname for ACME; default `localhost` → local TLS only              |
| `allowedHosts` (config) | Production  | Must include the public hostname — see [Security](/docs/reference/security) |

Caddy has no separate ACME email env in this recipe (unlike Traefik's
`OKE_PROXY_ACME_EMAIL`).

## Data and backup

| Volume         | Path                                      | What it stores                  |
| -------------- | ----------------------------------------- | ------------------------------- |
| `proxy-data`   | `/data`                                   | ACME certificates, account keys |
| `proxy-config` | `/config`                                 | Caddy runtime config            |
| Bind mount     | `./Caddyfile` → `/etc/caddy/Caddyfile:ro` | Generated site block            |

**Backup means:** preserve the `proxy-data` named volume (and ideally `proxy-config`) so
Let's Encrypt rate limits and cert renewals survive recreates. Losing `/data` forces
re-issuance. The `Caddyfile` is regenerated by `oke docker`.

## Production note

Caddy has **no** service-discovery story for multiple `app` replicas. Once you run
`docker compose up --scale app=N`, switch to [Traefik](/docs/recipes/traefik) — it
discovers replicas from Docker labels instead of a static `reverse_proxy` target.

Also set `allowedHosts` to your public hostname before exposing the edge — see
[Security](/docs/reference/security).

## What the recipe configures

| Field          | Value                                        |
| -------------- | -------------------------------------------- |
| Ports          | `80` + `443` published; `app` stays internal |
| Healthcheck    | `caddy version`, every 10s, 5 retries        |
| Connection URL | `https://<host>`                             |

## Troubleshooting

<Accordions>
<Accordion title="ACME fails — connection refused / challenge timeout">

`OKE_PROXY_HOST` must be a DNS name that resolves to this host on ports 80/443. Localhost
never gets a public Let's Encrypt cert — that path uses Caddy's local CA. Check firewall
and that nothing else binds `:80`.

</Accordion>
<Accordion title="Browser trust errors on localhost">

Expected with the local CA. Install Caddy's local root for that machine, or set a real
`OKE_PROXY_HOST` with public DNS when you need a public cert.

</Accordion>
</Accordions>

## Learn more

- [Traefik](/docs/recipes/traefik) — multi-replica discovery via Docker labels
- [nginx](/docs/recipes/nginx) — static HTTP reverse proxy
- [Security](/docs/reference/security) — `allowedHosts`

## Next

<Cards>
  <Card title="Traefik" description="Multi-replica alternative." href="/docs/recipes/traefik" />
  <Card title="nginx" description="Static HTTP reverse proxy." href="/docs/recipes/nginx" />
  <Card
    title="PgDog"
    description="Pool Postgres behind the same stack."
    href="/docs/recipes/pgdog"
  />
</Cards>


# CockroachDB (/docs/recipes/cockroachdb)

CockroachDB speaks Postgres wire closely enough that `drivers.store.sql` stays
`postgres`. Pin `cockroachdb/cockroach` as `store.sql` and `oke docker` derives a
single-node compose service — credentials, healthcheck, and `DATABASE_URL`.

<Callout title="The one rule">
  The driver id stays `postgres` — vendor choice lives in `images["store.sql"]`, not in
  `drivers.store.sql`. Host maps `:5432` → container `:26257` so local apps keep the usual Postgres
  port.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.sql": "cockroachdb/cockroach:v25.2.0", // pin a real tag
},
```

</Step>

<Step>
### Bring the stack up

```bash
oke dev
```

`oke docker` injects `COCKROACH_USER` / `COCKROACH_PASSWORD` / `COCKROACH_DATABASE`
from `OKE_STORE_SQL_*`, starts `start-single-node --accept-sql-without-tls`, and
publishes SQL on host `:5432` plus the DB Console on `:8080`.

</Step>

<Step>
### Connect

```bash
echo "$DATABASE_URL"
# postgres://oke:…@127.0.0.1:5432/oke?sslmode=require
```

</Step>

</Steps>

## Required env

| Variable                 | Who sets it            | Meaning                              |
| ------------------------ | ---------------------- | ------------------------------------ |
| `OKE_STORE_SQL_USER`     | `oke docker` → compose | → `COCKROACH_USER` (first boot only) |
| `OKE_STORE_SQL_PASSWORD` | `oke docker` → compose | → `COCKROACH_PASSWORD`               |
| `OKE_STORE_SQL_DB`       | `oke docker` → compose | → `COCKROACH_DATABASE`               |
| `DATABASE_URL`           | stack env for the app  | Includes `sslmode=require`           |

## Data and backup

| Path                        | What lives there                              |
| --------------------------- | --------------------------------------------- |
| `/cockroach/cockroach-data` | Named volume `store-sql-data` — cluster store |

**Backup means:** volume backup of `store-sql-data`, or Cockroach's backup tooling
against a running node. Losing that volume loses the cluster.

## Production note

This recipe is a **single-node** cluster — fine for local and small self-hosted apps,
not multi-region HA. For managed multi-region, use the
[CockroachDB provider](/docs/providers/cockroachdb). Prefer a pinned tag, not `latest`.

## What the recipe configures

| Field          | Value                                        |
| -------------- | -------------------------------------------- |
| Container port | `26257` (host publishes `5432`)              |
| Extra port     | `8080` — DB Console                          |
| Command        | `start-single-node --accept-sql-without-tls` |
| Healthcheck    | `GET /health?ready=1` on `:8080`             |
| Connection URL | `postgres://…?sslmode=require`               |
| Stats          | `pg_stat_statements` unsupported             |

## Query performance

Store → **Performance** returns `PgStatStatementsUnsupported`. oke does not shim
Cockroach `statement_statistics`.

## Troubleshooting

<Accordions>
<Accordion title="oke boot: postgres driver needs DATABASE_URL">

Re-run `oke dev` so the stack writes `.env.local`, or export
`DATABASE_URL` yourself when pointing at a remote Cockroach host.

</Accordion>
<Accordion title="store-sql unhealthy / healthcheck never ready">

First boot can take longer than Postgres. Check
`docker compose … logs store-sql` for SQL ready. Empty `${OKE_STORE_SQL_PASSWORD}`
in `.env.local` leaves the init user broken — regenerate the stack env.

</Accordion>
</Accordions>

## Learn more

- [CockroachDB (provider)](/docs/providers/cockroachdb) — managed Cloud Connect flow
- [YugabyteDB](/docs/recipes/yugabytedb) — Apache-2.0 self-hosted alternative
- [Postgres](/docs/recipes/postgres) — default SQL recipe
- [Store · SQL](/docs/elements/store#sql) — schema push / generate / migrate

## Next

<Cards>
  <Card
    title="CockroachDB Cloud"
    description="Managed multi-region alternative."
    href="/docs/providers/cockroachdb"
  />
  <Card title="YugabyteDB" description="Self-hosted YSQL peer." href="/docs/recipes/yugabytedb" />
  <Card title="Postgres" description="The default store.sql image." href="/docs/recipes/postgres" />
</Cards>


# Dragonfly (/docs/recipes/dragonfly)

Dragonfly is a multi-threaded, memory-efficient reimplementation of the Redis protocol —
same `redis://` wire format, different engine. Slots behind the same `redis` driver with
zero Flow changes.

<Callout title="The one rule">
  Driver id stays `redis`. Dragonfly is an image choice — every KV call through `fx.store` behaves
  identically to Redis or Valkey at the protocol layer.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.kv": "docker.dragonflydb.io/dragonflydb/dragonfly",
},
```

</Step>

<Step>
### Password + memory

```bash
OKE_STORE_KV_PASSWORD=…
OKE_STORE_KV_MAXMEMORY=256mb   # optional; recipe passes --maxmemory
REDIS_URL=redis://:…@127.0.0.1:6379
```

Command: `dragonfly --requirepass "$OKE_STORE_KV_PASSWORD" --maxmemory "…"`.

Unlike Redis/Valkey, this recipe does **not** pass `--maxmemory-policy` — Dragonfly's
own defaults apply.

</Step>

<Step>
### memlock ulimit

The recipe sets `ulimits.memlock: -1` — required for Dragonfly's shared-nothing design.
If the host forbids unlimited memlock, the container may fail to start.

</Step>

</Steps>

## Required env

| Variable                 | Required?     | Meaning                                             |
| ------------------------ | ------------- | --------------------------------------------------- |
| `OKE_STORE_KV_PASSWORD`  | **Yes**       | `--requirepass`                                     |
| `REDIS_URL`              | **Yes** (app) | `redis://` URL for the driver                       |
| `OKE_STORE_KV_MAXMEMORY` | Optional      | `--maxmemory` (default `0` in the command template) |
| `HEALTHCHECK_PORT`       | Set by recipe | `6379` — used by the image's healthcheck script     |

## Data and backup

The default `store.kv` recipe declares **no named volume** — ephemeral, same as Redis/Valkey.

Keys that must survive go on `{ durable: true }` — a JSONB table on your SQL database.
Dragonfly snapshots are not involved. See [Store · Durable KV](/docs/elements/store#durable-kv).

## Production note

BSL 1.1 — converts to Apache 2.0 on a published change date per release. Free for
self-hosting; the BSL terms restrict offering Dragonfly itself as a commercial managed
service to third parties — running it for your own app is unaffected.

Multi-threaded architecture makes better use of multi-core hosts than single-threaded
Redis under heavy concurrent load. For a managed endpoint with TLS + passkey, see
[Dragonfly Cloud](/docs/providers/dragonfly-cloud).

## What the recipe configures

| Field          | Value                                                 |
| -------------- | ----------------------------------------------------- |
| Container port | `6379`                                                |
| `ulimits`      | `memlock: -1`                                         |
| Healthcheck    | `/usr/local/bin/healthcheck.sh`, every 5s, 10 retries |
| Connection URL | `redis://:pass@host:6379`                             |

## Troubleshooting

<Accordions>
<Accordion title="Container exits — cannot lock memory / memlock">

Host `ulimit -l` is too low. The recipe requests unlimited memlock. Raise the Docker
daemon / systemd limit, or run on a host that allows `memlock: -1`.

</Accordion>
<Accordion title="oke boot: redis driver needs REDIS_URL">

Same driver failure as Redis — export `REDIS_URL` or let `oke dev` write it. Wrong
password yields Redis-protocol `NOAUTH` / `WRONGPASS` from the server.

</Accordion>
</Accordions>

## Learn more

- [Store · KV](/docs/elements/store#kv) — full Redis-protocol image license table
- [Dragonfly Cloud](/docs/providers/dragonfly-cloud) — the managed equivalent
- [Redis](/docs/recipes/redis) · [Valkey](/docs/recipes/valkey) — the other two peers

## Next

<Cards>
  <Card
    title="Dragonfly Cloud"
    description="Managed Dragonfly."
    href="/docs/providers/dragonfly-cloud"
  />
  <Card title="Valkey" description="Permissive-licensed peer." href="/docs/recipes/valkey" />
  <Card title="PgDog" description="Pooling for the SQL side." href="/docs/recipes/pgdog" />
</Cards>


# Recipes (/docs/recipes)

Every recipe below is already wired into `oke docker` — pin an image in `oke.config.ts`
and get env, healthcheck, and a connection URL for free.

These recipes are real, working infrastructure choices behind the same two templates (`standard`, `advanced`) and the same eight elements — not alternative products or separate frameworks.

<Callout title="The one rule">
  Vendor choice lives in `images[…]` — never in `drivers.*`, which only ever say protocol ids
  (`postgres`, `redis`, `s3`, `smtp`, `meilisearch`, `openai-compatible`, …).
</Callout>

## SQL

<Cards>
  <Card
    title="Postgres"
    description="Default store.sql — POSTGRES_*, PGDATA, DATABASE_URL."
    href="/docs/recipes/postgres"
  />
  <Card
    title="Supabase"
    description="supabase/postgres extension bundle only."
    href="/docs/recipes/supabase-docker"
  />
  <Card
    title="CockroachDB"
    description="Self-hosted single-node — port 26257, COCKROACH_*."
    href="/docs/recipes/cockroachdb"
  />
  <Card
    title="YugabyteDB"
    description="Self-hosted YSQL — port 5433, YSQL_*."
    href="/docs/recipes/yugabytedb"
  />
  <Card
    title="Timescale"
    description="Postgres + hypertables — same POSTGRES_* contract."
    href="/docs/recipes/timescale"
  />
</Cards>

## KV

Driver id stays `redis` for every image below.

<Cards>
  <Card
    title="Redis"
    description="Default store.kv — requirepass + maxmemory."
    href="/docs/recipes/redis"
  />
  <Card title="Valkey" description="BSD-licensed Redis-wire fork." href="/docs/recipes/valkey" />
  <Card
    title="Dragonfly"
    description="Multi-threaded Redis-wire runtime."
    href="/docs/recipes/dragonfly"
  />
</Cards>

## Proxy

Opt-in via `images.proxy` — leave unset until you need an edge or `--scale app=N`.

<Cards>
  <Card title="Caddy" description="Automatic-HTTPS, single instance." href="/docs/recipes/caddy" />
  <Card
    title="Traefik"
    description="Docker-label discovery + socket-proxy."
    href="/docs/recipes/traefik"
  />
  <Card
    title="nginx"
    description="Static nginx.conf reverse proxy (HTTP)."
    href="/docs/recipes/nginx"
  />
</Cards>

## Services

<Cards>
  <Card title="PgDog" description="Transaction pooler on :6432." href="/docs/recipes/pgdog" />
  <Card
    title="RustFS"
    description="S3-compatible store.files — /data volume."
    href="/docs/recipes/rustfs"
  />
  <Card title="Mailpit" description="SMTP catcher — UI on :8025." href="/docs/recipes/mailpit" />
  <Card
    title="Meilisearch"
    description="Full-text store.index on :7700."
    href="/docs/recipes/meilisearch"
  />
</Cards>

## AI

Compose does not manage inference. Prefer [OpenRouter](/docs/recipes/openrouter)
for zero-Docker `fx.ask`, or BYO any OpenAI-shaped `/v1` via `OKE_AI_URL` /
`baseUrl` — see [Models](/docs/elements/ai/models).

| Workload                       | Where                                  |
| ------------------------------ | -------------------------------------- |
| Zero Docker / free cloud smoke | [OpenRouter](/docs/recipes/openrouter) |
| BYO OpenAI-compatible `/v1`    | [Models](/docs/elements/ai/models)     |
| Managed cloud (registry)       | [Models](/docs/elements/ai/models)     |

<Cards>
  <Card
    title="OpenRouter"
    description="Zero Docker — openrouter/free + auto baseUrl."
    href="/docs/recipes/openrouter"
  />
  <Card
    title="Models"
    description="Registry providers + custom baseUrl / OKE_AI_URL."
    href="/docs/elements/ai/models"
  />
</Cards>

## Learn more

- [Providers](/docs/providers) — managed cloud alternatives
- [Configuration](/docs/reference/configuration) — `images` roles and compose derivation
- [Store](/docs/elements/store) — `sql` / `kv` / `files` / `index` facets

## Next

<Cards>
  <Card
    title="Postgres"
    description="Start with the default SQL recipe."
    href="/docs/recipes/postgres"
  />
  <Card title="Providers" description="Managed cloud alternatives." href="/docs/providers" />
  <Card title="Caddy" description="Automatic HTTPS edge." href="/docs/recipes/caddy" />
</Cards>


# Mailpit (/docs/recipes/mailpit)

Mailpit is the `oke dev` email catcher — every message the `smtp` channel driver sends
lands in a web UI instead of a real inbox. Pin it as `images.channel.email` when
`drivers.channel.email.dev` is `smtp`.

<Callout title="The one rule">
  `dev` ≈ `prod` for the **SMTP protocol** — same `SMTP_URL` shape. Mailpit is not a production MTA;
  swap the image / URL for a real relay in production.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image (templates already do)

```typescript title="oke.config.ts"
drivers: {
  channel: {
    email: { dev: "smtp", test: "console", prod: "smtp" },
  },
},
images: {
  channel: { email: "axllent/mailpit:v1.31.1" },
},
```

</Step>

<Step>
### Connect the smtp driver

```bash title=".env.local (written by oke docker)"
SMTP_URL=smtp://127.0.0.1:1025
MAILPIT_UI_URL=http://127.0.0.1:8025
```

No `SMTP_USER` / `SMTP_PASSWORD` required for Mailpit's open local listener.

</Step>

<Step>
### Send and inspect

Trigger any flow that uses `fx.email` / channel email. Open `MAILPIT_UI_URL` (port
**8025**) to read the caught message — subject, body, headers.

</Step>

</Steps>

## Required env

| Variable                      | Required?                  | Meaning                                                 |
| ----------------------------- | -------------------------- | ------------------------------------------------------- |
| `SMTP_URL`                    | **Yes** (app, smtp driver) | `smtp://host:1025` — boot fails without it              |
| `OKE_CHANNEL_EMAIL_URL`       | Alternative                | Same role as `SMTP_URL`                                 |
| `MAILPIT_UI_URL`              | Written by stack           | Browser UI — not read by the smtp driver                |
| `SMTP_USER` / `SMTP_PASSWORD` | No for Mailpit             | Used when pointing `SMTP_URL` at an authenticated relay |

## Data and backup

Mailpit declares **no volume**. Caught messages live in container memory/disk for that
instance only.

**Backup means:** nothing durable to back up — this is a catcher. For forensic copies,
export from the UI before recreate. Production mail never uses this image as the store of
record.

## Production note

Templates set `prod: "smtp"` but still pin Mailpit in `images` for docker. In real prod,
point `SMTP_URL` at your provider and set `SMTP_USER` / `SMTP_PASSWORD` as needed.

Leaving Mailpit reachable on a public host exposes an open relay UI — bind to the compose
network only.

Healthcheck probes `http://127.0.0.1:8025/api/v1/info` — UI down ⇒ service unhealthy even
if SMTP still accepts mail.

## What the recipe configures

| Field          | Value                                               |
| -------------- | --------------------------------------------------- |
| SMTP port      | `1025` (published)                                  |
| Extra port     | `8025` → UI                                         |
| Healthcheck    | `wget` against `/api/v1/info`, every 5s, 10 retries |
| Connection URL | `smtp://host:1025`                                  |

## Troubleshooting

<Accordions>
<Accordion title="oke boot: smtp driver needs SMTP_URL">

The smtp binder refuses to start without `SMTP_URL` (or `OKE_CHANNEL_EMAIL_URL`). In
under `oke dev` the error asks whether Compose wrote `.env.local`. Also rejects
non-`smtp://` schemes: `oke boot: SMTP_URL must use smtp://`.

</Accordion>
<Accordion title="Messages send but UI is empty">

Wrong UI port, or you opened a different instance's Mailpit. Confirm `MAILPIT_UI_URL`
matches the published `8025` mapping for **this** compose project (instance-id offsets
shift the host port).

</Accordion>
</Accordions>

## Learn more

- [Channel](/docs/elements/channel) — email delivery physics
- [Environment variables](/docs/reference/environment-variables) — `SMTP_*` map
- [Configuration](/docs/reference/configuration) — default `channel.email` image pin

## Next

<Cards>
  <Card
    title="RustFS"
    description="S3-compatible files for the same stack."
    href="/docs/recipes/rustfs"
  />
  <Card
    title="Vault"
    description="Secrets and built-in encrypted store."
    href="/docs/elements/vault"
  />
  <Card title="Channel" description="How fx.email reaches humans." href="/docs/elements/channel" />
</Cards>


# Meilisearch (/docs/recipes/meilisearch)

Meilisearch backs the `meilisearch` index driver — typo-tolerant full-text search over
HTTP. Pin `images.store.index` and set `drivers.store.index` to `meilisearch` where
you need it (index does **not** auto-promote under Compose).

<Callout title="The one rule">
  Index stays `memory` in every environment until you set the driver map explicitly — there is no
  silent fallback to Meilisearch or pgvector.
</Callout>

## Quick start

<Steps>

<Step>
### Declare driver + image

```typescript title="oke.config.ts"
drivers: {
  store: {
    index: { dev: "meilisearch", test: "memory", prod: "meilisearch" },
  },
},
images: {
  store: { index: "getmeili/meilisearch:v1.53" },
},
```

</Step>

<Step>
### Keys written by oke docker

```bash title=".env.local"
OKE_STORE_INDEX_URL=http://127.0.0.1:7700
OKE_STORE_INDEX_KEY=…          # also accepted as MEILI_MASTER_KEY
```

Recipe injects into the container:

- `MEILI_MASTER_KEY=${OKE_STORE_INDEX_KEY}`
- `MEILI_ENV=${OKE_MEILI_ENV:-production}`
- `MEILI_NO_ANALYTICS=true`

</Step>

<Step>
### Search from a flow

Use `store.index(…)` without `{ dims }` for full-text (vector dims select pgvector).
Writes wait on Meilisearch tasks before returning.

</Step>

</Steps>

## Required env

| Variable              | Required?            | Meaning                                          |
| --------------------- | -------------------- | ------------------------------------------------ |
| `OKE_STORE_INDEX_URL` | **Yes**              | Base URL — boot fails without it                 |
| `OKE_STORE_INDEX_KEY` | **Yes** (production) | Master/API key; falls back to `MEILI_MASTER_KEY` |
| `MEILI_MASTER_KEY`    | Alternate            | Same secret, container-side name                 |
| `OKE_MEILI_ENV`       | Optional             | Passed as `MEILI_ENV` (default `production`)     |

## Data and backup

| Volume             | Path          | What it stores               |
| ------------------ | ------------- | ---------------------------- |
| `store-index-data` | `/meili_data` | Indexes, documents, settings |

**Backup means:** snapshot/restore that named volume (or Meilisearch dumps). Recreating
the container **keeps** data if the volume remains; deleting the volume wipes every index.

The master key is **not** inside the volume — it lives in `.env.local` / secrets.
Losing the key without a backup blocks admin API access even if data remains.

## Production note

Always set a non-empty `OKE_STORE_INDEX_KEY` when `MEILI_ENV=production` — Meilisearch
refuses insecure production mode without a master key. Prefer network isolation (compose
network only) plus the key; do not publish `:7700` on the public internet without auth.

Vector `ai.embed` into a meilisearch index fails loud — embeddings need `pgvector`,
not FTS. See [Store · Index](/docs/elements/store#index).

## What the recipe configures

| Field          | Value                                          |
| -------------- | ---------------------------------------------- |
| Container port | `7700`                                         |
| Healthcheck    | `wget` against `/health`, every 5s, 12 retries |
| Connection URL | `http://host:7700`                             |

## Troubleshooting

<Accordions>
<Accordion title="oke boot: meilisearch index needs OKE_STORE_INDEX_URL">

No silent memory fallback. Re-run `oke dev` or export `OKE_STORE_INDEX_URL`.
Docker-mode wording asks whether `.env.local` was written.

</Accordion>
<Accordion title="meilisearch index: GET/POST … failed (403) — or master key errors">

`OKE_STORE_INDEX_KEY` does not match `MEILI_MASTER_KEY` inside the container, or the key
was rotated without updating the app env. Align both sides and restart. Unreachable host
surfaces as `meilisearch index: unreachable at <url> — …`.

</Accordion>
</Accordions>

## Learn more

- [Store · Index](/docs/elements/store#index) — FTS vs vector drivers
- [Environment variables](/docs/reference/environment-variables) — index URL / key
- [Configuration](/docs/reference/configuration) — `drivers.store.index` union

## Next

<Cards>
  <Card
    title="OpenRouter"
    description="Zero Docker cloud AI for the same stack."
    href="/docs/recipes/openrouter"
  />
  <Card
    title="Postgres"
    description="pgvector alternative for embeddings."
    href="/docs/recipes/postgres"
  />
  <Card title="Store" description="Index facet API." href="/docs/elements/store" />
</Cards>


# nginx (/docs/recipes/nginx)

nginx is the classic static reverse proxy — a generated `nginx.conf` forwards to
`app:6530` on the compose network. Right choice when TLS already terminates elsewhere
(Cloudflare, ALB, another edge) or you want a hand-edited config.

<Callout title="The one rule">
  Leave `images.proxy` unset until you need an edge in front of the app. Pin nginx for a static HTTP
  proxy; prefer [Caddy](/docs/recipes/caddy) or [Traefik](/docs/recipes/traefik) when you want
  automatic HTTPS in the same stack.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the proxy

```typescript title="oke.config.ts"
images: {
  proxy: "nginx:1.31-alpine",
},
```

`create-oke` offers **nginx** in **Add a reverse proxy…?** (or `--proxy nginx`).

</Step>

<Step>
### Include the proxy layer

```bash
docker compose -f docker-compose.yml up -d
```

Generated `nginx.conf`: `upstream oke_app { server app:6530; }` with
`proxy_pass http://oke_app` on `:80`. The app host port (`6530`) is unpublished —
traffic enters through the proxy.

</Step>

<Step>
### Put TLS in front (optional)

Point Cloudflare / an ALB / another terminator at host `:80`, or switch the pin to
Caddy / Traefik when you want ACME inside Compose.

</Step>

</Steps>

## Required env

| Variable                | Required?  | Meaning                                                                     |
| ----------------------- | ---------- | --------------------------------------------------------------------------- |
| `OKE_PROXY_HOST`        | Optional   | Documented for the proxy role; nginx listens on any `Host` (`_`)            |
| `allowedHosts` (config) | Production | Must include the public hostname — see [Security](/docs/reference/security) |

## Data and backup

| Volume / bind | Path                                        | What it stores        |
| ------------- | ------------------------------------------- | --------------------- |
| Bind mount    | `./nginx.conf` → `/etc/nginx/nginx.conf:ro` | Generated site config |

**Backup means:** the `nginx.conf` is regenerated by `oke docker`. Keep overrides in
`compose.override.yml` or a replaced file if you customize routing.

## Production note

nginx here is **HTTP-only** on port 80 — no ACME, no Docker service discovery. For
automatic HTTPS use [Caddy](/docs/recipes/caddy). For `--scale app=N` use
[Traefik](/docs/recipes/traefik).

**Consequence:** `OKE_PROXY_URL` is `http://…` for this recipe (Caddy / Traefik use
`https://…`).

## Troubleshooting

<Accordions>
<Accordion title="App still publishes 6530">

You still have `6530:6530` on `app`. Confirm `images.proxy` is set and
`oke docker` regenerated compose so the app host bind was omitted.

</Accordion>
<Accordion title="502 Bad Gateway from nginx">

The upstream is `app:6530` on the `oke` network. Confirm the app service is healthy
and shares that network. Override `nginx.conf` only after checking the generated
upstream block.

</Accordion>
</Accordions>

## Learn more

- [Caddy](/docs/recipes/caddy) — automatic HTTPS alternative
- [Traefik](/docs/recipes/traefik) — multi-replica discovery via Docker labels
- [Security](/docs/reference/security) — `allowedHosts`

## Next

<Cards>
  <Card title="Caddy" description="Automatic HTTPS alternative." href="/docs/recipes/caddy" />
  <Card title="Traefik" description="Multi-replica alternative." href="/docs/recipes/traefik" />
  <Card
    title="PgDog"
    description="Pool Postgres behind the same stack."
    href="/docs/recipes/pgdog"
  />
</Cards>


# OpenRouter (/docs/recipes/openrouter)

OpenRouter is the simplest way to call a real model from OKE: no Docker, no
Python, no local weights. Set `provider: "openrouter"` and an API key —
`baseUrl` resolves from the verified registry.

<Callout title="The one rule">
  Use a dedicated OpenRouter API key on the binding (`apiKey`), prefer the stable `openrouter/free`
  router alias for zero-cost smoke tests, and keep other providers on their own `ai.model` +
  `apiKey` so keys never collide.
</Callout>

## Quick start

<Steps>

<Step>
### Declare the cloud binding

```typescript
import { ai } from "okengine";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const triage = smart.prompt("triage");
```

`baseUrl` becomes `https://openrouter.ai/api/v1` automatically. Pass an explicit
`baseUrl` only to point at a proxy or mirror.

</Step>

<Step>
### Ask from a flow

```typescript title="src/flows/main/ask.ts"
import { on, flow, http } from "okengine";

export const ask = on(
  http.post(),
  flow({ asks: ["triage"] }, async (fx, input) => {
    return await fx.ask("triage", input);
  }),
);
```

</Step>

<Step>
### Optional setup via CLI

create-oke **Recommended** / Customize / Reuse and `oke ai setup --provider openrouter`
pick `openrouter/free`, write `OPENROUTER_API_KEY` to `.env.local` + `vault.secret` (no
`dev:` stub). Missing key → `oke dev` asks again ([openrouter.ai](https://openrouter.ai)).

</Step>

</Steps>

## Router aliases

OpenRouter **routers** are model slugs that pick (or compose) upstream models for
you. Pass the slug as `model` on `ai.model` — same as any other OpenRouter id.

| Slug                                                                                           | What it does                                          | Cost                            |
| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------- |
| [`openrouter/free`](https://openrouter.ai/docs/guides/routing/routers/free-router)             | Random free model that supports your request features | Free                            |
| [`openrouter/auto`](https://openrouter.ai/docs/guides/routing/routers/auto-router)             | Market-based pick by task type + cost tier            | Selected model rate             |
| [`openrouter/pareto-code`](https://openrouter.ai/docs/guides/routing/routers/pareto-router)    | Strong coding model by `min_coding_score`             | Selected model rate             |
| [`openrouter/fusion`](https://openrouter.ai/docs/guides/routing/routers/fusion-router)         | Multi-model panel + analyst deliberation              | ~4–5× one completion            |
| [`openrouter/bodybuilder`](https://openrouter.ai/docs/guides/routing/routers/body-builder)     | NL → parallel request bodies (you run them)           | Builder free; executions billed |
| [`~author/family-latest`](https://openrouter.ai/docs/guides/routing/routers/latest-resolution) | Newest concrete version in a family                   | Target model rate               |

**Consequence:** the response `model` field is the concrete upstream that answered —
log it for auditability.

Router plugin knobs (`cost_tier`, `allowed_models`, `min_coding_score`, fusion
panel) live on OpenRouter’s request `plugins` / account Routing defaults; OKE
passes the `model` slug through the openai-compatible driver.

### Free Models Router — `openrouter/free`

Ideal for smoke tests, demos, and learning. The router filters free models for
capabilities your request needs (vision, tools, structured outputs), then picks
one at random.

```typescript
export const smoke = ai.model("smoke", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

| Detail           | Behavior                                         |
| ---------------- | ------------------------------------------------ |
| Selection        | Random among eligible free models                |
| Pin a free model | Use `author/model:free` instead of the router    |
| Limits           | Lower rate limits; availability and latency vary |

Official guide:
[Free Models Router](https://openrouter.ai/docs/guides/routing/routers/free-router).

### Auto Router — `openrouter/auto`

Classifies the prompt (~30 task types), ranks by community spend share over a
trailing 7-day window, then applies your cost band and fallbacks.

```typescript
export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/auto",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

| Detail       | Behavior                                                      |
| ------------ | ------------------------------------------------------------- |
| Early track  | `openrouter/auto-beta` (plugin id `auto-beta-router`)         |
| Cost bands   | `low` · `medium` · `high` · `xhigh` · `max` (default ≈ `low`) |
| Allow / deny | Wildcard patterns via `allowed_models` / `excluded_models`    |
| Sessions     | Prefers the prior model while it stays a top candidate        |
| Pricing      | No router fee — pay the selected model                        |

Official guide:
[Auto Router](https://openrouter.ai/docs/guides/routing/routers/auto-router).

### Pareto Router — `openrouter/pareto-code`

Coding-only. You set a minimum coding score (`0`–`1`); the router maps it to a
tier and picks the cheapest (or fastest with `:nitro`) eligible model.

```typescript
export const coder = ai.model("coder", {
  provider: "openrouter",
  model: "openrouter/pareto-code",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

| `min_coding_score`             | Tier                          |
| ------------------------------ | ----------------------------- |
| `>= 0.66` (default if omitted) | high — top of AA coding field |
| `>= 0.33`, `< 0.66`            | medium                        |
| `< 0.33`                       | low                           |

Within the tier: cheapest available (+ same-tier fallbacks on provider errors).
Use `session_id` for multi-turn stickiness. Official guide:
[Pareto Router](https://openrouter.ai/docs/guides/routing/routers/pareto-router).

### Fusion Router — `openrouter/fusion`

A panel of models answers in parallel; an analyst returns structured consensus /
contradictions / gaps; your outer model writes the final answer.

```typescript
export const deliberate = ai.model("deliberate", {
  provider: "openrouter",
  model: "openrouter/fusion",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

| Detail        | Behavior                                                        |
| ------------- | --------------------------------------------------------------- |
| Fast preset   | `openrouter/fusion-flash` (`general-fast` panel)                |
| Default panel | Quality: Claude Opus / GPT / Gemini latest aliases              |
| Cost          | N panel + 1 analyst + outer — expect ~4–5× with 3 models        |
| Force fusion  | OpenRouter `tool_choice: "required"` (model decides by default) |

Official guide:
[Fusion Router](https://openrouter.ai/docs/guides/routing/routers/fusion-router).

### Body Builder — `openrouter/bodybuilder`

Describe a multi-model job in natural language. The response is JSON
`{ requests: [...] }` — generate is free; you execute each body yourself.
Useful for A/B checks, not a single `fx.ask` answer.

```typescript
export const builder = ai.model("builder", {
  provider: "openrouter",
  model: "openrouter/bodybuilder",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

Official guide:
[Body Builder](https://openrouter.ai/docs/guides/routing/routers/body-builder).

### Latest resolution — `~author/family-latest`

Stable family alias that always retargets to the newest visible model in that
family. Response `model` reports the concrete slug.

```typescript
export const opus = ai.model("opus", {
  provider: "openrouter",
  model: "~anthropic/claude-opus-latest",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

| Detail           | Behavior                                                  |
| ---------------- | --------------------------------------------------------- |
| Reproducibility  | Pin a concrete slug (e.g. `anthropic/claude-opus-4.8`)    |
| Reasoning params | Unsupported `none` / disabled may remap on `~latest` only |
| Pricing          | Listed as the current target’s rates                      |

Official guide:
[Latest Model Resolution](https://openrouter.ai/docs/guides/routing/routers/latest-resolution).

## Multi-provider projects

```typescript
export const viaOr = ai.model("via-or", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const viaGroq = ai.model("via-groq", {
  provider: "groq",
  model: "llama-3.1-8b-instant",
  apiKey: process.env.GROQ_API_KEY,
});
```

Each binding keeps its own `apiKey` and auto-resolved `baseUrl`. No shared
process-wide token.

## Troubleshooting

<Accordions>
<Accordion title="401 / invalid API key">

Pass `apiKey` on the binding (or the env your setup wrote). OpenRouter does not
accept an OpenAI key against its base URL.

</Accordion>
<Accordion title="Unknown provider error">

Typos fail loud: unknown names require an explicit `baseUrl`. Known names are
listed on [Models](/docs/elements/ai/models).

</Accordion>
<Accordion title="Free router feels flaky or rate-limited">

`openrouter/free` picks randomly among eligible free models — availability and
limits change. Pin `author/model:free` or move to `openrouter/auto` / a paid id
for stable prod paths.

</Accordion>
</Accordions>

## Learn more

- [Models](/docs/elements/ai/models) — verified providers, limited compatibility, BYO `/v1`
- [AI](/docs/elements/ai) — prompts and `fx.ask`
- [OpenRouter routers](https://openrouter.ai/docs/guides/routing/routers/auto-router) — Auto, Free, Pareto, Fusion, Body Builder, Latest

Any OpenAI-compatible `/v1` endpoint works via custom `baseUrl` / `OKE_AI_URL`
on an `openai-compatible` binding — see Models.

## Next

<Cards>
  <Card
    title="Models"
    description="Provider registry + custom URL."
    href="/docs/elements/ai/models"
  />
  <Card title="AI" description="Prompts and guardrails." href="/docs/elements/ai" />
</Cards>


# PgDog (/docs/recipes/pgdog)

Bun.SQL defaults to **10** connections per process. Scale to several app instances and
`N × pool` can exceed Postgres `max_connections`. PgDog sits in front of `store.sql` as a
transaction-pooling proxy — same wire protocol, no app code changes.

<Callout title="The one rule">
  Pin `pgdog` alongside `store.sql` and `DATABASE_URL` automatically points at the pooler on `:6432`
  instead of Postgres directly — Bun.SQL and Drizzle see no difference.
</Callout>

## Quick start

<Steps>

<Step>
### Pin both images

create-oke asks **Add PgDog connection pooling…?** (or pass `--pgdog`). Manually:

```typescript title="oke.config.ts"
images: {
  "store.sql": "postgres:18-alpine",
  pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
},
```

</Step>

<Step>
### Derive compose

```bash
oke docker
```

Writes `./pgdog/pgdog.toml` (listen + one primary database) and
`./pgdog/users.toml` (same user/password/database as Postgres). The pooler
waits on `store-sql` health before start.

</Step>

<Step>
### Confirm the rewrite

```bash
echo "$DATABASE_URL"
# …@host:6432/db  ← pooler, not :5432
echo "$OKE_STORE_SQL_URL"   # still the direct Postgres URL when you need it
```

</Step>

</Steps>

## Required env

| Variable                                 | Role                                                             |
| ---------------------------------------- | ---------------------------------------------------------------- |
| `OKE_STORE_SQL_USER` / `PASSWORD` / `DB` | Copied into `pgdog/users.toml` for client + server auth          |
| `DATABASE_URL`                           | Rewritten to the pooler URL (`:6432`) when PgDog is in the stack |
| `OKE_PGDOG_URL`                          | Same value as pooled `DATABASE_URL` when both are present        |
| `OKE_STORE_SQL_URL`                      | Direct Postgres host — bypass the pooler for admin / migrations  |

PgDog itself has no separate credential env — it mounts the generated TOML files read-only.

## Data and backup

| Mount                                     | What it is                                                         |
| ----------------------------------------- | ------------------------------------------------------------------ |
| `./pgdog/pgdog.toml:/pgdog/pgdog.toml:ro` | Generated listen + `[[databases]]` (host `store-sql`, port `5432`) |
| `./pgdog/users.toml:/pgdog/users.toml:ro` | Generated `[[users]]` credentials                                  |

**No database state lives in the PgDog container.** Backing up PgDog means keeping those
two config files (they are regenerated by `oke docker`). Durable data stays on the
[Postgres](/docs/recipes/postgres) volume / dump.

## Production note

Pooling mode is `transaction` — set explicitly (also PgDog's upstream default). Naive
poolers can leak session state (`SET`, RLS vars, `LISTEN`/`NOTIFY`) across clients;
PgDog re-applies that state per transaction.

**Do not stack** PgDog in front of a managed pooler (Neon `-pooler`, Supabase Supavisor
`:6543`). Pick one pooler. Use `OKE_STORE_SQL_URL` (direct) for migrations and anything
that needs session features.

Read-replica routing (`BEGIN READ ONLY` → replica) is documented readiness in PgDog —
not wired into the generated stack yet.

## What the recipe configures

| Field          | Value                                                   |
| -------------- | ------------------------------------------------------- |
| Container port | `6432`                                                  |
| `dependsOn`    | `store-sql` healthy                                     |
| Healthcheck    | `pg_isready -h 127.0.0.1 -p 6432`, every 2s, 20 retries |
| Connection URL | `postgres://user:pass@host:6432/db`                     |

## Troubleshooting

<Accordions>
<Accordion title="pgdog unhealthy — pg_isready on :6432 fails">

Usually the backend is not ready, or `pgdog/users.toml` credentials do not match Postgres.
Confirm `store-sql` is healthy first, then re-run `oke docker` so TOML matches
`OKE_STORE_SQL_*`. Logs often show auth failures against `store-sql:5432`.

</Accordion>
<Accordion title="Session features break through the pooler">

Transaction mode does not preserve session-scoped state the way a direct connection
does. Point migrations / `LISTEN` / session `SET` at `OKE_STORE_SQL_URL` (direct
Postgres), keep `DATABASE_URL` on the pooler for app traffic.

</Accordion>
</Accordions>

## Learn more

- [Postgres](/docs/recipes/postgres) — the backend PgDog fronts
- [Store · SQL](/docs/elements/store#sql) — “Connection pooling is infrastructure”
- [Configuration](/docs/reference/configuration) — `images.pgdog` and compose layers

## Next

<Cards>
  <Card title="Postgres" description="The backend PgDog fronts." href="/docs/recipes/postgres" />
  <Card
    title="Supabase"
    description="Also Postgres wire — same pooler applies."
    href="/docs/recipes/supabase-docker"
  />
  <Card title="Caddy" description="TLS at the edge, once you need it." href="/docs/recipes/caddy" />
</Cards>


# Postgres (/docs/recipes/postgres)

Postgres is the default `store.sql` image in `dev` and `prod`. `oke docker` matches
any image whose reference contains `postgres` or `pgvector` (after more specific
recipes), then derives env, healthcheck, and connection URL.

<Callout title="The one rule">
  The driver id stays `postgres` no matter which Postgres-wire image you pin — vendor choice lives
  in `images["store.sql"]`, not in `drivers.store.sql`.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.sql": "postgres:18-alpine", // any Postgres / pgvector image
},
```

</Step>

<Step>
### Bring the stack up

```bash
oke dev
```

`oke docker` writes `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` into
`.env.local` as `${OKE_STORE_SQL_USER}` / `${OKE_STORE_SQL_PASSWORD}` /
`${OKE_STORE_SQL_DB}` refs — never literal secrets in compose YAML.

</Step>

<Step>
### Connect

```bash
# App reads this (also accepts OKE_STORE_SQL_URL)
echo "$DATABASE_URL"
# postgres://oke:…@127.0.0.1:5432/oke
```

When PgDog is pinned, `DATABASE_URL` points at `:6432` instead — see
[PgDog](/docs/recipes/pgdog).

</Step>

</Steps>

## Required env

| Variable                 | Who sets it            | Meaning                              |
| ------------------------ | ---------------------- | ------------------------------------ |
| `OKE_STORE_SQL_USER`     | `oke docker` → compose | Injected as `POSTGRES_USER`          |
| `OKE_STORE_SQL_PASSWORD` | `oke docker` → compose | Injected as `POSTGRES_PASSWORD`      |
| `OKE_STORE_SQL_DB`       | `oke docker` → compose | Injected as `POSTGRES_DB`            |
| `DATABASE_URL`           | stack env for the app  | What Bun.SQL / Drizzle actually open |
| `PGDATA`                 | stack default          | `/var/lib/postgresql/data/pgdata`    |
| `POSTGRES_INITDB_ARGS`   | stack default          | `--data-checksums`                   |

## Data and backup

| Path                                          | What lives there                                     |
| --------------------------------------------- | ---------------------------------------------------- |
| `$PGDATA` (`/var/lib/postgresql/data/pgdata`) | Cluster data directory                               |
| Image `VOLUME` `/var/lib/postgresql/data`     | Official image mount — Docker attaches a volume here |

The recipe does **not** declare a named compose volume. Persistence rides the image's
`VOLUME` (anonymous unless you add a named mount in `compose.override.yml`).

**Backup means:** `pg_dump` / `pg_dumpall`, or a Docker volume backup of
`/var/lib/postgresql/data`. Losing that volume loses the cluster.

## Production note

For multi-replica apps, keep SQL on a shared Postgres — Clock CronStore and durable
journal need one backend. See [Clock](/docs/elements/clock#what-the-runtime-guarantees).

Scale out with [PgDog](/docs/recipes/pgdog) so `N × Bun.SQL pool` does not exhaust
`max_connections`. Do not also stack a managed provider's pooler on the same URL.

## What the recipe configures

| Field          | Value                                                     |
| -------------- | --------------------------------------------------------- |
| Container port | `5432`                                                    |
| Healthcheck    | `pg_isready -U $POSTGRES_USER`, every 5s, 10 retries      |
| Connection URL | `postgres://user:pass@host:5432/db`                       |
| Preload        | `postgres -c shared_preload_libraries=pg_stat_statements` |

## Query performance

<Callout title="Preload, then create">
  `pg_stat_statements` must load at postmaster start. `CREATE EXTENSION` alone is not enough.
  Recreate `store-sql` after a recipe change — the data volume can stay.
</Callout>

| Step    | What you do                                                       |
| ------- | ----------------------------------------------------------------- |
| Preload | Recipe already sets `shared_preload_libraries=pg_stat_statements` |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements`               |
| Console | Store → SQL band → **Performance**                                |

Default `postgres:18-alpine` does **not** ship `hypopg` / `index_advisor`. Pin the
opt-in image when you want Suggest indexes:

```typescript title="oke.config.ts"
images: {
  "store.sql": "oke-postgres-advisor:18-alpine",
},
```

`oke docker` writes `Dockerfile.postgres-advisor` and a compose `build:`. Driver id
stays `postgres`. Suggest copies `CREATE INDEX` DDL — it does not create indexes.

## Troubleshooting

<Accordions>
<Accordion title="oke boot: postgres driver needs DATABASE_URL">

The `postgres` driver fails boot when neither `DATABASE_URL` nor `OKE_STORE_SQL_URL` is
set. Re-run `oke dev` so the stack writes `.env.local`, or export
`DATABASE_URL` yourself when pointing at a managed host.

</Accordion>
<Accordion title="PgStatStatementsNotPreloaded">

The Console Performance view needs the library preloaded **and** the extension
created. Recreate `store-sql` so the new `command` applies, then run
`CREATE EXTENSION IF NOT EXISTS pg_stat_statements`.

Existing clusters that started before this recipe change keep the old
postmaster flags until recreate.

</Accordion>
<Accordion title="pg_isready fails / store-sql unhealthy">

Wrong `POSTGRES_USER` or the container is still initializing. Check
`docker compose … logs store-sql` for `database system is ready to accept connections`.

Credential refs must resolve in `.env.local` — empty `${OKE_STORE_SQL_PASSWORD}` leaves
Postgres refusing auth.

</Accordion>
</Accordions>

## Learn more

- [PgDog](/docs/recipes/pgdog) — transaction pooling in front of this recipe
- [Store · SQL](/docs/elements/store#sql) — schema push / generate / migrate
- [Environment variables](/docs/reference/environment-variables) — `DATABASE_URL` precedence
- [Neon](/docs/providers/neon) · [Supabase](/docs/providers/supabase) — managed alternatives

## Next

<Cards>
  <Card title="PgDog" description="Add pooling in front of Postgres." href="/docs/recipes/pgdog" />
  <Card title="Neon" description="A managed alternative." href="/docs/providers/neon" />
  <Card title="Redis" description="The default store.kv image." href="/docs/recipes/redis" />
</Cards>


# Redis (/docs/recipes/redis)

Redis is the default `store.kv` image in `dev` and `prod`. `oke docker` matches image
references containing `redis` or `keydb` and derives a password-required server — no
unauthenticated instance.

<Callout title="The one rule">
  The driver id stays `redis` for every Redis-wire image — Redis, Valkey, Dragonfly, or a managed
  provider. Vendor choice lives in `images["store.kv"]`, never in `drivers.store.kv`.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.kv": "redis:8-alpine",
},
```

</Step>

<Step>
### Required password

```bash title=".env.local (written by oke docker)"
OKE_STORE_KV_PASSWORD=…
REDIS_URL=redis://:…@127.0.0.1:6379
```

The recipe runs:

`redis-server --requirepass "$OKE_STORE_KV_PASSWORD" --maxmemory … --maxmemory-policy …`

</Step>

<Step>
### Optional memory caps

| Variable                        | Default in recipe                                       | Meaning  |
| ------------------------------- | ------------------------------------------------------- | -------- |
| `OKE_STORE_KV_MAXMEMORY`        | `0` (unlimited) in command; stack often sets `256mb`    | Max RSS  |
| `OKE_STORE_KV_MAXMEMORY_POLICY` | `noeviction` in command; stack often sets `allkeys-lru` | Eviction |

</Step>

</Steps>

## Required env

| Variable                        | Required?     | Meaning                                                            |
| ------------------------------- | ------------- | ------------------------------------------------------------------ |
| `OKE_STORE_KV_PASSWORD`         | **Yes**       | `--requirepass` — empty password is not a valid production posture |
| `REDIS_URL`                     | **Yes** (app) | What the `redis` driver opens (`OKE_STORE_KV_URL` also works)      |
| `OKE_STORE_KV_MAXMEMORY`        | Optional      | Cap memory                                                         |
| `OKE_STORE_KV_MAXMEMORY_POLICY` | Optional      | Eviction when at cap                                               |

## Data and backup

The default `store.kv` recipe declares **no named volume**. Process memory is the source of truth;
a container recreate loses those keys.

Keys that must survive go on `{ durable: true }` — a JSONB table on your SQL database, not a
second Redis. See [Store · Durable KV](/docs/elements/store#durable-kv).

## Production note

<Callout title="License note" type="warn">
  Redis ≥8 is dual-licensed RSALv2 / SSPLv1 / AGPLv3. Those terms restrict offering Redis itself as
  a managed service to third parties — running it for your own app is unaffected. Prefer
  [Valkey](/docs/recipes/valkey) when the managed-service restriction matters.
</Callout>

Multi-replica Gate rate counters and Signal need a **shared** Redis URL — an in-process
`memory` driver is per instance. Same shared-store idea as
[Clock](/docs/elements/clock#what-the-runtime-guarantees) for cron exclusivity.

## What the recipe configures

| Field          | Value                                                |
| -------------- | ---------------------------------------------------- |
| Container port | `6379`                                               |
| Healthcheck    | `redis-cli -a <password> ping`, every 5s, 10 retries |
| Connection URL | `redis://:pass@host:6379`                            |

<Callout title="Console Performance">
  Store → KV band → **Performance** reads `INFO` / `SLOWLOG` on this instance. Those commands are
  server-wide, not scoped to `oke:kv:{ns}:`. `memory` (tests) returns `KvStatsUnsupported`.
</Callout>

## Troubleshooting

<Accordions>
<Accordion title="oke boot: redis driver needs REDIS_URL">

Missing `REDIS_URL` fails boot loudly — never a silent fallback to `memory`. Re-run
`oke dev` or export `REDIS_URL` when using a managed host. Under Compose the
message asks whether `oke dev -d` wrote `.env.local`.

</Accordion>
<Accordion title="NOAUTH Authentication required">

`REDIS_URL` is missing the password, or it does not match `OKE_STORE_KV_PASSWORD`.
Format must be `redis://:PASSWORD@host:6379` (colon before the password, empty username).

</Accordion>
</Accordions>

## Learn more

- [Store · KV](/docs/elements/store#kv) — TTL physics, per-driver behavior, license table
- [Valkey](/docs/recipes/valkey) · [Dragonfly](/docs/recipes/dragonfly) — Redis-wire peers
- [Environment variables](/docs/reference/environment-variables) — `REDIS_URL` precedence

## Next

<Cards>
  <Card title="Valkey" description="Permissive-licensed alternative." href="/docs/recipes/valkey" />
  <Card title="Redis Cloud" description="Managed Redis." href="/docs/providers/redis-cloud" />
  <Card
    title="Upstash"
    description="Serverless Redis-protocol option."
    href="/docs/providers/upstash"
  />
</Cards>


# RustFS (/docs/recipes/rustfs)

RustFS is the default `oke dev` / prod `store.files` image — an Apache-2.0 S3-compatible
object store. Driver id stays `s3`; Bun binds `Bun.S3Client` against the endpoint.

<Callout title="The one rule">
  Protocol name is `s3` — vendor choice is the image pin (`rustfs/rustfs:…`) plus `S3_ENDPOINT`.
  Never invent a `rustfs` driver id.
</Callout>

## Quick start

<Steps>

<Step>
### Pin driver + image (templates already do)

```typescript title="oke.config.ts"
drivers: {
  store: {
    files: { dev: "s3", test: "memory", prod: "s3" },
  },
},
images: {
  store: { files: "rustfs/rustfs:1.0.0-rc.5" },
},
```

</Step>

<Step>
### Credentials and endpoint

```bash title=".env.local (written by oke docker)"
S3_ACCESS_KEY_ID=…
S3_SECRET_ACCESS_KEY=…
S3_BUCKET=oke
S3_ENDPOINT=http://127.0.0.1:9000
S3_URL=http://…@127.0.0.1:9000/oke
S3_REGION=us-east-1
S3_CONSOLE_URL=http://127.0.0.1:9001
```

Recipe maps:

- `RUSTFS_ACCESS_KEY=${S3_ACCESS_KEY_ID}`
- `RUSTFS_SECRET_KEY=${S3_SECRET_ACCESS_KEY}`
- `RUSTFS_CONSOLE_ENABLE=true`
- `RUSTFS_ADDRESS=:9000`
- command: `/data`

</Step>

<Step>
### Put an object

Any flow using `fx.store` files (`put` / `get`) against the `s3` driver talks to this
endpoint. Open `S3_CONSOLE_URL` for a browser console on port **9001**.

</Step>

</Steps>

## Required env

| Variable               | Required?         | Meaning                                    |
| ---------------------- | ----------------- | ------------------------------------------ |
| `S3_ACCESS_KEY_ID`     | **Yes**           | Access key → `RUSTFS_ACCESS_KEY`           |
| `S3_SECRET_ACCESS_KEY` | **Yes**           | Secret key → `RUSTFS_SECRET_KEY`           |
| `S3_BUCKET`            | **Yes** (app)     | Bucket name the driver opens               |
| `S3_ENDPOINT`          | **Yes** (non-AWS) | Origin `http://host:9000`                  |
| `S3_REGION`            | Optional          | Default `us-east-1` in stack env           |
| `S3_SESSION_TOKEN`     | Optional          | Temporary creds                            |
| `S3_CONSOLE_URL`       | Written by stack  | UI on `:9001` — not required by the driver |
| `S3_URL`               | Written by stack  | Credentialed URL form                      |

## Data and backup

| Volume             | Path    | What it stores                 |
| ------------------ | ------- | ------------------------------ |
| `store-files-data` | `/data` | Object bytes + RustFS metadata |

**Backup means:** snapshot that named volume (or `aws s3 sync` against the API).
Deleting the volume deletes every object.

Credentials live in `.env.local`, not in `/data` — rotate keys without wiping objects.
Losing both volume and keys means full restore from backup only.

## Production note

For cloud prod, point the same `s3` driver at real S3 / R2 / GCS interop by changing
`S3_ENDPOINT` + keys — keep the driver id. Self-hosted RustFS on a single node is a
durability SPOF: put the `/data` volume on reliable disks and back it up.

Do not publish `:9000` / `:9001` on the public internet without TLS and network policy —
the recipe enables the console for local ops.

## What the recipe configures

| Field          | Value                                                                   |
| -------------- | ----------------------------------------------------------------------- |
| API port       | `9000`                                                                  |
| Extra port     | `9001` → console                                                        |
| Healthcheck    | `curl -f http://127.0.0.1:9000/health`, every 5s, 12 retries, 10s start |
| Connection URL | `http://user:pass@host:9000/bucket`                                     |

## Troubleshooting

<Accordions>
<Accordion title="oke boot: redis / meilisearch / S3 URL missing — S3_BUCKET">

Files on the `s3` driver need `S3_BUCKET` (or `OKE_STORE_FILES_DB`). Without it the
facet cannot open. Re-run `oke dev` so stack env fills `S3_*`, or export them
for a managed bucket.

</Accordion>
<Accordion title="Access Denied / signature errors against :9000">

`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` do not match `RUSTFS_*` inside the
container, or `S3_ENDPOINT` points at the wrong host/port. Align `.env.local` with the
running service; confirm health at `/health` before debugging client signatures.

</Accordion>
</Accordions>

## Learn more

- [Store · Files](/docs/elements/store#files) — facet API and drivers
- [Environment variables](/docs/reference/environment-variables) — full `S3_*` map
- [Recipes](/docs/recipes) — docker env cheat sheet

## Next

<Cards>
  <Card
    title="Mailpit"
    description="SMTP catcher for the same stack."
    href="/docs/recipes/mailpit"
  />
  <Card
    title="Vault"
    description="Secrets and built-in encrypted store."
    href="/docs/elements/vault"
  />
  <Card title="Store" description="Files facet details." href="/docs/elements/store" />
</Cards>


# Supabase (/docs/recipes/supabase-docker)

Supabase publishes `supabase/postgres` — plain Postgres with a bundled extension set
(`pgvector`, `pg_graphql`, `pg_cron`, `wrappers`, and more). `oke docker` matches that
image ahead of the generic Postgres recipe.

<Callout title="Not the full Supabase platform">
  This recipe does **not** start GoTrue, PostgREST, Kong, Realtime, Storage, or Studio. Auth, files,
  and realtime delivery are already covered by [Vault](/docs/elements/vault), [Store ·
  files](/docs/elements/store#files), and [Signal](/docs/elements/signal).
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.sql": "supabase/postgres:15.8.1.049", // pin a real tag from Supabase's registry
},
```

</Step>

<Step>
### Same env shape as Postgres

`oke docker` injects the same `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`
refs from `OKE_STORE_SQL_*`. Driver id stays `postgres`.

</Step>

<Step>
### Use pgvector when you need it

```typescript
drivers: {
  store: {
    index: { test: "memory", prod: "pgvector" },
  },
},
```

`CREATE EXTENSION IF NOT EXISTS vector` runs when the `pgvector` index driver opens —
the extension ships in this image.

</Step>

</Steps>

## Required env

| Variable                 | Meaning                                   |
| ------------------------ | ----------------------------------------- |
| `OKE_STORE_SQL_USER`     | → `POSTGRES_USER`                         |
| `OKE_STORE_SQL_PASSWORD` | → `POSTGRES_PASSWORD`                     |
| `OKE_STORE_SQL_DB`       | → `POSTGRES_DB`                           |
| `DATABASE_URL`           | App connection (`postgres://…:5432/…`)    |
| `PGDATA`                 | Default `/var/lib/postgresql/data/pgdata` |

Identical credential contract to [Postgres](/docs/recipes/postgres) — only the image
(and its extension bundle) differs.

## Data and backup

| Path                                      | What lives there                           |
| ----------------------------------------- | ------------------------------------------ |
| `$PGDATA`                                 | Cluster data, including extension catalogs |
| Image `VOLUME` `/var/lib/postgresql/data` | Persistence mount                          |

Same backup story as plain Postgres: `pg_dump` or volume backup of
`/var/lib/postgresql/data`. Losing that volume loses tables **and** which extensions
were created.

## Production note

Want managed Supabase (Auth / Storage / Realtime included)? Use the
[Supabase provider](/docs/providers/supabase) and set `DATABASE_URL` — do not run this
image and the cloud project as if they were the same deployment.

For self-hosted production, prefer a pinned tag (not `latest`) and the same
shared-store guidance as [Postgres](/docs/recipes/postgres#production-note). Free-tier
cloud pausing does not apply to this Docker image — you own uptime.

## What the recipe configures

| Field          | Value                                                |
| -------------- | ---------------------------------------------------- |
| Match          | `supabase/postgres` before generic `postgres`        |
| Container port | `5432`                                               |
| Healthcheck    | `pg_isready -U $POSTGRES_USER`, every 5s, 10 retries |
| Connection URL | `postgres://user:pass@host:5432/db`                  |
| Preload        | Image config — recipe does **not** set `command`     |

## Query performance

<Callout title="Do not overwrite command">
  `supabase/postgres` already preloads `pg_stat_statements`. This recipe leaves `command` unset so
  vendor startup stays intact.
</Callout>

| Step    | What you do                                                        |
| ------- | ------------------------------------------------------------------ |
| Preload | Image `postgresql.conf` (not an oke `command`)                     |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements`                |
| Advisor | `CREATE EXTENSION IF NOT EXISTS index_advisor CASCADE` when listed |

Console **Performance** then reads engine stats. Enable Index Advisor from the
same view when `pg_available_extensions` lists it.

## Troubleshooting

<Accordions>
<Accordion title="PgStatStatementsNotPreloaded after a custom command">

Do not replace the image entrypoint with a generic `postgres -c
shared_preload_libraries=pg_stat_statements`. Restore the vendor command, create
the extension, then open Store → **Performance**.

</Accordion>
<Accordion title='Extension "vector" is not available'>

You pinned a plain `postgres:` image, not `supabase/postgres`, or the tag predates the
bundle. Confirm the image reference contains `supabase/postgres`, then re-derive.
With the right image, `CREATE EXTENSION vector` succeeds without a separate install.

</Accordion>
<Accordion title="Looking for Studio / the REST API">

Those services are not part of this recipe. Use the
[managed Supabase provider](/docs/providers/supabase) for the full platform, or oke's
own Vault / Store · files / Signal for those concerns.

</Accordion>
</Accordions>

## Learn more

- [Supabase (provider)](/docs/providers/supabase) — managed cloud alternative
- [Postgres](/docs/recipes/postgres) — generic recipe this one takes precedence over
- [Store · Index](/docs/elements/store#index) — `pgvector`-backed vector search

## Next

<Cards>
  <Card
    title="Supabase"
    description="Managed cloud, same wire protocol."
    href="/docs/providers/supabase"
  />
  <Card
    title="PgDog"
    description="Add pooling in front of this recipe too."
    href="/docs/recipes/pgdog"
  />
  <Card
    title="Postgres"
    description="The plain image this specializes."
    href="/docs/recipes/postgres"
  />
</Cards>


# Timescale (/docs/recipes/timescale)

TimescaleDB is Postgres plus hypertables and time-series tooling. Pin a
`timescale/timescaledb` image as `store.sql` and `oke docker` uses the Timescale
recipe — same `POSTGRES_*` contract as plain Postgres. Driver id stays `postgres`.

<Callout title="The one rule">
  The driver id stays `postgres` — vendor choice lives in `images["store.sql"]`. Create the
  `timescaledb` extension in SQL when you need hypertables; the image ships it.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.sql": "timescale/timescaledb:latest-pg17", // pin a real tag
},
```

</Step>

<Step>
### Bring the stack up

```bash
oke dev
```

Same credential contract as [Postgres](/docs/recipes/postgres):
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` from `OKE_STORE_SQL_*`.

</Step>

<Step>
### Enable the extension when you need it

```sql
CREATE EXTENSION IF NOT EXISTS timescaledb;
```

Then create hypertables with Timescale's usual DDL — Flows still talk through
`fx.store` / Drizzle like any other Postgres.

</Step>

</Steps>

## Required env

| Variable                 | Who sets it            | Meaning                           |
| ------------------------ | ---------------------- | --------------------------------- |
| `OKE_STORE_SQL_USER`     | `oke docker` → compose | → `POSTGRES_USER`                 |
| `OKE_STORE_SQL_PASSWORD` | `oke docker` → compose | → `POSTGRES_PASSWORD`             |
| `OKE_STORE_SQL_DB`       | `oke docker` → compose | → `POSTGRES_DB`                   |
| `DATABASE_URL`           | stack env for the app  | `postgres://…:5432/…`             |
| `PGDATA`                 | stack default          | `/var/lib/postgresql/data/pgdata` |

## Data and backup

| Path                                      | What lives there                           |
| ----------------------------------------- | ------------------------------------------ |
| `$PGDATA`                                 | Cluster data, including Timescale catalogs |
| Image `VOLUME` `/var/lib/postgresql/data` | Persistence mount                          |

Same backup story as plain Postgres: `pg_dump` / volume backup of
`/var/lib/postgresql/data`. Losing that volume loses tables and extension state.

## Production note

Pin a concrete tag (image + Postgres major). For multi-replica apps, keep one
shared SQL backend — see
[Postgres · Production note](/docs/recipes/postgres#production-note).

Managed time-series hosts still use `DATABASE_URL` and
`drivers.store.sql: "postgres"`.

## What the recipe configures

| Field          | Value                                                  |
| -------------- | ------------------------------------------------------ |
| Match          | `timescale` ahead of generic `postgres`                |
| Container port | `5432`                                                 |
| Healthcheck    | `pg_isready -U $POSTGRES_USER`                         |
| Connection URL | `postgres://user:pass@host:5432/db`                    |
| Preload        | `timescaledb,pg_stat_statements` — Timescale **first** |

## Query performance

<Callout title="Keep timescaledb first">
  Overwriting `command` with only `pg_stat_statements` breaks hypertables. This recipe preloads
  `timescaledb,pg_stat_statements`. Then `CREATE EXTENSION pg_stat_statements`.
</Callout>

| Step    | What you do                                                           |
| ------- | --------------------------------------------------------------------- |
| Preload | Recipe sets `shared_preload_libraries=timescaledb,pg_stat_statements` |
| Create  | `CREATE EXTENSION IF NOT EXISTS pg_stat_statements`                   |
| Console | Store → SQL band → **Performance**                                    |

Index Advisor is not in the Timescale image. Pin
`oke-postgres-advisor:18-alpine` only if you drop Timescale — or enable
`index_advisor` on an image that ships it.

## Troubleshooting

<Accordions>
<Accordion title="hypertables missing after a custom command">

If you override compose `command`, keep `timescaledb` first in
`shared_preload_libraries`. A `pg_stat_statements`-only preload unloads Timescale.

</Accordion>
<Accordion title='extension "timescaledb" is not available'>

You pinned a plain `postgres:` image. Confirm the reference contains `timescale`,
re-derive, then `CREATE EXTENSION timescaledb`.

</Accordion>
<Accordion title="oke boot: postgres driver needs DATABASE_URL">

Same as Postgres — re-run `oke dev` or export `DATABASE_URL` for a remote
host.

</Accordion>
</Accordions>

## Learn more

- [Postgres](/docs/recipes/postgres) — generic recipe this specialises
- [Store · SQL](/docs/elements/store#sql) — schema workflows
- [Environment variables](/docs/reference/environment-variables) — `DATABASE_URL`

## Next

<Cards>
  <Card title="Postgres" description="Plain Postgres recipe." href="/docs/recipes/postgres" />
  <Card
    title="Supabase"
    description="Postgres + extension bundle."
    href="/docs/recipes/supabase-docker"
  />
  <Card title="PgDog" description="Transaction pooling in front." href="/docs/recipes/pgdog" />
</Cards>


# Traefik (/docs/recipes/traefik)

Traefik discovers `app` replicas from Docker labels instead of a static upstream, so
`docker compose up --scale app=N` load-balances without reconfiguring the proxy.

<Callout title="The one rule">
  Traefik never mounts the raw Docker socket. A filtered `tecnativa/docker-socket-proxy` companion
  exposes only containers / events / ping / version / networks on the internal compose network.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the proxy

```typescript title="oke.config.ts"
images: {
  proxy: "traefik:v3.7",
},
```

</Step>

<Step>
### Hostname + ACME email

```bash title=".env.local"
OKE_PROXY_HOST=app.example.com
OKE_PROXY_ACME_EMAIL=admin@example.com
```

`OKE_PROXY_ACME_EMAIL` defaults to `admin@example.com` if unset — set a real mailbox
before production ACME.

</Step>

<Step>
### Scale the app

```bash
docker compose … up -d --scale app=3
```

App labels: `traefik.http.routers.app.rule=Host(...)`, TLS cert resolver `letsencrypt`,
load-balancer port `6530`.

</Step>

</Steps>

## Required env

| Variable                | Required?            | Meaning                                        |
| ----------------------- | -------------------- | ---------------------------------------------- |
| `OKE_PROXY_HOST`        | **Yes** (production) | Host rule + ACME identity; default `localhost` |
| `OKE_PROXY_ACME_EMAIL`  | **Yes** (production) | Let's Encrypt account email                    |
| `allowedHosts` (config) | Production           | Must include the public hostname               |

## Data and backup

| Volume              | Path                      | What it stores                                    |
| ------------------- | ------------------------- | ------------------------------------------------- |
| `proxy-letsencrypt` | `/letsencrypt`            | `acme.json` — certificates + ACME account         |
| Socket proxy only   | `/var/run/docker.sock:ro` | **Only** the `socket-proxy` companion mounts this |

**Backup means:** preserve the `proxy-letsencrypt` volume (especially `acme.json`). Losing
it forces re-issuance and can hit Let's Encrypt rate limits. The socket-proxy has no
durable state.

## Production note

Security posture is intentional: Traefik talks to `tcp://socket-proxy:2375` with a
filtered API (`CONTAINERS`, `EVENTS`, `PING`, `VERSION`, `NETWORKS` only). Never mount
raw `docker.sock` on Traefik — keep the socket-proxy companion in front.

HTTP entrypoint redirects to `websecure`. For a single instance with no scale plans,
[Caddy](/docs/recipes/caddy) is simpler (no labels, no companion).

## What the recipe configures

| Field       | Value                                              |
| ----------- | -------------------------------------------------- |
| Ports       | `80` + `443`                                       |
| Providers   | Docker via socket-proxy, `exposedbydefault=false`  |
| Companion   | `tecnativa/docker-socket-proxy:v0.5.0`             |
| Healthcheck | `traefik healthcheck --ping`, every 10s, 5 retries |

## Troubleshooting

<Accordions>
<Accordion title="404 / no backend — Traefik cannot see app">

Usually `socket-proxy` is down, or `app` lacks Traefik labels. Confirm
`depends_on: socket-proxy` and that `compose.proxy.yml` merged the app labels.

Logs showing Docker provider connection errors point at the socket-proxy, not Traefik
config syntax.

</Accordion>
<Accordion title="oke dev: service app has neither an image nor a build context">

`oke dev` starts infra only; the app stays on the host at `:6530`.
Traefik labels apply when `app` is in Compose — use `oke docker` for that.
Re-run `oke dev` after upgrading so compose drops the label-only `app`.

</Accordion>
<Accordion title="ACME email rejected / rate limited">

Replace the default `admin@example.com` with a real `OKE_PROXY_ACME_EMAIL`. If you wiped
`proxy-letsencrypt`, you may be rate-limited — restore `acme.json` from backup instead of
re-issuing repeatedly.

</Accordion>
</Accordions>

## Learn more

- [Caddy](/docs/recipes/caddy) — simpler single-instance HTTPS edge
- [nginx](/docs/recipes/nginx) — static HTTP reverse proxy
- [Security](/docs/reference/security) — `allowedHosts`

## Next

<Cards>
  <Card title="Caddy" description="Single-instance alternative." href="/docs/recipes/caddy" />
  <Card title="nginx" description="Static HTTP reverse proxy." href="/docs/recipes/nginx" />
  <Card
    title="PgDog"
    description="Pool Postgres behind the same stack."
    href="/docs/recipes/pgdog"
  />
</Cards>


# Valkey (/docs/recipes/valkey)

Valkey is the Linux Foundation's BSD-3-Clause fork of Redis, forked after Redis's 2024
license change. Same wire protocol — pin the image and nothing in Flow code moves.

<Callout title="The one rule">
  Driver id stays `redis`. Valkey is an image choice, not a driver. `REDIS_URL` and every KV call
  through `fx.store` work exactly as they do against Redis.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.kv": "valkey/valkey:8-alpine",
},
```

</Step>

<Step>
### Same password contract as Redis

```bash
OKE_STORE_KV_PASSWORD=…
REDIS_URL=redis://:…@127.0.0.1:6379
```

Command becomes `valkey-server --requirepass "$OKE_STORE_KV_PASSWORD" …` — healthcheck
uses `valkey-cli`.

</Step>

<Step>
### Optional memory caps

Same knobs as Redis: `OKE_STORE_KV_MAXMEMORY`, `OKE_STORE_KV_MAXMEMORY_POLICY`.

</Step>

</Steps>

## Required env

| Variable                        | Required?     | Meaning                                             |
| ------------------------------- | ------------- | --------------------------------------------------- |
| `OKE_STORE_KV_PASSWORD`         | **Yes**       | `--requirepass` for `valkey-server`                 |
| `REDIS_URL`                     | **Yes** (app) | Still `redis://` scheme — protocol name, not vendor |
| `OKE_STORE_KV_MAXMEMORY`        | Optional      | Memory cap                                          |
| `OKE_STORE_KV_MAXMEMORY_POLICY` | Optional      | Eviction policy                                     |

## Data and backup

The default `store.kv` recipe declares **no named volume** — same ephemeral default as
[Redis](/docs/recipes/redis).

Keys that must survive go on `{ durable: true }` — a JSONB table on your SQL database.
See [Store · Durable KV](/docs/elements/store#durable-kv).

## Production note

Choose Valkey when RSAL/SSPL managed-service terms on Redis ≥8 matter to your legal
posture — BSD-3-Clause carries no such restriction. Feature parity with Redis 7/8 stays
close since the fork tracks upstream.

DigitalOcean's managed product moved onto Valkey after Aiven stepped back from Redis —
see [DigitalOcean Managed Caching](/docs/providers/digitalocean-caching).

## What the recipe configures

| Field          | Value                                                              |
| -------------- | ------------------------------------------------------------------ |
| Container port | `6379`                                                             |
| Command        | `valkey-server --requirepass … --maxmemory … --maxmemory-policy …` |
| Healthcheck    | `valkey-cli -a <password> ping`, every 5s, 10 retries              |
| Connection URL | `redis://:pass@host:6379`                                          |
| License        | BSD-3-Clause                                                       |

## Troubleshooting

<Accordions>
<Accordion title="oke boot: redis driver needs REDIS_URL">

Same loud failure as Redis — the driver id is still `redis`. Export `REDIS_URL` or let
`oke dev` write it.

</Accordion>
<Accordion title="Healthcheck uses redis-cli and fails">

You still have the Redis recipe matched (image ref contains `redis`). Valkey images must
match `/valkey/i` so the healthcheck binary is `valkey-cli`. Pin `valkey/valkey:…`
explicitly.

</Accordion>
</Accordions>

## Learn more

- [Store · KV](/docs/elements/store#kv) — full Redis-protocol image license table
- [Redis](/docs/recipes/redis) · [Dragonfly](/docs/recipes/dragonfly) — the other two peers
- [DigitalOcean Managed Caching](/docs/providers/digitalocean-caching) — managed Valkey

## Next

<Cards>
  <Card
    title="Dragonfly"
    description="Multi-threaded Redis-wire peer."
    href="/docs/recipes/dragonfly"
  />
  <Card title="Redis" description="The mature default." href="/docs/recipes/redis" />
  <Card title="Upstash" description="Managed alternative." href="/docs/providers/upstash" />
</Cards>


# YugabyteDB (/docs/recipes/yugabytedb)

YugabyteDB's **YSQL** API speaks Postgres wire. Pin `yugabytedb/yugabyte` as
`store.sql` and `oke docker` derives a single-node `yugabyted` service —
credentials, healthcheck, and `DATABASE_URL`. Driver id stays `postgres`.

<Callout title="The one rule">
  Use the YSQL API only — never treat YCQL as `store.sql`. Vendor choice lives in
  `images["store.sql"]`; host maps `:5432` → container `:5433`.
</Callout>

## Quick start

<Steps>

<Step>
### Pin the image

```typescript title="oke.config.ts"
images: {
  "store.sql": "yugabytedb/yugabyte:2025.1.0.0-b100", // pin a real tag
},
```

</Step>

<Step>
### Bring the stack up

```bash
oke dev
```

`oke docker` injects `YSQL_USER` / `YSQL_PASSWORD` / `YSQL_DB` from `OKE_STORE_SQL_*`
(password presence turns authentication on), runs
`bin/yugabyted start --background=false`, and publishes YSQL on host `:5432`.

</Step>

<Step>
### Connect

```bash
echo "$DATABASE_URL"
# postgres://oke:…@127.0.0.1:5432/oke
```

First healthy state is slower than plain Postgres — the healthcheck allows a long
start period.

</Step>

</Steps>

## Required env

| Variable                 | Who sets it            | Meaning                          |
| ------------------------ | ---------------------- | -------------------------------- |
| `OKE_STORE_SQL_USER`     | `oke docker` → compose | → `YSQL_USER`                    |
| `OKE_STORE_SQL_PASSWORD` | `oke docker` → compose | → `YSQL_PASSWORD` (enables auth) |
| `OKE_STORE_SQL_DB`       | `oke docker` → compose | → `YSQL_DB`                      |
| `DATABASE_URL`           | stack env for the app  | Host `:5432` → container `:5433` |

## Data and backup

| Path                     | What lives there                                     |
| ------------------------ | ---------------------------------------------------- |
| `/home/yugabyte/yb_data` | Named volume `store-sql-data` — `yugabyted` base dir |

**Backup means:** volume backup of `store-sql-data`, or Yugabyte's backup tooling.
Losing that volume loses the universe data.

## Production note

Single-node `yugabyted` is for local and small self-hosted use. For managed Aeon
clusters, use the [YugabyteDB provider](/docs/providers/yugabytedb). Pin a concrete
tag; the image is large and cold-start is slower than Postgres.

## What the recipe configures

| Field          | Value                                                 |
| -------------- | ----------------------------------------------------- |
| Container port | `5433` (host publishes `5432`)                        |
| Command        | `bin/yugabyted start --base_dir=… --background=false` |
| Healthcheck    | `ysqlsh … SELECT 1` (90s `start_period`)              |
| Connection URL | `postgres://user:pass@host:5432/db`                   |
| Preload        | Vendor — recipe does **not** replace `yugabyted`      |

## Query performance

YSQL already preloads `pg_stat_statements`. Create the extension, then open Store →
**Performance**. Do not add a Postgres `command` overlay.

## Troubleshooting

<Accordions>
<Accordion title="store-sql unhealthy for a long time">

Yugabyte cold-start is slow. Wait through the healthcheck `start_period`, then read
`docker compose … logs store-sql`.

macOS AirPlay on `:7000` can conflict with Yugabyte's master UI — stop AirPlay
Receiver or remap that port in `compose.override.yml` if you need the UI.

</Accordion>
<Accordion title="password authentication failed">

`YSQL_PASSWORD` must match `DATABASE_URL`. Regenerate `.env.local` with
`oke dev`. Auth is off only when `YSQL_PASSWORD` is unset — this recipe
always sets it.

</Accordion>
</Accordions>

## Learn more

- [YugabyteDB (provider)](/docs/providers/yugabytedb) — managed Aeon Connect flow
- [CockroachDB](/docs/recipes/cockroachdb) — self-hosted distributed peer
- [Postgres](/docs/recipes/postgres) — default SQL recipe
- [Store · SQL](/docs/elements/store#sql) — schema workflows

## Next

<Cards>
  <Card
    title="YugabyteDB Aeon"
    description="Managed cloud alternative."
    href="/docs/providers/yugabytedb"
  />
  <Card
    title="CockroachDB"
    description="Self-hosted Cockroach recipe."
    href="/docs/recipes/cockroachdb"
  />
  <Card title="Postgres" description="The default store.sql image." href="/docs/recipes/postgres" />
</Cards>


# CLI (/docs/reference/cli)

The CLI is how you scaffold, run, and operate an app. `create-oke` writes the project;
`oke` is the day-to-day binary (dev loop, schema, vault, docker, doctor).

For developers who already know the model — look up a command, its flags, and what it
writes.

<Callout title="The one rule">
  Prefer the CLI for environment wiring (`oke dev`, `oke db`, `oke docker`). Put behavior in Flows
  and `oke.config.ts` — never invent a ninth element or hand-edit the Manifest.
</Callout>

## Smallest Example

<Steps>

<Step>
### Scaffold an app

```bash
bunx create-oke@latest notes --yes
cd notes
```

</Step>

<Step>
### Run the dev loop

```bash
bun run dev
```

`bun run dev` is the portable form (`bunx oke dev` is the same). Bare `oke` needs `node_modules/.bin` on PATH — PowerShell does not add it.

Compose comes up (pull / create / start progress streams into the boot status
lines), the backend listens on **6530**, Console on **6533**, app MCP on **6535**,
docs MCP on **6536**. Open `http://localhost:6533`. Quit with **Ctrl+C**.

On a TTY, after schema push, `oke dev` also:

1. Prompts one-by-one for any Vault boot gaps (writes `.env.local`) — e.g.
   `OPENROUTER_API_KEY` when create-oke / `oke ai setup` / Keel declared the
   OpenRouter contract without a value.
2. Asks to run `oke db seed` when a seed module exists and `.oke/state.json` has
   not recorded that seed identity yet. Run `oke db seed` anytime later.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Daily", "Schema", "Ship", "Scaffold"]}>

<Tab value="Daily">

```bash
oke dev                 # watch · Console · client types · Docker Compose
oke test                # bun test with PGLite posture
oke doctor              # secrets · ports · schema drift
oke doctor --diff       # CI gate: undeclared contract breaks
```

</Tab>

<Tab value="Schema">

```bash
oke db push             # sync domain schema (dev; auto under oke dev)
oke db generate         # versioned SQL under drizzle/
oke db migrate          # apply migrations (prod)
oke schema generate     # core + plugin stub → .oke/schema/oke.ts
```

**Consequence:** `oke schema generate` is not `oke db generate`. The first writes the
framework stub; the second emits your app's Drizzle migrations.

</Tab>

<Tab value="Ship">

```bash
oke docker --prod       # derive production Compose
oke build --target bun  # tree-shaken bundle
oke start               # production entry (Docker CMD)
```

</Tab>

<Tab value="Scaffold">

```bash
bunx create-oke@latest my-app --yes
bunx create-oke@latest my-app -t advanced --locales ar --proxy caddy
bunx create-oke@latest          # interactive (TTY only)
```

</Tab>

</Tabs>

## Port Map

| Port     | Surface              | Who starts it           |
| -------- | -------------------- | ----------------------- |
| **6530** | Backend API          | `oke dev` / `oke start` |
| **6533** | Developer Console    | `oke dev`               |
| **6535** | App MCP              | `oke dev`               |
| **6536** | Docs MCP (read-only) | `oke dev`               |

Override app / console / mcp listen ports in `oke.config.ts` `ports` — see
[Configuration](/docs/reference/configuration#ports).

## `oke` Commands

| Command      | Purpose                                                      | Key flags / subs                                                                                                                                          |
| ------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dev`        | Watch · hot reload · Console · client types (Docker Compose) | `--docker\|-d [roles]`, `--no-db-push`, `--entry\|-e`                                                                                                     |
| `test`       | Run bun test with PGLite test posture                        |                                                                                                                                                           |
| `start`      | Production entry (Docker CMD)                                | `--entry\|-e`, `--port\|-p`                                                                                                                               |
| `doctor`     | Secrets · ports · stub/domain schema drift                   | `--manifest`, `--diff`, `--before`, `--after`, `--base`, `--json`                                                                                         |
| `stack`      | Preview images / tags / ports (writes nothing)               | `--config`, `--json`                                                                                                                                      |
| `schema`     | Core + plugin tables → `.oke/schema/oke.ts`                  | `generate` (`--check`, `--manifest`, `--out`)                                                                                                             |
| `db`         | Domain schema                                                | `push` · `generate` · `migrate` · `seed` · `studio` · `search-backfill`                                                                                   |
| `client`     | Ambient types for a separate frontend repo                   | `add <url>` (`--out`)                                                                                                                                     |
| `vault`      | Secrets                                                      | `set` · `list` · `import` · `key` · `init` · `status` · `seal` · `unseal` · `rotate` · `rotate-master` · `audit` · `purge-expired` · `backup` · `restore` |
| `docker`     | Derive compose · clean leftover stacks                       | `--prod\|-p`, `--out`, `--config`, `--manifest`; sub `clean`                                                                                              |
| `images`     | List / pin digests                                           | `list` · `pin`                                                                                                                                            |
| `build`      | Tree-shaken bundle                                           | `--target\|-t bun\|node\|edge`, `--entry`, `--outdir`                                                                                                     |
| `eval`       | Prompt eval sets (CI gate)                                   | `--manifest`                                                                                                                                              |
| `ai`         | Configure AI driver + models                                 | `setup` (`--provider`, `--chat`, `--vision`, `--embed`, `--yes`)                                                                                          |
| `branch`     | Fork journaled state                                         | `<name>`, `--at\|-a`                                                                                                                                      |
| `replay`     | Re-invoke a past Flow from Runs                              | `--request-id\|-r`, `--entry`, `--dry-run`, `--live`                                                                                                      |
| `privacy`    | Crypto-shred subject data                                    | `erase` (`--subject\|-s`)                                                                                                                                 |
| `upgrade`    | Breaking-change codemods + diff                              | `--apply\|-a`                                                                                                                                             |
| `console`    | Console helpers                                              | `claim-code` (`--json`)                                                                                                                                   |
| `gates`      | Gate catalogue from Manifest                                 | `list` (`--manifest`, `--json`)                                                                                                                           |
| `completion` | Shell completion script                                      | `bash` · `zsh` · `fish`                                                                                                                                   |
| `mode`       | **Deprecated** — `oke dev` always uses Docker Compose        | `--help`                                                                                                                                                  |

Bare `oke` (TTY) opens the interactive board; `oke --help` prints the catalogue.

### `oke db` detail

Shared flags on push / generate / migrate / studio: `--config\|-c`, `--env name`
(`dev` · `test` · `prod`). Seed adds `--force`, `--entry`. Search-backfill adds
`--batch n` and a `<table>` positional.

```bash
oke db push --env dev
oke db migrate --env prod
oke db seed --force
oke db search-backfill notes --batch 500
```

## `create-oke`

| Flag                             | Meaning                                                   |
| -------------------------------- | --------------------------------------------------------- |
| `-t, --template <id>`            | `standard` (default) or `advanced`                        |
| `--sql <id>`                     | Store SQL dialect — only `postgres` (test stays `pglite`) |
| `-y, --yes`                      | No prompts; defaults + bun install (no `oke dev`)         |
| `--install` / `--no-install`     | Run or skip `bun install` after scaffold                  |
| `--agents-md` / `--no-agents-md` | Write `AGENTS.md` (default on)                            |
| `--ai` / `--no-ai` / `--ai skip` | Configure or skip AI setup                                |
| `--locales <tags>`               | Extra languages beyond English (e.g. `ar` or `ar,fr`)     |
| `--pgdog` / `--no-pgdog`         | Pin PgDog in front of Postgres                            |
| `--proxy <id>` / `--no-proxy`    | `none` · `caddy` · `traefik` · `nginx`                    |
| `-h, --help`                     | Show help                                                 |

## Troubleshooting

<Accordions>

<Accordion title="oke is not recognized (Windows PowerShell)">
  PowerShell has no local `oke` on PATH. Use `bun run dev` or `bunx oke dev`. New Cursor/VS Code
  terminals inherit `node_modules/.bin` from `.vscode/settings.json`. Global: `bun install -g
  okengine`.
</Accordion>

<Accordion title="Cannot find package zod from a global okengine (Windows)">
  `oke` resolved to `%USERPROFILE%\.bun\install\global\node_modules\okengine`. From a framework
  checkout run `bun install` then `bun run dev:keel` — not a global `oke`. Keel links with a Windows
  junction; a plain symlink needs Developer Mode and fails with `EPERM`.
</Accordion>

<Accordion title="Ctrl+C emptied the new create-oke folder (Windows)">
  Interrupting install or `bun run dev` used to delete a folder create-oke had just created. On
  Windows that wipe often failed mid-tree (`EBUSY`) and left an empty or half-deleted project.

After scaffold, Ctrl+C only stops the process. `cd` into the folder and run `bun run dev` again.
Missing `docker` is a separate install — Compose needs it on PATH.

</Accordion>

<Accordion title="Console shows Shell assets not built">
  `:6533` is up but the SPA files were not found. Published `okengine` ships them. Upgrade past the
  Windows path bug (`file://` `/C:/…` lookup), then restart `bun run dev`. From a framework
  checkout, run `bun run build` or keep `oke dev` so Vite HMR attaches.
</Accordion>

<Accordion title="password authentication failed for user oke">
  Postgres was initialized with an older password than `.env.local` — usual after deleting and
  recreating the folder. `oke dev` resets that project's volumes when it sees this.

Already failed? From the app folder: `oke docker clean` then `bun run dev`. Run commands
inside the project (`o1`), not the parent `oke` directory.

</Accordion>

<Accordion title="OKE1020 on oke dev (Compose)">
  The app child had no Manifest to stamp effects (`main.health`). `oke dev` now hands the parent
  extract to the child. A failed extract appends `Manifest extract failed — …` (`oxc-parser`).
</Accordion>

<Accordion title="oke start: no entry found">
  Set `package.json` `okengine.entry` or `main`, pass `--entry`, or keep a conventional
  `src/app.ts`. Production imports that module; the app must call `createBunRuntime().serve` itself.
</Accordion>

<Accordion title="Confusion: oke schema generate vs oke db generate">
  `oke schema generate` emits framework / plugin stub tables under `.oke/schema/`. `oke db generate`
  emits versioned domain SQL via Drizzle. Use `db` for your app tables; use `schema` when the stub
  drifted after a plugin change.
</Accordion>

<Accordion title="oke mode still in muscle memory">
  `oke mode` is deprecated. `oke dev` always starts Docker Compose. There is no soft-compat for old
  `local` / `docker` env map keys — rename to `dev` / `test` / `prod` in
  [Configuration](/docs/reference/configuration).
</Accordion>

<Accordion title="OKE1110 after deploy">
  Domain tables are missing in prod (no auto-DDL). Run `oke db migrate` against that environment —
  see [Errors](/docs/reference/errors).
</Accordion>

<Accordion title="Claim code missing for first Console operator">
  The setup claim code prints on the `oke dev` TTY board and is mirrored to gitignored
  `.oke/claim-code`. Run `oke console claim-code` while setup is open.
</Accordion>

</Accordions>

## Learn more

- [Configuration](/docs/reference/configuration) — `oke.config.ts` drivers and ports
- [Environment Variables](/docs/reference/environment-variables) — what Compose writes
- [Security](/docs/reference/security) — Host / Origin / `allowedHosts`
- [The Architecture](/docs/understand/the-architecture) — one contract; `oke doctor --diff` reviews it
- [Vault](/docs/elements/vault) — `oke vault` rotate / unseal

## Next

<Cards>
  <Card
    title="Configuration"
    description="Every option in oke.config.ts."
    href="/docs/reference/configuration"
  />
  <Card
    title="Security"
    description="Host, Origin, planes, and MCP posture."
    href="/docs/reference/security"
  />
  <Card title="Client" description="Typed createClient for your flows." href="/docs/client" />
</Cards>


# Configuration (/docs/reference/configuration)

Complete reference for `oke.config.ts`. Options below match `defineConfig`. Driver maps use three keys: **dev** · **test** · **prod**.

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  // options below
});
```

<Callout title="The one rule">
  Pin drivers and images in `oke.config.ts` for `dev` · `test` · `prod`. We test on what we deploy:
  Docker-first local stack, PGLite for tests, same protocol ids in production.
</Callout>

## Environments

| Key    | When                       | Typical backends                                      |
| ------ | -------------------------- | ----------------------------------------------------- |
| `dev`  | `oke dev` (Docker Compose) | Same protocols as prod (Postgres, Redis, S3, SMTP, …) |
| `test` | `oke test` / `bun test`    | PGLite for SQL; memory / frozen / console elsewhere   |
| `prod` | Production deploy          | Shared durable backends                               |

Missing `dev` pins fill from `prod` (`fillDevFromProd`). Only `dev` / `test` / `prod` keys are
valid — rename `local` → `dev` and `docker` → `prod` if you still have the old maps.

## Quick start

<Steps>

<Step>
### Pin drivers for three envs

Pin **only the env keys that differ** from the built-in defaults — omitted keys
keep `DRIVER_DEFAULTS`. create-oke templates ship that sparse shape:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // Default vault.dev is "env"; pin the built-in store for Docker-first apps.
    vault: { dev: "vault" },
  },
});
```

</Step>

<Step>
### Or use a string shorthand

A bare string (or `{ driver, … }` object) expands to all three envs:

```typescript
drivers: {
  signal: "redis", // → { dev: "redis", test: "redis", prod: "redis" }
},
```

Prefer an explicit `{ dev, test, prod }` map (or a partial override) when test
should differ from the default for that driver.

</Step>

<Step>
### Run and test

```bash title="Terminal"
oke dev    # Docker Compose + host Bun · drivers.dev
oke test   # bun test · NODE_ENV=test · OKE_PGLITE_URL=memory://
```

</Step>

</Steps>

## Default driver maps

Built-in defaults (`DRIVER_DEFAULTS`) when a key is omitted:

| Map             | `dev`      | `test`    | `prod`     |
| --------------- | ---------- | --------- | ---------- |
| `store.sql`     | `postgres` | `pglite`  | `postgres` |
| `store.kv`      | `redis`    | `memory`  | `redis`    |
| `store.files`   | `s3`       | `memory`  | `s3`       |
| `signal`        | `redis`    | `memory`  | `redis`    |
| `clock`         | `postgres` | `frozen`  | `postgres` |
| `journal`       | `postgres` | `memory`  | `postgres` |
| `vault`         | `env`      | `memory`  | `vault`    |
| `channel.email` | `smtp`     | `console` | `smtp`     |
| `channel.sms`   | — (opt-in) | —         | —          |
| `runs`          | `files`    | `memory`  | `files`    |

`store.index`, `channel.whatsapp` / `push`, and `ai` have no single three-env default table — set them explicitly when you need them. create-oke templates pin `vault.dev: "vault"` (built-in) because the default `dev` driver is `env`.

## Driver keys and ids

| Key                | Shape          | Driver ids                                                                       |
| ------------------ | -------------- | -------------------------------------------------------------------------------- |
| `store.sql`        | env driver map | `postgres` · `pglite` · `memory`                                                 |
| `store.kv`         | env driver map | `memory` · `redis`                                                               |
| `store.files`      | env driver map | `memory` · `fs` · `s3`                                                           |
| `store.index`      | env driver map | `memory` · `pgvector` · `meilisearch`                                            |
| `signal`           | env driver map | `memory` · `redis` (boot); `postgres` · `nats` fail loud until clients bind      |
| `clock`            | env driver map | `memory` · `postgres` · `file` · `frozen`                                        |
| `journal`          | env driver map | `memory` · `file` · `postgres`                                                   |
| `vault`            | env driver map | `env` · `vault` · `memory` · `managed`                                           |
| `channel.email`    | env driver map | `console` · `smtp` · `resend` · `sndr` · `taqnyat-mail`                          |
| `channel.sms`      | env driver map | `console` · `taqnyat` · `msegat` · `unifonic`                                    |
| `channel.whatsapp` | env driver map | `console` · `wa-cloud`                                                           |
| `channel.push`     | env driver map | `console` · `webpush` · `fcm`                                                    |
| `ai`               | env driver map | `mock` · `anthropic` · `openai-compatible` · `bedrock` · `vertex`                |
| `runs`             | env driver map | `memory` · `files` (Parquet + DuckDB at `.oke/runs`) · `postgres` · `clickhouse` |
| `prod`             | `string[]`     | flat protocol list for the Manifest — nested maps are preferred                  |

### Safety rules

`defineConfig` rejects unsafe pins:

- **`sqlite` is removed** — use `postgres` (dev/prod) or `pglite` (test).
- **`drivers.store.sql.test` must be `pglite`** when set — real Postgres semantics in tests.

### Rich driver objects

```typescript
sql: {
  prod: { driver: "postgres", pool: { max: 20 }, replicas: ["postgres://ro-1/db"] },
},
```

| Field      | Type              | Meaning                                     |
| ---------- | ----------------- | ------------------------------------------- |
| `url`      | connection string | Overrides the env-var resolution            |
| `pool`     | `{ max?, min? }`  | SQL pool sizing                             |
| `replicas` | `string[]`        | Read-only routing targets (read flows only) |

## images

Nested pins by element role — vendor choice lives here, never in driver ids:

```typescript
images: {
  store: {
    sql: "postgres:18-alpine",
    kv: "redis:8-alpine",
    files: "rustfs/rustfs:1.0.0-rc.5",
  },
  channel: { email: "axllent/mailpit:v1.31.1" },
  pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
  // proxy: "caddy:2-alpine",
},
```

Omitted image keys mean no container for that role. When both `store.sql` and `pgdog` are pinned, `DATABASE_URL` points at PgDog — see [Store](/docs/elements/store#multiple-environments).

For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays `redis`.
`{ durable: true }` KV lives in SQL (`oke_kv` on `DATABASE_URL`), not a second Redis image.

Compose does not manage AI inference — [OpenRouter](/docs/recipes/openrouter) or BYO
`OKE_AI_URL` ([Models](/docs/elements/ai/models)). For `proxy`, see [Caddy](/docs/recipes/caddy),
[Traefik](/docs/recipes/traefik), or [nginx](/docs/recipes/nginx).

## i18n

| Option    | Type       | Default               | Meaning                                     |
| --------- | ---------- | --------------------- | ------------------------------------------- |
| `locales` | `string[]` | `["en"]` when omitted | Supported locales                           |
| `default` | string     | `"en"` when omitted   | Fallback locale (channel templates, `fx.t`) |
| `dir`     | record     | —                     | Per-locale direction: `"ltr"` \| `"rtl"`    |

```typescript
i18n: { locales: ["en"], default: "en" },
// or with Arabic: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } }
```

## tenancy

`oke.config.ts` `tenancy` is **isolation posture** (how rows are separated). Identity —
who the tenant is — is `gate.auth.tenant` on [Gate](/docs/elements/gate#tenants-identity-dimension).

| Option      | Type                              | Meaning                                           |
| ----------- | --------------------------------- | ------------------------------------------------- |
| `isolation` | `"row" \| "schema" \| "database"` | How tenants are separated in the store            |
| `resolve`   | string \| function                | Observational resolver (isolation-only manifests) |

## privacy

Presence of this block turns CORE privacy tooling on in the Console — not a `.plug()` call.

```typescript
privacy: {},
```

## runs

Runs retention and redaction — **not** the same key as `drivers.runs`.

| Option   | Type                     | Meaning                                                                    |
| -------- | ------------------------ | -------------------------------------------------------------------------- |
| `keep`   | `string` \| `"forever"`  | Delete Parquet partitions older than this (`7d` in `dev`, `30d` in `prod`) |
| `redact` | `Record<string, string>` | Field → retention duration; presence turns privacy on                      |

## db

Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated to `oke schema generate`.

| Option      | Default                      | Meaning                                                                   |
| ----------- | ---------------------------- | ------------------------------------------------------------------------- |
| `autoPush`  | `true`                       | Auto-run `db push` on schema change under `oke dev`; forced off in `prod` |
| `config`    | `"drizzle.config.ts"`        | Path to the drizzle-kit config                                            |
| `declare`   | `"src/db/schema.decl.ts"`    | Abstract schema module (`store.schema.table` exports)                     |
| `generated` | `"src/db/schema.drizzle.ts"` | Where `oke db` emits dialect Drizzle                                      |
| `entry`     | `src/app.ts`                 | App entry for collecting plugin table contributions                       |

## topology

| Value        | Meaning                                          |
| ------------ | ------------------------------------------------ |
| `"monolith"` | One process serves everything (default posture)  |
| `"services"` | Split deployment units derived from the Manifest |

## ports

| Option    | Default | Surface |
| --------- | ------- | ------- |
| `app`     | `6530`  | Backend |
| `console` | `6533`  | Console |
| `mcp`     | `6535`  | MCP     |

## console

| Option         | Type                                 | Default | Meaning                   |
| -------------- | ------------------------------------ | ------- | ------------------------- |
| `prod.enabled` | boolean                              | —       | Serve the Console in prod |
| `prod.auth`    | `"required" \| "optional" \| "none"` | —       | Access requirement        |

## Troubleshooting

<Accordions>

<Accordion title='oke.config: … uses removed driver "sqlite"'>
  The `sqlite` driver is gone. Pin `postgres` for `dev`/`prod` and `pglite` for `test`. Edit the
  file by hand — rename the env keys to `dev` / `test` / `prod` first.
</Accordion>

<Accordion title='drivers.store.sql.test must be "pglite"'>
  Tests use PGLite so SQL semantics match Postgres. Set `test: "pglite"` (or omit the key and keep
  the default). Do not use `memory` or `postgres` for `store.sql.test`.
</Accordion>

<Accordion title='CLI error: "local" / "sqlite" is no longer valid'>
  There is no soft-compat. Rename env keys to `dev` / `test` / `prod` and replace `sqlite` with
  `postgres` / `pglite`. `oke mode` was removed — `oke dev` always starts Docker Compose.
</Accordion>

<Accordion title="Runs DuckDB / Parquet queries requires @duckdb/node-api">
  The `files` driver needs `@duckdb/node-api`. create-oke templates add it. Install with `bun add
  @duckdb/node-api` if an older app is missing the peer.
</Accordion>

</Accordions>

## Learn more

- [CLI](/docs/reference/cli) — `oke dev` · `oke test` · `oke db`
- [Environment Variables](/docs/reference/environment-variables) — URL and secret resolution
- [Store](/docs/elements/store) — what the store drivers back
- [i18n](/docs/reference/i18n) — locale catalogs beyond the config block

## Next

<Cards>
  <Card
    title="Environment Variables"
    description="URLs and secrets Compose writes."
    href="/docs/reference/environment-variables"
  />
  <Card title="CLI" description="Commands that honor this config." href="/docs/reference/cli" />
  <Card
    title="Security"
    description="Host, Origin, planes, and MCP ports."
    href="/docs/reference/security"
  />
</Cards>


# Environment Variables (/docs/reference/environment-variables)

OKE reads environment variables at boot for connection detail and secrets — never for
behavior you could declare in `oke.config.ts`. Under `oke dev`, Compose writes these
into `.env.local`; this page is the full map.

<Callout title="The one rule">
  Put drivers, images, and locale lists in `oke.config.ts`. Use env vars for URLs, credentials, and
  runtime posture the host injects — not for inventing new behavior.
</Callout>

## Smallest Example

<Steps>

<Step>
### Let Compose write the map

```bash
oke dev
```

Compose fills `.env.local` with `DATABASE_URL`, Redis, S3, and vault keys for the
pinned images.

</Step>

<Step>
### Override one URL when needed

```bash
export DATABASE_URL=postgres://user:pass@db:5432/oke
```

Conventional names win over `OKE_*` aliases, then built-in defaults.

</Step>

</Steps>

## Precedence

<Callout title="Resolution order">
  For anything with both an `OKE_*` variable and a conventional one (like `DATABASE_URL`), the
  conventional variable wins first, then the `OKE_*` form, then the built-in default.
</Callout>

## SQL store

| Variable            | Used for                                                                                                                                                                                                                                                          | Default when unset                        |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `DATABASE_URL`      | Postgres connection (`postgres` store / clock / journal / Console operator plane, drizzle-kit). App tables stay in `public`; Console operators/sessions use schema `oke_console` on the same database. When PgDog is pinned, this points at the pooler (`:6432`). | `postgres://localhost:5432/oke`           |
| `OKE_STORE_SQL_URL` | Direct SQL URL (Postgres host, even when PgDog fronts the app). Also used by Console when `DATABASE_URL` is unset.                                                                                                                                                | —                                         |
| `OKE_PGDOG_URL`     | PgDog pooler URL (same value as `DATABASE_URL` when both are present)                                                                                                                                                                                             | —                                         |
| `OKE_PGLITE_URL`    | PGlite data dir or `memory://` (`pglite` driver; required for `store.sql.test`)                                                                                                                                                                                   | `memory://` in `test`; else `.oke/pgdata` |
| `OKE_SQL_DRIVER`    | Force the sql driver id at boot                                                                                                                                                                                                                                   | config map                                |

`oke test` sets `OKE_PGLITE_URL=memory://` when unset. The `sqlite` driver and `OKE_SQLITE_URL` are removed.

## Index store

| Variable              | Used for                                            | Default when unset |
| --------------------- | --------------------------------------------------- | ------------------ |
| `OKE_INDEX_DRIVER`    | Force the index driver id                           | config map         |
| `OKE_STORE_INDEX_URL` | Meilisearch base URL (`meilisearch` driver)         | —                  |
| `OKE_STORE_INDEX_KEY` | Meilisearch API / master key (`meilisearch` driver) | `MEILI_MASTER_KEY` |

## KV store

| Variable           | Used for                       | Default when unset |
| ------------------ | ------------------------------ | ------------------ |
| `REDIS_URL`        | Cache Redis connection         | driver default     |
| `OKE_STORE_KV_URL` | Explicit cache KV URL override | —                  |
| `OKE_KV_DRIVER`    | Force the kv driver id         | config map         |

## Files store

| Variable               | Used for                         | Default when unset |
| ---------------------- | -------------------------------- | ------------------ |
| `S3_BUCKET`            | Bucket name (`s3` driver)        | —                  |
| `S3_ENDPOINT`          | S3-compatible endpoint (RustFS)  | —                  |
| `S3_ACCESS_KEY_ID`     | Access key                       | —                  |
| `S3_SECRET_ACCESS_KEY` | Secret key                       | —                  |
| `S3_REGION`            | Region                           | —                  |
| `S3_SESSION_TOKEN`     | Session token (temporary creds)  | —                  |
| `OKE_STORE_FILES_DB`   | Explicit files location override | —                  |
| `OKE_FILES_DRIVER`     | Force the files driver id        | config map         |

## Vault

Built-in store uses `OKE_VAULT_MASTER_KEY` (or `--key` / stdin on CLI). Managed providers use the
vars below — see [Vault](/docs/elements/vault) for which each id needs.

| Variable               | Used for                                                                                                     | Default when unset    |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------- |
| `OKE_VAULT_PROVIDER`   | Managed backend (`aws-secrets-manager` · `azure-key-vault` · `gcp-secret-manager` · `doppler` · `1password`) | — (platform-injected) |
| `OKE_VAULT_REGION`     | Cloud region (AWS Secrets Manager; GCP user-managed replication)                                             | —                     |
| `OKE_VAULT_MOUNT`      | Scope: AWS/Azure prefix, GCP `project[/prefix]`, Doppler `project/config`, 1Password vault name              | —                     |
| `OKE_VAULT_MASTER_KEY` | Built-in vault master key (base64)                                                                           | —                     |
| `OKE_VAULT_URL`        | Azure Key Vault URI, 1Password Connect host, optional Doppler origin                                         | —                     |
| `OKE_VAULT_TOKEN`      | Doppler service/personal token, 1Password Connect token                                                      | —                     |

## Channel (email) — boot binder

Read when `drivers.channel.email` resolves to that driver id.

| Variable                | Used for                                         |
| ----------------------- | ------------------------------------------------ |
| `SMTP_URL`              | `smtp` — full SMTP URL (`smtp://…`)              |
| `SMTP_USER`             | Overrides the user embedded in `SMTP_URL`        |
| `SMTP_PASSWORD`         | Overrides the password in `SMTP_URL`             |
| `OKE_CHANNEL_EMAIL_URL` | Alternative to `SMTP_URL`                        |
| `RESEND_API_KEY`        | `resend` API key                                 |
| `SNDR_API_KEY`          | `sndr` API key                                   |
| `SNDR_BASE_URL`         | Optional SNDR API origin (default `api.sndr.sh`) |
| `TAQNYAT_MAIL_TOKEN`    | `taqnyat-mail` bearer token (Email-enabled)      |
| `TAQNYAT_CAMPAIGN`      | `taqnyat-mail` campaign name                     |

## Channel (SMS) — boot binder

Read when `drivers.channel.sms` resolves to that driver id (`console` opens nothing).

| Variable               | Used for                         |
| ---------------------- | -------------------------------- |
| `TAQNYAT_BEARER_TOKEN` | `taqnyat` bearer token           |
| `TAQNYAT_TOKEN`        | Alias for `TAQNYAT_BEARER_TOKEN` |
| `TAQNYAT_SENDER`       | Taqnyat pre-approved sender id   |
| `MSEGAT_USERNAME`      | `msegat` account username        |
| `MSEGAT_API_KEY`       | `msegat` API key                 |
| `MSEGAT_SENDER`        | Msegat pre-approved sender id    |
| `UNIFONIC_APPSID`      | `unifonic` AppSid                |
| `UNIFONIC_APP_SID`     | Alias for `UNIFONIC_APPSID`      |
| `UNIFONIC_SENDER`      | Unifonic SenderID (optional)     |

WhatsApp (`wa-cloud`) and push (`webpush` / `fcm`) are not opened from env at
boot — pass them on `BootOptions.channel.drivers` with their open options.

## Live test gates (opt-in, contributors)

Provider-quota-burning live suites in the okengine repo are double-gated: the
medium flag **plus** that provider's real credentials — credentials alone never
send. App projects can ignore these.

| Variable         | Used for                                                  |
| ---------------- | --------------------------------------------------------- |
| `OKE_SMS_LIVE`   | `=1` allows live SMS provider tests (e.g. Taqnyat OTP)    |
| `OKE_EMAIL_LIVE` | `=1` allows live email provider tests (e.g. Taqnyat Mail) |

## AI providers

| Variable              | Used for                                                                                | Default when unset |
| --------------------- | --------------------------------------------------------------------------------------- | ------------------ |
| `ANTHROPIC_API_KEY`   | `anthropic` driver credential                                                           | —                  |
| `ANTHROPIC_MODEL`     | Model override for the anthropic driver                                                 | —                  |
| `OPENAI_API_KEY`      | `openai-compatible` driver credential                                                   | —                  |
| `OPENAI_BASE_URL`     | `openai-compatible` base URL                                                            | OpenAI cloud       |
| `OKE_AI_DRIVER`       | Force the AI driver id (honoured under Compose / `oke dev`)                             | config map         |
| `OKE_AI_URL`          | openai-compatible base URL (must end in `/v1`) — BYO; Compose does not manage inference | —                  |
| `OKE_AI_MODEL`        | Default model id for openai-compatible / setup bindings                                 | —                  |
| `OKE_AI_VISION_MODEL` | Vision model id written by `oke ai setup` (logical `ai.model("vision")`)                | —                  |
| `OKE_AI_EMBED_MODEL`  | Embedding model id written by `oke ai setup`                                            | —                  |

## Framework behavior

| Variable                 | Used for                                                                                                              |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `OKE_DOCKER`             | `"1"` marks Compose / `oke dev` posture (set by the CLI)                                                              |
| `OKE_DB_AUTO_PUSH`       | Overrides `db.autoPush` at boot                                                                                       |
| `OKE_DRIZZLE_DIALECT`    | `"postgresql"` for drizzle-kit overlays (templates hardcode it)                                                       |
| `OKE_DEV_REQUEST_LOG`    | `"1"` logs requests during `oke dev` (set by the CLI) — surface, flow, status, timestamp, run id                      |
| `OKE_CONSOLE_SECRET`     | Console operator-session signing secret (HMAC) — set in production; else `.oke/console.secret`. Not a Vault contract. |
| `OKE_CONSOLE_AUTH_STORE` | `"1"` lists the operator-plane `oke_console` schema in Store browse (read-only). Hidden by default.                   |
| `OKE_RUNS_INGEST_URL`    | Host → Console WideEvent ingest URL (`oke dev` sets this on the app child). Enables a memory runs store + push.       |
| `OKE_RUNS_INGEST_SECRET` | Shared secret for `POST /console/runs/ingest` (`x-oke-runs-ingest` header). Minted by `oke dev`; never return events. |
| `PORT`                   | App port in production containers (default `6530`)                                                                    |
| `NODE_ENV`               | `"production"` switches the Console to its production posture                                                         |

Console operator rows are **not** stored in `.oke/console.sqlite`. With `DATABASE_URL` (or
`OKE_STORE_SQL_URL`) they live in Postgres schema `oke_console`. Without a Postgres URL,
Console uses PGlite under `.oke/console-pg` for local reopen durability.

## Troubleshooting

<Accordions>

<Accordion title="Compose did not write .env.local">
  Run `oke dev` (not a bare `bun` entry) so Compose posture sets `OKE_DOCKER=1` and writes
  connection URLs. Confirm `images` pins exist in [Configuration](/docs/reference/configuration).
</Accordion>

<Accordion title="Vault sealed / missing master key">
  Built-in vault needs `OKE_VAULT_MASTER_KEY` (or `oke vault unseal`). See
  [Vault](/docs/elements/vault).
</Accordion>

<Accordion title="Console operator store missing">
  Operator rows need `DATABASE_URL` or `OKE_STORE_SQL_URL` (schema `oke_console`). Without Postgres,
  Console uses PGlite under `.oke/console-pg`.
</Accordion>

</Accordions>

## Learn more

- [Configuration](/docs/reference/configuration) — the declarative side of the same knobs
- [Vault](/docs/elements/vault) — how `OKE_VAULT_*` gets minted on first boot
- [CLI](/docs/reference/cli) — which commands write these for you
- [Security](/docs/reference/security) — `OKE_CONSOLE_SECRET`

## Next

<Cards>
  <Card
    title="Configuration"
    description="Drivers and images in oke.config.ts."
    href="/docs/reference/configuration"
  />
  <Card title="CLI" description="oke dev writes the env map." href="/docs/reference/cli" />
  <Card title="Vault" description="Master key and managed providers." href="/docs/elements/vault" />
</Cards>


# Errors (/docs/reference/errors)

OKE has two error families: **failures are values** a Flow returns
(`{ data: null, error: { code, data } }`), while **framework errors are thrown**
for invariant violations (permanent numeric code + cause + fix).

```text
OKE1110  domain table not found — migrations have not been applied.
         → run `oke db migrate` against this environment.
         https://oke.omqkhafi.dev/e/1110
```

Codes are stable after the domain-range renumber — match them safely across upgrades
(see the Unreleased Breaking Changes mapping if you still have a pre-renumber note).

<Callout title="The one rule">
  Switch on `error.code` for Flow failures. Catch / match `OKE####` only for thrown framework
  invariants. Do not treat a typed `NotFound` as an unhandled exception.
</Callout>

## Smallest Example

<Steps>

<Step>
### Return a typed failure

```typescript
on(
  http.get({
    in: z.object({ id: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [row] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!row) return fx.fail("NotFound", { id });
      return row;
    },
  }),
);
```

</Step>

<Step>
### Handle it on the client

```typescript
const { data, error } = await api.notes.get({ id });
if (error?.code === "NotFound") {
  // value — not a throw
}
```

</Step>

</Steps>

## Localized messages

Typed failures and OKE codes ship English and Arabic ICU catalogs. Locale comes
from `Accept-Language` (matched to `i18n.locales`); fallback is `i18n.default`.

| Surface            | Keys                                       | How it appears                           |
| ------------------ | ------------------------------------------ | ---------------------------------------- |
| `fx.fail` / `fail` | `errors.{code}` · `errors.{code}.{reason}` | Optional `error.message` on the envelope |
| Thrown `OkeError`  | `oke.{code}.cause` · `oke.{code}.fix`      | Cause + fix lines in the thrown message  |

Override via `defineLocale`. Pass `fail(code, data, { message })` for a custom
string. Custom app codes stay message-less until registered. Full catalogs:
[i18n](/docs/reference/i18n).

## OKE numeric codes

| Code   | Name                   | Cause                                                  | Fix                                                       |
| ------ | ---------------------- | ------------------------------------------------------ | --------------------------------------------------------- |
| `1001` | undeclared read        | Flow reads a resource not in `effects.reads`           | Add it to the flow's `effects.reads`                      |
| `1002` | undeclared write       | Flow writes a resource not in `effects.writes`         | Add it to the flow's `effects.writes`                     |
| `1003` | undeclared emit        | Flow emits a signal not in `effects.emits`             | Add it to the flow's `effects.emits`                      |
| `1004` | undeclared send        | Flow sends a template not in `effects.sends`           | Add it to the flow's `effects.sends`                      |
| `1005` | undeclared ask         | Flow asks a prompt not in `effects.asks`               | Add it to the flow's `effects.asks`                       |
| `1006` | undeclared secret      | Flow reads a secret not in `effects.secrets`           | Add it to the flow's `effects.secrets`                    |
| `1007` | undeclared call        | Flow calls a flow not in `effects.calls`               | Add it to the flow's `effects.calls`                      |
| `1008` | undeclared fetch       | Flow fetches a host not in `effects.fetches`           | Add the hostname to the flow's `effects.fetches`          |
| `1009` | undeclared embed       | Flow embeds with a model not in `effects.embeds`       | Add it to the flow's `effects.embeds`                     |
| `1020` | no effects declared    | Flow has no `effects` and no Manifest to infer from    | Run `oke build` / `oke dev`, or declare effects           |
| `1030` | adopt barrel stale     | A `src/flows/<unit>` folder was not adopted            | Run `oke dev` or `oke build` to regenerate `generated.ts` |
| `1040` | HTTP path unresolved   | Pathless `http.get()` never received a file-tree stamp | Import `@/flows/generated`, or pass `http.get("/…")`      |
| `1041` | HTTP route clash       | Two HTTP flows share the same method + path            | Give each flow a unique method + path                     |
| `1045` | HTTP flow unnamed      | Adopted HTTP flow still has no `unit.export`           | Export from `flows/<unit>/` or pass a named `flow`        |
| `1050` | live exposure dup      | Same signal, gates, and match on two GET routes        | Change the gate or path-param filter                      |
| `1060` | MCP tool duplicate     | Two MCP tool bindings share the same tool name         | Give each MCP tool exposure a unique name                 |
| `1070` | flow name duplicate    | Two Flows share the same Manifest / `fx.call` name     | Give at least one an explicit `flow("…")` or tree export  |
| `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once`    | Use `signal.broadcast`, or bind only one Flow             |
| `1072` | flow unnamed           | Signal / Clock consumer still has no `unit.export`     | Export from `flows/<unit>/` or pass a named `flow`        |
| `1110` | schema missing         | Domain table absent in `prod` — no auto-DDL            | Run `oke db migrate` against this environment             |
| `1210` | live resume gap        | `Last-Event-ID` is not on the retained tape            | Reconnect without the cursor; remaining tape replays      |
| `1240` | orphan emit            | Emit with zero subscribers and `optional` false        | Add `on(signal, …)` or declare `optional: true`           |
| `1250` | signal schema          | Emit payload failed the signal's `schema`              | Pass a payload that matches `schema`, or remove it        |
| `1605` | channel schema         | Send payload failed the template's `schema`            | Fix template `data` payload or the template `schema`      |
| `1810` | tenant required        | Tenant-scoped op with no `fx.tenant.id`                | `switchTenant`, signed `tid`, or tenant header            |
| `1820` | tenant not member      | Client-supplied tenant id is not a membership          | Pick from `listTenants` or add the user as a member       |
| `1830` | tenant unknown scope   | Tenant role used an invented or `console:*` scope      | Use a declared application scope                          |

<Callout title="Effects are usually inferred">
  The 1001–1007 · 1008 · 1009 family exists for flows that declare effects explicitly. Most apps
  never write an `effects` block — inference covers them — so seeing one of these means an explicit
  declaration drifted from the code.
</Callout>

## Gate denials (typed failures)

Returned, not thrown — the request never reached `do`:

| Code           | When                                         | Payload                                                                                    |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `Unauthorized` | Policy denied, request not authenticated     | —                                                                                          |
| `Forbidden`    | Policy denied, authenticated but not allowed | `gate`, `reason` (`tenant_required` · `not_member` · `unknown_scope` · `session_only` · …) |
| `RateLimited`  | Rate gate budget exhausted                   | `retryAfterMs`                                                                             |

## Framework validation failures

| Code                   | When                                                                                                                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `ValidationError`      | Input failed the `in` schema, or a list param isn't whitelisted (`unknown list param "x"`)                          |
| `NotFound`             | A `store.resource` get/update/remove hit a missing row                                                              |
| `InvalidQuery`         | QUERY missing `Content-Type` (`reason: missing_content_type`) or body isn't JSON (`inconsistent_content`) — **400** |
| `UnsupportedMediaType` | QUERY `Content-Type` is present but not `application/json` — **415**, `Accept-Query` lists JSON                     |

## Subsystem errors

Thrown by specific subsystems — each names its own cause:

| Error                         | Thrown when                                                        | What to do                                                            |
| ----------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- |
| `VaultBootError`              | A vault contract has no value in any resolution layer              | Set the missing names — the error lists every gap                     |
| `VaultError` (`UNSUPPORTED`)  | Managed provider unknown                                           | Use an official id, built-in `vault`, or `env`                        |
| `VaultSealed`                 | Console rotate-master while this process holds no master key       | Export `OKE_VAULT_MASTER_KEY` or run `oke vault unseal`               |
| `VaultRotateBusy`             | Master-rotation lease held or a batch is already in flight         | Wait and continue, or resume with `oke vault rotate-master --new-key` |
| `VaultUnsupported`            | Console vault action on a non-builtin backend or missing SQL       | Use `drivers.vault = "vault"` and set `DATABASE_URL`                  |
| `AiPiiBuildError`             | Build: a flow sends PII fields to a third-party model              | Drop the fields or add `allowPii: true` — fields named                |
| `AiSchemaValidationError`     | A model response failed the prompt's `out` schema                  | Fix the prompt or the schema — response didn't conform                |
| `ScheduleNotOverridableError` | Console tried to edit a clock declared without `overridable: true` | Declare it overridable and redeploy                                   |
| `ClockResourceNotFoundError`  | Console action targeted an unknown clock name                      | Check the name against your `clock()` declarations                    |
| `DryRunWriteIsolationError`   | A write attempted inside a dry run                                 | Dry runs never write — use a real run                                 |
| `ManifestValidationError`     | The compiled Manifest failed schema validation                     | Re-run the build; the error names the offending entry                 |
| `CrossPlaneError`             | A user-plane token was used on the operator plane (or vice versa)  | Use the correct principal for the plane                               |
| `AttenuationError`            | A token was used beyond its attenuated scope                       | Re-issue with the needed scope                                        |
| `SessionError`                | Session token invalid, expired, or malformed                       | Re-authenticate                                                       |
| `OperatorError`               | Operator-plane operation failed its checks                         | Error message names the failed check                                  |
| `AccessGrantError`            | Console access grant rejected                                      | Re-request access with a valid grant                                  |
| `UnsupportedPathError`        | A route path shape the router can't compile                        | Simplify the path pattern                                             |

## Troubleshooting

<Accordions>

<Accordion title="I hit OKE1001–1009">
  Explicit `effects` drifted from `do`. Add the missing ledger entry or remove the hand-written
  `effects` block so inference covers the Flow.
</Accordion>

<Accordion title="OKE1040 pathless HTTP never stamped">
  Import `@/flows/generated`, or pass an explicit path: `http.get("/users/:id")`.
</Accordion>

<Accordion title="OKE1041 method + path bound twice">
  A resource mount and a handwritten route share the same method + path. Drop one binding.
</Accordion>

<Accordion title="OKE1072 Signal or Clock flow unnamed">
  Cause: `A {kind} flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`. See
  [Signal](/docs/elements/signal) · [Clock](/docs/elements/clock).
</Accordion>

<Accordion title="OKE1071 once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should get a copy, or bind only one Flow. See [Once · Competing
  consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
</Accordion>

<Accordion title="OKE1110 in production">
  Domain tables missing — prod has no auto-DDL. Run `oke db migrate` against that environment
  ([CLI](/docs/reference/cli)).
</Accordion>

<Accordion title="Unauthorized / Forbidden / RateLimited">
  These are gate denials returned as values before `do` runs — not thrown OKE codes. Fix the policy,
  membership, or rate budget. See [Gate](/docs/elements/gate).
</Accordion>

<Accordion title="VaultBootError at startup">
  A vault contract has no value in any resolution layer. The error lists every gap — set the missing
  names ([Vault](/docs/elements/vault)).
</Accordion>

</Accordions>

## Learn more

- [i18n](/docs/reference/i18n) — catalogs, `fx.t`, locale matching
- [Flow](/docs/elements/flow) — `fx.fail` and the response envelope
- [Gate](/docs/elements/gate) — where the three denials come from
- [CLI](/docs/reference/cli) — `oke db migrate` and friends
- [fx](/docs/reference/fx) — how failures and effects surface

## Next

<Cards>
  <Card title="fx" description="The door that records effects." href="/docs/reference/fx" />
  <Card
    title="Gate"
    description="Where Unauthorized and Forbidden come from."
    href="/docs/elements/gate"
  />
  <Card title="Client" description="Switch on error.code in the browser." href="/docs/client" />
</Cards>


# fx (/docs/reference/fx)

`fx` is the second argument of every `do` — the single door to the world. This page is the
whole surface; each entry notes the **effect it records**, which feeds the Manifest, caching,
and capability checks.

<Callout title="The one rule">
  All world access goes through `fx`. No `node:` I/O, no raw `fetch` for side effects (use
  `fx.fetch` instead), no `Date.now()` — clocks, stores, channels, vault, AI, and outbound HTTP only
  through this object.
</Callout>

## Smallest Example

<Steps>

<Step>
### Use `fx` inside `do`

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      await fx.store(db).insert(notes).values({ id, title });
      return fx.json.create({ id, title });
    },
  }),
);
```

</Step>

<Step>
### Type extracted helpers as `Fx`

```typescript
import type { Fx } from "okengine";

async function loadNote(id: string, fx: Fx) {
  return fx.store(db).findById(notes, id);
}
```

A narrower structural type will not match `store()` overloads. See [Flow](/docs/elements/flow).

</Step>

</Steps>

## What the effects ledger powers

| Derived behavior       | From                                   |
| ---------------------- | -------------------------------------- |
| Cache invalidation     | Inferred / ledgered `reads` · `writes` |
| Live queries / CDC     | Store `writes` observed as row events  |
| Least-privilege tokens | Effect matrices on Flows               |
| Deterministic tests    | Injectable clock · store · channel     |
| Runs observability     | Wide events per invocation (`fx.runs`) |

## Stores

| Signature                                                           | Records                    | Returns                                                                                     |
| ------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------- |
| `fx.store(sqlDecl).select().from(t)…`                               | `read`                     | inferred rows (`where` · `orderBy` · `limit` · `offset` chainable)                          |
| `fx.store(sqlDecl).insert(t).values(v)`                             | `write`                    | `Promise<void>`                                                                             |
| `fx.store(sqlDecl).update(t).set(v).where(…)`                       | `write`                    | `Promise<void>`                                                                             |
| `fx.store(sqlDecl).delete(t).where(…)`                              | `write`                    | `Promise<void>`                                                                             |
| `fx.store(sqlDecl).findById(t, id)`                                 | `read`                     | row \| undefined                                                                            |
| `fx.store(sqlDecl).search(table, { query, fuse?, rerank?, … })`     | `read` (+ `ask` if rerank) | `{ data, meta }` hybrid BM25 ± LSH — see [Search](/docs/elements/store/search)              |
| `fx.store(kv).get / set(key, value, ttl?) / delete / list(prefix?)` | read / write               | per op                                                                                      |
| `fx.store(files).put / get / delete / list(prefix?)`                | read / write               | per op                                                                                      |
| `fx.store(files).image(key\|bytes).…`                               | read / write               | Bun.Image chain; terminals gate (see [Store](/docs/elements/store#images--image--putimage)) |
| `fx.store(files).putImage(key, data, opts?)`                        | `write`                    | original + variants (+ optional LQIP)                                                       |
| `fx.store(index).upsert / search(vector, topK?) / delete`           | read / write               | per op                                                                                      |

See [Store](/docs/elements/store) for the query-builder surface.

## Signals

| Signature                             | Records                | Notes                                                                                                                                                                                                                                                                                                                            |
| ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fx.emit(signal, payload?, { key? })` | `emit`                 | Pass a `SignalDecl<T>` handle to type-check `payload`; a string name stays `unknown` (runtime `schema` still applies). Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1240** (orphan) or **OKE1250** (schema) |
| `fx.deadLetters(signal)`              | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**.                                                                                                                                                                                      |
| `fx.live(signal, { match? })`         | `read` `signal:<name>` | Live tape as SSE. Returns `JsonStreamResult` (object chunks, `id:` on the wire). Cross-signal throws **OKE1001**. Do not wrap with `fx.json.stream`.                                                                                                                                                                             |

## Runs (observability read)

Declare `effects: { reads: ["runs"] }`. Powers native SLO checkers (Clock + Channel) without a parallel metrics API.

| Signature                                | Records       | Returns                                                                                  |
| ---------------------------------------- | ------------- | ---------------------------------------------------------------------------------------- |
| `fx.runs.query(sql)`                     | `read` `runs` | SQL rows (`FROM runs` on files/memory). Unrestricted Flow SQL — not the Console sandbox. |
| `fx.runs.all()`                          | `read` `runs` | All wide events                                                                          |
| `fx.runs.window(flow, windowMs?)`        | `read` `runs` | Rolling P50/P95/P99 + error rate (default 5m)                                            |
| `fx.runs.checkSlo(flow, slo, windowMs?)` | `read` `runs` | Availability / latency breaches                                                          |

```typescript
const sloCheckClock = clock.every("ops.slo-check", "5m");

on(
  sloCheckClock,
  flow("ops.slo-check", {
    effects: { reads: ["runs"], sends: ["slo-alert"] },
    do: async (_, fx) => {
      const breaches = await fx.runs.checkSlo(
        "checkout.create",
        { availability: "99.9%", latency: { p95: "200ms" } },
        5 * 60_000,
      );
      if (breaches.length === 0) return;
      await fx.send(sloAlert, {
        to: "oncall@example.com",
        data: { flow: "checkout.create", count: breaches.length },
      });
    },
  }),
);
```

## Flows

| Signature                                                                      | Records                 | Returns / notes                                                                                    |
| ------------------------------------------------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------- |
| `fx.call(flow, input?)`                                                        | `call`                  | The callee's `out` — runs through the same pipeline                                                |
| `fx.step(name, fn)`                                                            | —                       | Durable step: replays from the journal, never re-runs                                              |
| `fx.all([...thunks])`                                                          | —                       | Parallel; first rejection aborts siblings                                                          |
| `fx.race([...thunks])`                                                         | —                       | First settle wins; losers aborted                                                                  |
| `fx.retry(fn, opts?)`                                                          | —                       | Exponential backoff + jitter (plain Promise)                                                       |
| `fx.using(acq, rel, use)`                                                      | —                       | `release` runs once on settle or ambient abort                                                     |
| `fx.signal`                                                                    | —                       | Ambient `AbortSignal` for the current branch                                                       |
| `fx.fail(code, data, opts?)`                                                   | —                       | Typed failure value (`opts.message` overrides)                                                     |
| `fx.auth.createApiKey({ name, scopes, expiresIn?, ipAllowlist?, rateLimit? })` | `write` `auth:api-keys` | Secret once. Creator is live `userId` / `scopes`. Session only. `ipAllowlist` is IPs or hostnames. |
| `fx.auth.listApiKeys()`                                                        | `read` `auth:api-keys`  | Keys this session minted                                                                           |
| `fx.auth.revokeApiKey(id)`                                                     | `write` `auth:api-keys` | Owner only                                                                                         |
| `fx.auth.rotateApiKey(id)`                                                     | `write` `auth:api-keys` | New secret once. Owner only                                                                        |
| `fx.auth.updateApiKey(id, …)`                                                  | `write` `auth:api-keys` | Name / scopes / expiry / allowlist / rate. Re-attenuates                                           |
| `fx.auth.listTenants()`                                                        | `read` `auth:tenants`   | Memberships for the live session. Session only                                                     |
| `fx.auth.switchTenant(id)`                                                     | `write` `auth:tenants`  | New access+refresh, new family, `tid` on both. Never Set-Cookie. Session only                      |
| `fx.auth.createTenant({ name, slug?, id? })`                                   | `write` `auth:tenants`  | Creator becomes a member. Session only                                                             |
| `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })`                     | `write` `auth:tenants`  | Application scopes only — `console:*` is unknown_scope                                             |

`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization) and
propagates `fx.tenant.id`. For audit/attribution only, read `fx.principal` — it propagates the
originating identity without copying into `fx.auth`. Gates never consult `fx.principal`.

## Concurrency and retry

Pass **thunks** to `all` / `race` — not already-started Promises — so each branch gets an abort scope before work begins.

```typescript
const [user, stock] = await fx.all([
  () => fx.store(db).findById(users, input.userId),
  () => fx.store(db).findById(inventory, input.sku),
]);

const charge = await fx.step("charge", () =>
  fx.retry(() => fx.call(stripeCharge, { amount: input.total }), {
    retries: 3,
    delay: "100ms",
    backoff: 2,
    jitter: true,
  }),
);
```

| `fx.retry` option | Default | Meaning                                      |
| ----------------- | ------- | -------------------------------------------- |
| `retries`         | `0`     | Extra attempts after the first               |
| `delay`           | `50`    | Initial backoff — ms number or `"100ms"`     |
| `backoff`         | `2`     | Multiplier after each retry                  |
| `jitter`          | `true`  | Full jitter on the delay (thundering-herd)   |
| `when`            | thrown  | Predicate; skips `AbortError` and sleep park |

<Callout title="Cooperative cancel">
  Losing branches see `fx.signal` abort. Drivers that do not yet honor the signal may still finish
  in the background — check `fx.signal.aborted` in long user work, and prefer `fx.all` over bare
  `Promise.all`.
</Callout>

`fx.using(acquire, release, use)` scopes a process-local resource to one attempt: `release` runs
exactly once when `use` settles **or** when the ambient signal aborts (a sibling `fx.race` winner,
a failing `fx.all` sibling). It is not journaled — do not hold handles across durable park/resume.

```typescript
const rows = await fx.using(
  () => pool.acquire(),
  (conn) => conn.release(),
  (conn) => conn.query("select …"),
);
```

**Consequence:** put `fx.retry` inside `fx.step` on durable flows so a completed charge never re-runs on resume. Coarse whole-body retry is `flow(name, { retry: { … } })` on the same journal session.

## Channel

| Signature                                    | Records | Notes                                                                                      |
| -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ |
| `fx.send(template, { to?, data?, via?, … })` | `send`  | `via` orders fallback; `locale` / `profileLocale` / `acceptLanguage` feed the locale chain |

Omit locale opts and the send uses `fx.locale`. Dry runs record _would have fired_ and never
contact a provider. Channel bodies use `{{field}}` catalogs — not ICU (see [Channel](/docs/elements/channel)).

## Outbound HTTP

| Signature              | Records                     | Notes                                                                     |
| ---------------------- | --------------------------- | ------------------------------------------------------------------------- |
| `fx.fetch(url, init?)` | `fetch` on the URL hostname | Always stamps `EffectEntry.external` with `{ host, kind: "third-party" }` |

Declare hosts in `effects.fetches` (e.g. `["api.stripe.com"]`). Prefer `fx.step`; use
`fx.retry` only when the remote API is safe to repeat. Dry runs never hit the network.

Use this for third-party REST outside Channel / AI / Store — not as a substitute for those
elements.

## AI

| Signature                                             | Records                   | Returns                                                                                                  |
| ----------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------- |
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out`                                                              |
| `fx.run(agent, input?)`                               | `ask`                     | Agent result                                                                                             |
| `fx.stream(model, { prompt?, data?, via? })`          | `ask`                     | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
| `fx.search(embed, query, { topK? })`                  | `read`                    | Matches from the index/embed                                                                             |

AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).

Driver-reported `EffectEntry.external` marks cloud providers as `third-party` and
self-hosted / `provider: "local"` as `infrastructure` (Console waterfall dashed egress +
host tooltip).

## Vault

| Signature                          | Records  | Returns / notes                                                                                    |
| ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `fx.vault.get(contract)`           | `secret` | `Promise<Redacted<string>>` — prints/logs as a placeholder; `.reveal()` at the credential boundary |
| `fx.vault.set(path, value, opts?)` | `secret` | `{ path, version }` — needs a bound Vault backend                                                  |
| `fx.vault.rotate(path, value)`     | `secret` | `{ path, version }` — new version with a fresh data key                                            |
| `fx.vault.delete(path)`            | `secret` | `boolean` — crypto-shreds the path                                                                 |
| `fx.vault.list(prefix?)`           | —        | Secret paths, never values                                                                         |
| `fx.vault.status()`                | —        | `{ sealed, initialized, backend }`                                                                 |

`get` reads through the boot resolution chain. Everything else needs the encrypted-at-rest backend (`drivers.vault = "vault"`) and throws without it.

## Clock

| Signature                         | Notes                                                               |
| --------------------------------- | ------------------------------------------------------------------- |
| `fx.clock.now()`                  | Epoch-ms, injectable — the only legal "now"                         |
| `fx.clock.ago(duration)`          | Instant before now (`"30d"` → now − 30 days)                        |
| `fx.clock.fromNow(duration)`      | Instant after now (`"14d"` → now + 14 days)                         |
| `fx.clock.duration(duration)`     | Span in ms — offset a stored instant (`createdAt + duration("7d")`) |
| `fx.clock.sleep(label, duration)` | Durable sleep in `durable` flows; immediate otherwise               |

Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400_000 ms, not a calendar day. Unknown strings parse as `0`.

## Cache

Read-only flows cache automatically from inferred or ledgered `reads` — no
`fx.cache` call and no `cache:` default on the flow. Writes invalidate those
keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.

`fx.cache` is the manual (tier-3) surface:

| Signature                              | Notes                                     |
| -------------------------------------- | ----------------------------------------- |
| `fx.cache.get(key)`                    | Value or `undefined`                      |
| `fx.cache.set(key, value, ttl?)`       | Optional TTL string                       |
| `fx.cache.getOrSet(key, ttl, produce)` | Read-through; writes invalidate by effect |

## Responses

| Helper                                    | Status | Body                                                           |
| ----------------------------------------- | ------ | -------------------------------------------------------------- |
| `fx.json.ok(value, { meta? })`            | 200    | `{ data, meta?, error: null }`                                 |
| `fx.json.create(value)`                   | 201    | `{ data, error: null }`                                        |
| `fx.json.empty()`                         | 204    | no body                                                        |
| `fx.json.with(page)` / `with(data, meta)` | 200    | `{ data, meta, error: null }` — already-built pager            |
| `fx.json.withQuery(rows, input, spec?)`   | 200    | In-memory list page — zero-config `q` / auto-eq / PostgREST    |
| `fx.json.stream(chunks)`                  | 200    | `text/event-stream` — JSON `data:` frames, then `data: [DONE]` |
| `fx.live(signal)`                         | 200    | Same SSE carrier for a live signal (payload frames + `id:`)    |

Returning a plain value instead answers 200 with `{ data: value, error: null }` — the helpers exist for status and `meta` control. Pass `fx.stream(...)` into `fx.json.stream` to reach the HTTP client token-by-token.

## Logging, i18n, ids

| Signature                                  | Notes                                                       |
| ------------------------------------------ | ----------------------------------------------------------- |
| `fx.log.debug/info/warn/error(msg, data?)` | Redacting — secrets print as `***`                          |
| `fx.t(key, values?)`                       | ICU MessageFormat — active locale → `i18n.default` → key    |
| `fx.locale`                                | Active locale (`Accept-Language` matched to `i18n.locales`) |
| `fx.id()`                                  | OKID — 21-char native id from `okengine/okid`               |

Catalogs, ICU syntax, and `Register` augmentation live on [i18n](/docs/reference/i18n).
Id options: [OKID](/docs/reference/okid). Localized `fx.fail` / `OkeError` catalogs:
[Errors](/docs/reference/errors).

## Principals

| Property       | Shape                                                                                 |
| -------------- | ------------------------------------------------------------------------------------- |
| `fx.auth`      | `{ userId, scopes, verified?, apiKeyId? }` plus key and tenant methods (session only) |
| `fx.operator`  | `{ id: string \| null }` — Console plane                                              |
| `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?`             |
| `fx.tenant`    | `{ id: string \| null }` — active tenant (propagates on `fx.call`)                    |

**Consequence:** use `fx.auth` / gates for authorization; use `fx.principal` only when a callee
must log who started the call chain. A key Bearer sets `userId` to the issuer and `apiKeyId`
to the key — see [Gate](/docs/elements/gate#api-keys).

## Not on `fx`

<Callout title="No fx.metric">
  Investigated and declined. `fx.runs` already provides per-invocation observability as wide events.
  Native alerting is `fx.runs` + Clock + Channel — not a second counter/gauge API. Optional OTLP
  export for existing Grafana/Datadog stacks is additive and never required.
</Callout>

## Troubleshooting

<Accordions>

<Accordion title="OKE1001–1007 / 1008 / 1009 undeclared effect">
  An explicit `effects` block drifted from what `do` touches. Add the missing ledger entry (`reads`
  · `writes` · `emits` · `sends` · `asks` · `embeds` · `secrets` · `calls` · `fetches`), or drop the
  block so inference covers the Flow ([Errors](/docs/reference/errors)).
</Accordion>

<Accordion title="OKE1240 orphan emit">
  `fx.emit` with zero subscribers and `optional: false`. Add `on(signal, …)` or declare the signal
  `optional: true`.
</Accordion>

<Accordion title="OKE1250 signal schema">
  Emit payload failed the signal's `schema`. Pass a matching payload or remove the schema.
</Accordion>

<Accordion title="Cross-signal fx.live / fx.deadLetters">
  Reading another signal's tape throws **OKE1001**. Declare
  `effects.reads: ["signal:<name>"]` for that name.
</Accordion>

<Accordion title="Helper fx type errors on store()">
  Type the parameter as `Fx` from `okengine`, not a hand-rolled structural type.
</Accordion>

</Accordions>

## Learn more

- [Flow](/docs/elements/flow) — why `fx` is the only door
- [Channel](/docs/elements/channel) — `fx.send`, consent, locale chain, `{{field}}` catalogs
- [i18n](/docs/reference/i18n) — `fx.t`, catalogs, locale matching
- [Errors](/docs/reference/errors) — what `fx.fail` produces
- [Configuration](/docs/reference/configuration) — drivers and the `i18n` block
- [OKID](/docs/reference/okid) — `fx.id()` options

## Next

<Cards>
  <Card title="Flow" description="Triggers, effects, and durability." href="/docs/elements/flow" />
  <Card title="Errors" description="OKE codes and failure values." href="/docs/reference/errors" />
  <Card title="Client" description="Call Flows with the same envelope." href="/docs/client" />
</Cards>


# i18n (/docs/reference/i18n)

App copy lives in message catalogs — greetings, plurals, and status lines you
format inside a Flow with `fx.t`. Configure supported locales once in
`oke.config.ts`; the request's `Accept-Language` picks the active tag.

<Callout title="The one rule">
  Register catalogs with `defineLocale` before boot, list every locale in `i18n.locales`, and call
  `fx.t(key, values?)` for Flow copy. Channel emails use a separate `{{ field }}` catalog — not ICU.
</Callout>

## Quick start

<Steps>

<Step>
### Configure locales

```typescript title="oke.config.ts"
i18n: { locales: ["en"], default: "en" },
```

create-oke scaffolds English-only. Choose **Add more languages** (or
`--locales ar,fr`) to write locale files + `locales/index.ts` and expand
`i18n.locales` (Arabic also gets `dir: { ar: "rtl" }`).

If `i18n` is omitted, boot defaults to `locales: ["en"]` and `default: "en"`.

</Step>

<Step>
### Register catalogs

Each locale file calls `defineLocale`. The starter pulls them in once from
`core.ts` via `locales/index.ts` — `app.ts` only needs `import "@/core"`:

```typescript title="src/locales/en.ts"
import { defineMessages, defineLocale } from "okengine";

export const en = defineMessages({
  greeting: "Hello, {name}",
  items: "{count, plural, one {# item} other {# items}}",
  errors: { notFound: "Not found" },
});
defineLocale("en", en);

declare module "okengine" {
  interface Register {
    messages: typeof en;
  }
}
```

```typescript title="src/locales/ar.ts"
import { defineLocale, type MessagesFor } from "okengine";
import type { en } from "./en";

defineLocale("ar", {
  greeting: "مرحباً، {name}",
  items: "{count, plural, zero {لا عناصر} one {عنصر واحد} other {# عناصر}}",
  errors: { notFound: "غير موجود" },
} satisfies MessagesFor<typeof en>);
```

```typescript title="src/locales/index.ts"
import "./en";
import "./ar";
```

When you add `ar` via create-oke, the Arabic catalog is filled in and
`locales/index.ts` gains `import "./ar"`. Other tags get an English stub.

</Step>

<Step>
### Use `fx.t` in a Flow

```typescript
do: async (input, fx) => {
  return {
    text: fx.t("greeting", { name: input.name }),
    countLabel: fx.t("items", { count: input.count }),
    locale: fx.locale,
  };
},
```

Send `Accept-Language: ar` (or `ar-SA`) against `locales: ["en", "ar"]` and
`fx.locale` is `"ar"`. Missing keys fall back through `i18n.default`, then the
key string itself.

</Step>

</Steps>

## Config (`i18n`)

| Option    | Type       | Default (when omitted) | Meaning                                     |
| --------- | ---------- | ---------------------- | ------------------------------------------- |
| `locales` | `string[]` | `["en", "ar"]`         | Tags matched against `Accept-Language`      |
| `default` | string     | `"en"`                 | Fallback for `fx.t`, Channel, fail messages |
| `dir`     | record     | —                      | Per-locale direction: `"ltr"` \| `"rtl"`    |

Matching: exact tag → language subtag (`ar-SA` → `ar`) → `default`.

## `fx.t` and `fx.locale`

| Signature            | Notes                                                    |
| -------------------- | -------------------------------------------------------- |
| `fx.t(key, values?)` | ICU MessageFormat — active locale → `i18n.default` → key |
| `fx.locale`          | Active BCP 47 tag for this run                           |

Nested trees flatten to dot keys (`errors.notFound`). App overlays win over
built-in keys for the same locale.

## ICU MessageFormat

`fx.t` formats catalog strings with
[ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/)
(FormatJS). The active locale drives plural/select rules — English `one`/`other` vs Arabic `zero`/`two`/`few`/`many` on the same key.

| Feature         | Syntax sketch                                             | `values`                    |
| --------------- | --------------------------------------------------------- | --------------------------- |
| Interpolation   | `Hello, {name}`                                           | `{ name: "Ada" }`           |
| Exact plural    | `{count, plural, =0 {none} one {# item} other {# items}}` | `{ count: 0 }`              |
| Cardinal plural | `{count, plural, one {…} other {…}}`                      | `{ count: number }`         |
| Ordinal         | `{place, selectordinal, one {#st} two {#nd} other {#th}}` | `{ place: number }`         |
| Select          | `{status, select, online {…} offline {…} other {…}}`      | `{ status: "online" }`      |
| Rich-text tag   | `Read <docs>the docs</docs>`                              | `{ docs: (chunks) => "…" }` |

`#` inside a plural/ordinal branch is the numeric argument. Always include an
`other` (or `=N`) branch — ICU requires a fallback.

### Interpolation

```typescript
// catalog: "Hello, {name}"
fx.t("greeting", { name: "Ada" }); // → "Hello, Ada"
```

Values may be `string`, `number`, `boolean`, `Date`, `null` / `undefined`, or a
rich-text function (below). Missing args leave the source string unformatted.

### Plurals (cardinal)

```typescript
// en: "{count, plural, =0 {no items} one {# item} other {# items}}"
fx.t("items", { count: 0 }); // → "no items"
fx.t("items", { count: 1 }); // → "1 item"
fx.t("items", { count: 5 }); // → "5 items"
```

#### Arabic cardinals

Arabic (`ar`) uses six [CLDR](https://cldr.unicode.org/index/cldr-spec/plural-rules)
cardinal categories. FormatJS picks the branch from `fx.locale` — an English `one`/`other` skeleton on `ar` misfires for dual, paucal, and hundreds.

| Category | When (integers)                        | Typical form                       |
| -------- | -------------------------------------- | ---------------------------------- |
| `zero`   | `n = 0`                                | No items / special zero phrasing   |
| `one`    | `n = 1`                                | Singular                           |
| `two`    | `n = 2`                                | Dual                               |
| `few`    | `n % 100` in `3…10` (also `103…110` …) | Paucal — often sound plural        |
| `many`   | `n % 100` in `11…99`                   | Accusative / “tamyīz” style counts |
| `other`  | `100…102`, `200…202`, … and fractions  | General plural / leftover integers |

Write every branch on the Arabic catalog (starter `items` key):

```typescript
// ar catalog
items: "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصراً} other {# عنصر}}";
```

```typescript
// fx.locale === "ar"
fx.t("items", { count: 0 }); // → "لا عناصر"      (zero)
fx.t("items", { count: 1 }); // → "عنصر واحد"     (one)
fx.t("items", { count: 2 }); // → "عنصران"        (two)
fx.t("items", { count: 5 }); // → "5 عناصر"       (few)
fx.t("items", { count: 11 }); // → "11 عنصراً"     (many)
fx.t("items", { count: 100 }); // → "100 عنصر"      (other)
fx.t("items", { count: 103 }); // → "103 عناصر"     (few — 103 % 100 = 3)
```

**Consequence:** copy the six-way shape for Arabic noun counts; do not reuse an
English `one`/`other` skeleton. `#` still inserts the number inside a branch.

### Ordinals (`selectordinal`)

```typescript
// "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}!"
fx.t("place", { place: 1 }); // → "You finished 1st!"
fx.t("place", { place: 11 }); // → "You finished 11th!"
```

### Select (enums)

```typescript
// "{status, select, online {Online} offline {Offline} other {Unknown}}"
fx.t("status", { status: "online" }); // → "Online"
fx.t("status", { status: "away" }); // → "Unknown"
```

### Rich-text tags

Tags in the message become function values. The function receives the formatted
inner chunks and returns a string (HTML, Markdown, plain wrappers):

```typescript
// catalog: "Read <docs>the docs</docs>"
fx.t("cta", {
  docs: (chunks) => `<a href="/docs">${chunks.join("")}</a>`,
});
// → 'Read <a href="/docs">the docs</a>'
```

### Escaping

| Need                        | Write                                      |
| --------------------------- | ------------------------------------------ |
| Apostrophe in copy          | Double it: `this flow''s effects`          |
| Literal `{` / `}` in output | Quote the braces: `'{'optional: true'}'`   |
| Channel-style `{{field}}`   | Not ICU — use Channel catalogs, not `fx.t` |

Malformed ICU falls back to the raw catalog string (no throw from `fx.t`).

## Typed keys

Augment `Register` with your English tree so `fx.t` autocompletes and rejects
typos. Keep other locales aligned with `satisfies MessagesFor<typeof en>`.

| Helper           | Role                                             |
| ---------------- | ------------------------------------------------ |
| `defineMessages` | Preserve a `const` English (or canonical) tree   |
| `defineLocale`   | Register / replace a locale's flat catalog       |
| `MessagesFor<T>` | Same key shape as `T`; leaf values are strings   |
| `AppMessageKey`  | Flattened key union once `Register` is augmented |

## Built-in failure catalogs

English and Arabic ship for typed failures and OKE codes — no app registration
required:

| Surface            | Keys                                       | Appears as                       |
| ------------------ | ------------------------------------------ | -------------------------------- |
| `fx.fail` / `fail` | `errors.{code}` · `errors.{code}.{reason}` | Optional `error.message`         |
| Thrown `OkeError`  | `oke.{code}.cause` · `oke.{code}.fix`      | Cause + fix lines in the message |

Override any key with `defineLocale`. Pass `fail(code, data, { message })` (or
`fx.t(...)`) when you need a one-off string. Custom app codes stay message-less
until registered. Full tables: [Errors](/docs/reference/errors).

## Channel catalogs are separate

`fx.send` templates use `{{field}}` bodies and their own `locales` list — not
ICU. Omit `locale` / `profileLocale` / `acceptLanguage` on `fx.send` and the
send uses `fx.locale`. Details: [Channel](/docs/elements/channel).

## Troubleshooting

<Accordions>
<Accordion title="fx.t returns the key string unchanged">

No catalog entry for that key in the active locale or `i18n.default`. Register
it with `defineLocale`, import the locale module before boot, and check the
flattened key (`errors.notFound`, not `errors: { notFound }`).

</Accordion>
<Accordion title="Response is English despite Accept-Language: ar">

The tag must match `i18n.locales` (exact or base language). A request for `fr`
with only `["en", "ar"]` falls back to `i18n.default`. Confirm the header reaches
the app (proxies sometimes strip it).

</Accordion>
<Accordion title="Email body is still English while fx.t is Arabic">

Channel catalogs are separate `{{field}}` strings. Add an `ar` body on the
template / plugin catalog; `fx.t` does not translate Channel templates.

</Accordion>
<Accordion title="TypeScript rejects a key that exists at runtime">

Augment `Register` with `messages: typeof en` in the English locale module.
Without that, `fx.t` accepts any `string` and loses autocomplete.

</Accordion>
<Accordion title="Plural message looks wrong or returns the raw template">

Missing `other` (or `=N`), a typo in a branch name, or an unescaped `{` / `'`
makes FormatJS reject the message — `fx.t` then returns the catalog source.
Keep `#` inside plural/ordinal branches only; double apostrophes (`''`).

</Accordion>
</Accordions>

## Learn more

- [fx](/docs/reference/fx) — full `fx` surface including `fx.t` / `fx.locale`
- [Errors](/docs/reference/errors) — localized failure messages and OKE codes
- [Configuration](/docs/reference/configuration) — `i18n` block next to drivers
- [Channel](/docs/elements/channel) — `{{field}}` templates and locale chain
- [Flow](/docs/elements/flow) — envelope shape with optional `error.message`

## Next

<Cards>
  <Card
    title="Errors"
    description="OKE codes, denials, and localized messages."
    href="/docs/reference/errors"
  />
  <Card
    title="Channel"
    description="Human reach — templates, consent, locale chain."
    href="/docs/elements/channel"
  />
  <Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
</Cards>


# Reference (/docs/reference)

Dense tables and command lists. Reach for these when you already know what you are looking for.

## Pages

<Cards>
  <Card title="CLI" description="oke and create-oke commands." href="/docs/reference/cli" />
  <Card
    title="Configuration"
    description="Every option in oke.config.ts."
    href="/docs/reference/configuration"
  />
  <Card
    title="Environment Variables"
    description="Every variable OKE reads."
    href="/docs/reference/environment-variables"
  />
  <Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
  <Card title="Errors" description="OKE codes, denials, fixes." href="/docs/reference/errors" />
  <Card
    title="Security"
    description="Host, Origin, planes, MCP posture."
    href="/docs/reference/security"
  />
  <Card
    title="i18n"
    description="ICU catalogs, fx.t, typed keys, locale matching."
    href="/docs/reference/i18n"
  />
  <Card
    title="Plugins"
    description="Plugin API — hooks, schemas, identity."
    href="/docs/reference/plugins"
  />
  <Card title="OKID" description="Native id generator." href="/docs/reference/okid" />
</Cards>


# OKID (/docs/reference/okid)

Using `okengine/okid` gives you an id for any primary key, request trace, or event that is short (`okid()` is 21 characters), URL-safe, and random from a cryptographic source. Turn to it when a plain UUID string is more than you need; your app already generates them wherever `defaultFn(id)` is used.

<Callout title="The one rule">
  Use OKID for identity, never for secrets. An id is enumerable by design, so anything you hand to
  an untrusted client must be a token from the Vault, not an OKID.
</Callout>

## Quick start

<Steps>

<Step>
### Install nothing — it is exported by the package

```typescript
import { okid } from "okengine/okid";
```

</Step>

<Step>
### Generate an id

```typescript
const userId = okid();
const requestId = okid(16);
const typedId = okid({ prefix: "usr_" });
const eventKey = okid({ sortable: true });
const inviteCode = okid({ lookAlikes: false, uppercase: false });
```

</Step>

<Step>
### Store it anywhere a string fits

```typescript
// field.id() is shorthand for "default generation id" — currently OK ID.
field.id().primaryKey();
// Or pin OK ID explicitly:
field.okid().primaryKey();
```

The same 21-character id lands in your SQL primary keys, KV keys, and trace ids.

</Step>

</Steps>

## Reference

| Call                                               | Result                                   | Notes                                   |
| -------------------------------------------------- | ---------------------------------------- | --------------------------------------- |
| `okid()`                                           | 21-char URL-safe id, 126 bits of entropy | 64-char alphabet, `a-zA-Z0-9-_`         |
| `okid(length)`                                     | id of exactly `length` characters        | integer between 8 and 128               |
| `okid({ length })`                                 | options form, body of `length`           | default 21                              |
| `okid({ prefix })`                                 | `prefix` + body                          | body length unchanged; see Options      |
| `okid({ sortable })`                               | time-prefixed body, 8 + `length − 8`     | lexicographic order ≈ creation order    |
| `okid({ numbers, lowercase, uppercase, symbols })` | charset control                          | each group defaults to on               |
| `okid({ lookAlikes })`                             | confusable-char control                  | `lookAlikes: false` drops `1lI0Oouv5Ss` |

### Options

| Option       | Type      | Default | Meaning                                                     |
| ------------ | --------- | ------- | ----------------------------------------------------------- |
| `length`     | `number`  | `21`    | generated body length (8–128); does not include `prefix`    |
| `prefix`     | `string`  | `""`    | fixed label prepended to the body (e.g. `"usr_"`, `"evt_"`) |
| `sortable`   | `boolean` | `false` | prefix the body with an 8-char epoch-ms timestamp           |
| `numbers`    | `boolean` | `true`  | include `0-9`                                               |
| `lowercase`  | `boolean` | `true`  | include `a-z`                                               |
| `uppercase`  | `boolean` | `true`  | include `A-Z`                                               |
| `symbols`    | `boolean` | `true`  | include `-` and `_`                                         |
| `lookAlikes` | `boolean` | `true`  | include confusable chars `1lI0Oouv5Ss`; set `false` to drop |

### Exported constants

| Constant                   | Value                                     | Meaning                          |
| -------------------------- | ----------------------------------------- | -------------------------------- |
| `OKID_ALPHABET`            | `a-zA-Z0-9-_`                             | default, Base64URL order         |
| `OKID_SORTABLE_ALPHABET`   | alphabet sorted by code unit (same chars) | used by the sortable encoder     |
| `OKID_LOOKALIKE_CHARS`     | `1lI0Oouv5Ss`                             | dropped when `lookAlikes: false` |
| `OKID_DEFAULT_LENGTH`      | `21`                                      | default body length              |
| `OKID_MIN_LENGTH`          | `8`                                       | shortest non-sortable body       |
| `OKID_MAX_LENGTH`          | `128`                                     | longest body                     |
| `OKID_SORTABLE_MIN_LENGTH` | `16`                                      | shortest sortable body (8+8)     |
| `OKID_MAX_PREFIX_LENGTH`   | `32`                                      | longest semantic `prefix`        |

## Collision resistance

Every character is drawn uniformly from the alphabet with `crypto.getRandomValues()`. Because it uses an unbiased character selection (never modulo), each character carries exactly `log2(alphabet)` bits of entropy. At the default 21 characters over 64 symbols, that is 126 bits — the birthday-bound collision probability across one billion ids is on the order of `10⁻²¹`. You do not need a UUID for collision resistance; this is where a UUID is stronger only because it is a different format, not a different amount of randomness.

**Consequence:** two ids minted at the same millisecond are still distinct — the timestamp prefix never replaces entropy, it prefixes it.

## Semantic prefixes

`prefix` is a fixed label (`"usr_"`, `"evt_"`, `"inst-"`) prepended to the generated body. Characters must belong to `OKID_ALPHABET` (max 32). `length` stays the body size; the returned string is `prefix + body`.

**Consequence:** `okid({ prefix: "usr_", sortable: true })` yields `usr_` + 8-char timestamp + random tail — the label sorts first, then time.

## Sortable ids

`sortable: true` prepends 48 bits of `Date.now()` encoded in exactly 8 characters, in an alphabet whose sort order matches time order. Sorting a batch of these ids reproduces the creation order across milliseconds.

**Consequence:** a sortable id embeds its creation time (millisecond precision), so keep them out of public, enumerable surfaces. Clock skew distorts order but can never produce a duplicate — the tail stays random.

## Alphabet control

Turning groups off shrinks the alphabet. With a non-power-of-two alphabet, OKID uses rejection sampling instead of modulo, so every remaining character stays equally likely — the output never becomes measurably biased.

**Consequence:** smaller alphabets mean fewer bits per character. `lookAlikes: false` alone drops the default entropy only slightly (126 → ~120 bits); dropping whole groups costs more. Choose the smallest alphabet that fits the human-transcription use case.

## Under the hood

The generator is a pure function: no counters, no process or machine fingerprint, no shared mutable state. It is safe to call concurrently from any number of workers, and every body uses only the bytes it needs — no hidden timestamp unless `sortable` is on.

## Troubleshooting

<Accordions>
<Accordion title="I get an error for an empty alphabet">

Passing `numbers: false, lowercase: false, uppercase: false, symbols: false` at the same time throws a `RangeError` with the message `okid: alphabet is empty — enable at least one character group`. Re-enable at least one group, or don't use the option object and rely on the default alphabet.

</Accordion>
<Accordion title="I get a RangeError for a length">

`okid(0)`, `okid(-1)`, `okid(7)`, `okid(129)`, and non-integer lengths throw a `RangeError`. Sortable ids have a higher floor: passing a sortable length below 16 throws. Keep lengths between 8 and 128 (16–128 for sortable).

</Accordion>
<Accordion title="I get a RangeError for a prefix">

Characters outside `OKID_ALPHABET` (for example `usr:` or a space) throw
`okid: prefix contains invalid character … — use characters from OKID_ALPHABET`.
A prefix longer than 32 throws `okid: prefix length … exceeds max 32` — stick to `A-Za-z0-9-_`.

</Accordion>
<Accordion title="My ids are not sortable by the alphabet order I expected">

The default alphabet order is not lexicographic; `_` sorts between uppercase and lowercase. When `sortable` is on, ids use the code-point-ordered alphabet, so plain string comparison matches time order. Do not customize the alphabet in sortable mode — the option exists exactly because the default order is not trustworthy for ordering.

</Accordion>
</Accordions>

## Learn more

- [Store](/docs/elements/store) — `defaultFn(id)` in table declarations delegates to `okid()`
- [fx](/docs/reference/fx) — `fx.id()` returns an OKID; options live here
- [Clock](/docs/elements/clock) — process `instanceId` (`inst-<okid>`) is an OKID

## Next

<Cards>
  <Card title="fx" description="fx.id() in Flows." href="/docs/reference/fx" />
  <Card title="Store" description="defaultFn(id) on columns." href="/docs/elements/store" />
  <Card title="Client" description="Ids round-trip on typed routes." href="/docs/client" />
</Cards>


# Plugins (/docs/reference/plugins)

A plugin is a definition that receives the app at `.plug()` time, adds capabilities, and returns it **with accumulated types**. Plugins are how OKE's own built-ins are built — auth, the Console, docker derivation, channels — so the extension API you get is the one we use ourselves.

<Callout title="The one rule">
  Public API only. Every built-in feature goes through the same `plugin()` surface you do — if the
  core team ever needs a private hook, the API is treated as broken and gets fixed.
</Callout>

## Quick start

<Steps>

<Step>
### Define the plugin

`plugin(name, { version })` returns a fluent definition. Each method **queues** a contribution — nothing executes yet:

```typescript title="src/plugins/audit.ts"
import { plugin, field, id, now } from "okengine";

export const audit = plugin("audit", { version: "1.0.0" })
  .table("audit_log", {
    id: field.text().primaryKey().defaultFn(id),
    flowId: field.text().notNull(),
    at: field.integer().notNull().defaultFn(now),
  })
  .hook("afterHandle", async (ctx, fxOrErr, fx) => {
    // observe every completed flow
  });
```

</Step>

<Step>
### Plug it into the app

`.plug()` executes the queued registration, records the capability list into the Manifest, and accumulates the plugin's types into the app (decorations become typed on the flow context):

```typescript title="src/app.ts"
import { oke } from "okengine";
import { audit } from "./plugins/audit.ts";

export const app = oke({ name: "shop", env: "dev" }).plug(audit);
```

</Step>

<Step>
### Everything derives as usual

Plugin flows appear in the Manifest, plugin tables land in `schema.drizzle.ts` on the next `oke db push`, plugin panels show up in the Console. No extra wiring — a contribution is ordinary OKE, just authored elsewhere.

</Step>

</Steps>

## What a plugin may contribute

Every method below exists on both the fluent definition and the boot-time builder the registry records:

| Method                           | Contributes                                                                                   |
| -------------------------------- | --------------------------------------------------------------------------------------------- |
| `.flow(def)`                     | An ordinary flow — Manifest / Console metadata (does **not** join the HTTP router alone)      |
| `.binding({ trigger, flow })`    | A real Binding — joins `adopted` + the router on `app.plug()` (auth method plugins)           |
| `.hook(stage, fn)`               | A per-request intercept at one pipeline stage                                                 |
| `.edge(fn)`                      | A handler for HTTP requests that match **no** flow                                            |
| `.decorate(key, value)`          | A typed context decoration, visible to flows                                                  |
| `.element({ kind, name })`       | An opaque element contribution (e.g. `store.sql` facet)                                       |
| `.vault(secret)`                 | A vault secret/config contract — merged into boot secrets                                     |
| `.clock(decl)`                   | A named clock schedule — merged into boot clocks                                              |
| `.signal(decl)`                  | A signal declaration — merged into boot signals                                               |
| `.gate(decl)`                    | A gate declaration — merged into boot gates                                                   |
| `.channelTemplate(decl)`         | A channel template — merged into boot channel templates                                       |
| `.channelCatalog(catalog)`       | Template body catalog entries — merged into boot channel catalog (`{{field}}` interpolation)  |
| `.driver(id, impl)`              | A protocol-named driver for an existing element                                               |
| `.image(role, recipe)`           | An image recipe for a docker role                                                             |
| `.table(name, columns, options)` | A whole DB table, merged into the generated schema (`options.description` / `plane` optional) |
| `.errors(map)`                   | Typed errors flows can fail with                                                              |
| `.client(name, ext)`             | Reserved plugin seam — prefer `createAuthClient` method helpers for auth                      |
| `.consolePanel(panel)`           | A Console panel (ESM entry loaded at runtime)                                                 |
| `.cli(name, handler)`            | An `oke <name>` CLI command                                                                   |
| `.config(schema)`                | A config schema; values live on the plugin identity                                           |
| `.needs(dep)`                    | Runtime dependency — plugin name or element/driver id; unmet → `PluginNeedsError` at boot     |

New infrastructure is a **driver** for an existing element, never a ninth element — plugins follow the same law.

## Hooks run inside the pipeline

A hook intercepts every flow invocation at one stage, in documented order:

| Stage          | Runs                              |
| -------------- | --------------------------------- |
| `onRequest`    | First — request just arrived      |
| `onParse`      | After input parsing               |
| `onAuth`       | After gate resolution             |
| `beforeHandle` | Immediately before the flow body  |
| `afterHandle`  | After a successful body           |
| `onError`      | When the flow fails               |
| `onResponse`   | Last — before the response leaves |

The handler itself is a pipeline slot, not a hook name — you cannot replace a flow's body from a plugin, only observe and short-circuit around it. A hook may return `void`, a `Response` (short-circuit), or a `FlowFailure`.

At `onResponse`, `ctx.response` holds the **final serialized HTTP response** — mutate it in place (rebuild with new headers) to stamp headers on every outcome, including failures. Non-HTTP triggers leave `ctx.response` undefined; middleware hooks must no-op then. The official plugins rely on exactly this contract.

## Edge handlers answer unmatched requests

Hooks only ever see requests a flow owns — an `OPTIONS` preflight for a path bound to `GET` matches nothing and would 405 untouched. `.edge(fn)` closes that gap: handlers run in install order when the router finds no flow, the first returned `Response` answers, and `undefined` passes to the next handler, then **405** if the path exists for other methods, else 404:

```typescript
plugin("cors", { version: "1.0.0" }).edge((request, info) => {
  if (info.method === "OPTIONS" && request.headers.has("origin")) {
    return new Response(null, { status: 204, headers: preflightHeaders(request) });
  }
  return undefined; // not mine — let someone else answer, else 405/404
});
```

There is no flow context on the edge (no flow matched!), so handlers receive only `(request, { method, path })` — no `ctx`, no `fx`. The official CORS plugin's preflight handling is built on exactly this.

## Runtime configuration: code or DB

Plugin options are static by default — changing them means a redeploy. Every official plugin also accepts a `configSource()`, which keeps code as the floor and lets a database row override it live, with a KV binding as the automatic read-through cache:

<Steps>

<Step>
### Declare the source and its sync flow

```typescript title="src/app.ts"
import { clock, oke, on, store } from "okengine";
import { configSource, maintenanceMode } from "okengine/plugins";

const db = store.sql("app");
const cache = store.kv("cache");

const maintenance = configSource({
  plugin: "maintenance-mode",
  code: { enabled: false }, // the floor — always safe
  db: { store: db }, // source of truth (optional)
  kv: cache, // read-through cache (optional)
});

const maintenanceSyncClock = clock.every("maintenance.sync", "30s");
on(maintenanceSyncClock, maintenance.sync()); // one clock flow refreshes the box

export const app = oke({ name: "shop", env: "dev" }).plug(maintenanceMode(maintenance));
```

</Step>

<Step>
### Push the contributed table

A DB-backed source makes the plugin contribute its own config table (`maintenance_mode_config`) — created by the usual `oke db push`, no hand-written DDL:

```bash
oke db push
```

</Step>

<Step>
### Change config without a deploy

Insert or update the single config row; every instance picks it up within one sync interval:

```sql
INSERT INTO maintenance_mode_config ("key", "value")
VALUES ('config', '{"enabled": true, "retryAfter": 300}');
```

</Step>

</Steps>

| Rule                 | Behavior                                                                                                  |
| -------------------- | --------------------------------------------------------------------------------------------------------- |
| Code is the floor    | `current()` always returns at least the `code` config — boot is never blocked on the DB                   |
| Shallow merge        | DB values replace `code` keys one-for-one (no deep merging)                                               |
| KV read-through      | With `kv` set, sync ticks hit the database only after the TTL (default `30s`) expires                     |
| Effects are declared | The sync flow's `effects` cover exactly the stores it touches — least privilege holds                     |
| Fail loud            | A config row that is not valid JSON fails the sync flow — visible in Console runs, never silently ignored |
| Identity             | The plugin identity snapshot is the `code` config — DB edits never trip the conflict guard                |

<Callout title="Why a sync flow, not a hook read?">
  Every store access goes through `fx`, and `fx` is capability-gated per flow — a hook cannot read a
  store the flow did not declare. So the refresh lives in a real flow with declared effects, and
  hooks read the in-memory box synchronously. The fx rule holds with zero exceptions.
</Callout>

## Plugin tables are whole tables

A plugin may declare **its own tables** with `field.*` columns, merged into the generated domain schema at `oke db` time — the CLI loads your live app entry, collects every plugged plugin's contributions, and emits them alongside app tables:

```typescript
plugin("billing", { version: "2.1.0" }).table(
  "invoices",
  { id: field.text().primaryKey().defaultFn(id) },
  { plane: "user", description: "Customer invoices" },
);
```

Extending an existing **app-owned** table with plugin columns is not supported in v1 — contribute a separate table and reference the app's by key. The optional `plane` metadata (`"operator" | "user" | "shared"`) keeps data-plane isolation intact for privacy tooling. Optional `description` is a human title in the Console (falls back to the table name).

## Identity, config, and dependencies

| Concept    | Rule                                                                                         |
| ---------- | -------------------------------------------------------------------------------------------- |
| Name       | Stable plugin id — the Manifest key and conflict namespace                                   |
| `version`  | Semver string recorded in the Manifest                                                       |
| `config`   | Snapshot for identity dedup: same name + same config → no-op re-plug                         |
| Conflict   | Same name + **different** config → loud boot error, never a silent merge                     |
| `.needs()` | Declares runtime dependencies (plugin name or element/driver id); unmet → `PluginNeedsError` |

## Troubleshooting

<Accordions type="single">
  <Accordion title="Boot error: plugin already registered with different config">
    You plugged the same plugin name twice with different `config` snapshots. This is deliberate —
    two configurations of one plugin would silently diverge. Pass identical config, or rename one
    instance.
  </Accordion>
  <Accordion title="My plugin table is missing from schema.drizzle.ts">
    The CLI reads table contributions from the live app entry. Make sure the plugin is actually
    `.plug()`ed in `src/app.ts` (or `db.entry` if overridden), then re-run `oke db push`.
  </Accordion>
  <Accordion title="My hook never runs">
    Hooks are per-request intercepts keyed by stage — check the stage name against the pipeline
    table above, and confirm the flow you expect actually reaches that stage (a gate denial never
    reaches `beforeHandle`).
  </Accordion>
  <Accordion title="Boot: plugin boot failed — unmet .needs() dependencies">
    A plugged plugin declared `.needs("auth")` or `.needs("store.sql")` (or another token) and
    nothing satisfied it. For `"auth"`, enable `oke({ gate: { auth } })`. Otherwise plug the peer
    plugin, or ensure the element/driver is available (tables imply `store.sql`, and so on).
  </Accordion>
</Accordions>

## Learn more

- [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [OTP](/docs/plugins/otp) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
- [Flow](/docs/elements/flow) — what plugin flows and hooks plug into
- [Store](/docs/elements/store) — `field.*` builders and schema sync
- [Configuration](/docs/reference/configuration) — where plugin config is declared

## Next

<Cards>
  <Card
    title="Headers"
    description="The full secure-headers set on every response."
    href="/docs/plugins/headers"
  />
  <Card title="CORS" description="Cross-origin rules at the edge." href="/docs/plugins/cors" />
  <Card title="Flow" description="Triggers, effects, and durability." href="/docs/elements/flow" />
</Cards>


# Security (/docs/reference/security)

Every served request on the backend, Console, and app MCP passes Host / Origin checks
before your Flow runs. Private bind addresses are not a substitute for that check.

Reach for this page when you put a public hostname in front of the app, open Console
in production, or wire MCP tokens.

<Callout title="The one rule">
  Pass every public hostname in `allowedHosts` on `createBunRuntime().serve` (and the Console / MCP
  serve options). Loopback is always allowed; `Origin: null` is always rejected. Failures return
  **403**.
</Callout>

## Smallest Example

<Steps>

<Step>
### Serve with an explicit host allow-list

```typescript title="src/app.ts"
import { createBunRuntime } from "okengine/http";
import { app } from "./app";

createBunRuntime().serve(app, {
  port: Number(process.env.PORT ?? 6530),
  hostname: "0.0.0.0",
  allowedHosts: ["app.example.com", ".example.com"],
});
```

`.example.com` allows `a.example.com` (Vite-style suffix). Always merged with
`localhost` · `127.0.0.1` · `::1` and the listen hostname when it is not a
wildcard bind.

</Step>

<Step>
### Confirm a bad Host is refused

```bash
curl -i http://127.0.0.1:6530/health -H "Host: evil.example"
```

Expect `403` with body `Forbidden: unexpected Host header`.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Reverse proxy", "Planes", "MCP"]}>

<Tab value="Reverse proxy">

Edge terminates TLS and forwards to the app. The `Host` the app sees must be on
the allow-list — usually the public name, not the container hostname.

```typescript
createBunRuntime().serve(app, {
  hostname: "0.0.0.0",
  allowedHosts: ["app.example.com"],
});
```

**Consequence:** omitting the public name behind Caddy / nginx / Traefik looks
like a random 403 to browsers that send the site's `Host`.

</Tab>

<Tab value="Planes">

Only two planes exist: **user** (application Flows, `fx.auth`) and **operator**
(Console / MCP capability, `fx.operator`). Declare `plane: "operator"` on Flows
that must stay off the public API.

A user-plane token on an operator Flow (or the reverse) throws `CrossPlaneError`.
API keys and Console `invoke-as` are attenuated — a derived principal never
exceeds the creator's scopes.

</Tab>

<Tab value="MCP">

| Surface  | Port     | Auth                                                      |
| -------- | -------- | --------------------------------------------------------- |
| App MCP  | **6535** | Bearer required **even on localhost**; audience `oke-mcp` |
| Docs MCP | **6536** | Host / Origin only — public docs, not a live Manifest     |

App MCP inherits **operator-plane** capability and never exceeds it. Tokens are
never forwarded upstream. Tools declare scopes; writes need confirmation.

</Tab>

</Tabs>

## Host / Origin Rules

| Check    | Behavior                                                             |
| -------- | -------------------------------------------------------------------- |
| `Host`   | Required; must match the effective allow-list                        |
| `Origin` | When present, host must match; `Origin: null` → 403                  |
| Defaults | Always includes loopback + listen hostname (unless `0.0.0.0` / `::`) |
| Extras   | `ServeOptions.allowedHosts` — never replaces the mandatory check     |

`allowedHosts` lives on **serve options**, not in `oke.config.ts`. Pass it wherever
you call `serve` for **6530**, Console **6533**, and app MCP **6535**.

## Console Posture

| Control       | Meaning                                                                    |
| ------------- | -------------------------------------------------------------------------- |
| Host / Origin | Same allow-list logic as the backend                                       |
| CSP           | `default-src 'self'`; `frame-ancestors 'none'`                             |
| Cookies       | `SameSite=Strict`                                                          |
| Claim code    | Printed on `oke dev` TTY; `oke console claim-code` while setup is open     |
| PII           | Store / Call API mask classified fields unless `revealPii: true` (audited) |

Production Console: set `OKE_CONSOLE_SECRET` and configure
`console.prod` in [Configuration](/docs/reference/configuration#console).

## Sessions and Audiences

| Audience      | Surface              |
| ------------- | -------------------- |
| `oke-app`     | Application sessions |
| `oke-console` | Console operators    |
| `oke-mcp`     | App MCP              |

Short access JWT (default ~14m) + rotating refresh. Refresh-token reuse revokes
the family.

## Troubleshooting

<Accordions>

<Accordion title="403 Forbidden: unexpected Host header">
  The request `Host` is not on the allow-list. Add the public hostname to `allowedHosts`, or hit
  loopback (`localhost` / `127.0.0.1`) during local `oke dev`.
</Accordion>

<Accordion title="403 Forbidden: unexpected Origin header">
  Browser sent an `Origin` whose host is not allowed, or `Origin: null`. Align the page origin with
  `allowedHosts`, or call same-origin / non-browser clients without a spoofed Origin.
</Accordion>

<Accordion title="MCP tools refuse without Bearer on localhost">
  App MCP on **6535** requires a Bearer with audience `oke-mcp` even on loopback. Docs MCP on
  **6536** does not — it serves documentation only.
</Accordion>

<Accordion title="CrossPlaneError">
  A user-plane token reached an operator Flow (or the reverse). Use the principal that matches the
  Flow's `plane`.
</Accordion>

<Accordion title="AttenuationError">
  A derived key or invoke-as identity requested a scope the creator cannot grant. Re-issue with a
  subset of the creator's scopes.
</Accordion>

</Accordions>

## Learn more

- [CLI](/docs/reference/cli) — `oke dev`, `oke console claim-code`
- [Gate](/docs/elements/gate) — policies, API keys, tenancy
- [The Architecture](/docs/understand/the-architecture) — one shape, one door, fixed vocabulary
- [Errors](/docs/reference/errors) — `CrossPlaneError`, `SessionError`, `AttenuationError`
- [Environment Variables](/docs/reference/environment-variables) — `OKE_CONSOLE_SECRET`

## Next

<Cards>
  <Card
    title="Gate"
    description="Auth, authorization, and rate limits."
    href="/docs/elements/gate"
  />
  <Card
    title="CLI"
    description="oke and create-oke command catalogue."
    href="/docs/reference/cli"
  />
  <Card
    title="The Model"
    description="One shape for every trigger, one door for every effect."
    href="/docs/understand/the-architecture"
  />
</Cards>


# The Architecture (/docs/understand/the-architecture)

Four lines ship the signup flow. Eight months later it is six files that disagree about retries, audits, and who is allowed to do what.

This page shows that drift, the one rule that stops it, and the exact anatomy behind every Flow — in one sitting.

<Callout title="The Law">
  Every backend behavior is a Flow: `on(Trigger) → Effects`. One species; triggers are typed values.
  All world access goes through `fx`.
</Callout>

## The problem: three features, same wall

**A signup flow.** A user registers. Send them a welcome email:

```typescript
app.post("/signup", async (req, res) => {
  const user = await db.users.create(req.body);
  await sendMail(user.email, "Welcome!", welcomeTemplate(user));
  res.json(user);
});
```

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.

**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.

**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.

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.**

### Follow one all the way through

Two weeks later, a launch drives a traffic spike, the mail provider starts returning `429`, and signups start failing because an unrelated email is slow. You move the send off the request path:

```typescript
app.post("/signup", async (req, res) => {
  const user = await db.users.create(req.body);
  emailQueue.add("welcome", { userId: user.id });
  res.json(user);
});
```

The endpoint is fast again. It's also no longer one system — it's an endpoint, a queue, a worker, and a Redis connection nobody else on the team knew existed until a missing `REDIS_URL` broke staging.


> Evolution of a signup feature from 1 route file into 6 disparate subsystem files over 8 months in production.


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.

None of these were mistakes. Each one was the correct call, made by a competent engineer, in direct response to something that actually happened.

### What's actually going on

Look at what the six resulting files have in common: none of them agree with each other about the same three things. What counts as "done." What happens on failure. Who's allowed to do this at all.

That's the real cost — not the number of tools, but what sits between them:

- **Failure means something different in each one.** A queue retry, an HTTP 500, and a rejected promise from a mail SDK are three unrelated shapes that all happen to mean "this didn't work."
- **Permission has no fixed address.** It lives wherever whoever wrote that file remembered to put it — which means a reviewer can't point at one place and ask "is this checked?"
- **Two systems both think they own the same fact.** The database says an order is paid. The already-running webhook handler doesn't know that yet. Nothing keeps them honest with each other in the gap.
- **Nobody can see the whole thing at once.** There is no file, diagram, or dashboard where "the signup flow" exists as one object — only as the sum of files that happen to call each other.

What would have to be true on day one for month eight to never happen?

## The model: one rule, in two parts

Every seam above came from the same root cause: each system involved had its own idea of when it should run and what it was allowed to touch, and nothing forced those ideas to agree with each other.

OKE removes the disagreement by removing the choice. It's one rule, in two parts.

**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.

**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.

<FlowShape />

That's the whole model. Not a bigger toolbox — a smaller number of things that are allowed to happen at all.

### What OKE is — and isn't

OKE is a backend programming model: behavior is expressed as Flows, effects are captured through `fx`, and the compiler turns that model into a versioned Manifest that powers the rest of the backend.

| OKE is not …                                  | OKE is …                                                                |
| --------------------------------------------- | ----------------------------------------------------------------------- |
| Another queue, ORM, or mailer to wire up      | One Flow species every trigger wakes up                                 |
| A toolbox of forty clients with forty configs | Eight elements with irreducible physics — nothing else gets added       |
| A platform you deploy into                    | TypeScript you host; Client, Console, and MCP derive from your Manifest |

One shape for every trigger, one door for every effect, a fixed vocabulary for what the door allows — that's what this project built. It's called **OKE**.

<sub>
  *OKE isn't an acronym for anything in the code. It comes from Omq Khafi — the organization this
  engine grew out of — with "Engine" appended: **O**mq **K**hafi **E**ngine.*
</sub>

## The vocabulary: eight elements, closed set

The door from the last section isn't open-ended — it recognizes a fixed set of things it's willing to do. Each one made the cut because it has _irreducible physics_: behavior that breaks if you tried to fake it using one of the others.

<Features />

| Element     | What it is           | What it replaces                                                        |
| ----------- | -------------------- | ----------------------------------------------------------------------- |
| **Flow**    | Execution & behavior | endpoint, handler, consumer, job, workflow, webhook                     |
| **Signal**  | Data in motion       | queue, pub/sub, stream, websocket, SSE, event bus                       |
| **Store**   | Data at rest         | relational database, cache, key-value store, file storage, search index |
| **Clock**   | Time & schedules     | cron, every, delay, durable sleep, timeout                              |
| **Gate**    | Permission to act    | auth, session, tenancy, RBAC, scope, rate limit, public                 |
| **Vault**   | Protected knowledge  | secrets, encryption keys, rotation, environment variables               |
| **Channel** | Reaching a human     | transactional email, SMS, push notifications, receipts                  |
| **AI**      | Machine intelligence | model calls, structured prompts, embeddings, agents, RAG                |

Read the right column as the honest answer to "what would I have reached for before this?" Every item in it is a separate tool with its own configuration, its own failure modes, and its own place to go wrong.

The left column is the same ground, covered by something with one shared door and one shared set of guarantees. Where that distinction is real, it gets a name. Where it isn't, it doesn't — which is why the list stops at eight instead of growing indefinitely.

## The anatomy: five pieces behind every Flow

Everything a Flow does reduces to one line:

```typescript
on(
  trigger,
  flow({
    do: (input, fx) => {
      /* ... */
    },
  }),
);
```

If that line doesn't mean much yet, that's what this section is for. Five pieces make it up. We'll take them one at a time, then put them together using a complete signup example.

### `on(...)` — wires a trigger to a flow

`on` does exactly one thing: it connects "something that can happen" to "code that should run when it does." Nothing executes until this connection exists.

```typescript
on(someTrigger, someFlow);
```

That's the whole job. The interesting parts are what goes in each slot.

### A trigger — the answer to "when"

The first argument to `on` is the trigger: whatever wakes the code up. A trigger doesn't run any of your logic — it only answers one question: _when should this happen?_

```typescript
http.post(); // path from the file tree — e.g. src/flows/users/signup.ts → POST /users/signup
```

There are five kinds of trigger in total — the table at the end of this page lists them. For now: the trigger is the _when_, and it's the only thing that changes between an endpoint, a scheduled job, and everything else.


> Six triggers — HTTP, signal, interval, row change, fx.call, and an MCP tool — all binding to the same Flow species.


### `flow(...)` — the actual unit of work

The second argument to `on` is a Flow — declared with the `flow()` function. It answers _what_: what work is this, and what does it promise about its inputs and outputs?

```typescript
flow({
  do: /* the actual code — next */,
});
```

Omit the name on tree files — the compiler stamps `unit.export` (e.g. `users.signup`).
Pass `flow("users.signup", { … })` outside a unit folder, or for barrels / `fx.call`.
Nameless Signal / Clock consumers outside a unit fail **OKE1072**.

### `do` — the code that actually runs

`do` is a function you write. It answers _how_. It receives two things: `input` (your data) and `fx` (next). Everything your Flow actually does lives here.

```typescript
do: async (input, fx) => {
  return { ok: true };
};
```

### `fx` — the only door to the outside world

`fx` is the second argument to `do`, and it's the piece the other four exist to protect. The rule is simple and absolute: **your Flow is not allowed to read a database, send an email, check a clock, or touch anything outside itself except through `fx`.**

```typescript
do: async (input, fx) => {
  const user = await fx.store(db).insert(users).values(input); // the database, through fx
  await fx.send(welcomeEmail, { to: user.email }); // another system, through fx
  return user;
};
```

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.

### Putting the five pieces together

Here is the complete signup flow, with every piece labeled where it sits:

```typescript title="src/flows/users/signup.ts"
export const signup = on(
  http.post(), // ← trigger: when (stamped POST /users/signup)
  flow({
    // ↑ flow: what (stamped users.signup)
    do: async (input, fx) => {
      // ← do: how
      const user = await fx.store(db).insert(users).values(input); // ← fx: the only way out
      await fx.send(welcomeEmail, { to: user.email, data: { name: user.name } });
      return user;
    },
  }),
);
```

<Callout title="Call-only flows">
  A `flow(...)` declared without `on(...)` around it is internal — nothing outside your code can
  start it. Other flows invoke it directly with `fx.call(flowRef, input)`.
</Callout>

### Checking it against the timeline

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:

| Then: a new system per incident                                      | Now: one door, fixed answer                                             |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| **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     |
| **Month 2 silence** — a retry count in a worker nobody remembers     | Retries are a property of the `fx` boundary every effect passes through |
| **Month 4 audit** — a `sent_emails` table written by hand from a job | What was sent is already known — nothing sends outside `fx`             |
| **Month 6 double-submit** — dedup split across a queue and a DB      | One call through one door — no second path for a duplicate              |

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.

### Five kinds of trigger

`flow`, `do`, and `fx` never change shape. Only the trigger does — and there are exactly five kinds, one per element that can independently wake a Flow up:

| Trigger                                             | Element | Starts When                      |
| --------------------------------------------------- | ------- | -------------------------------- |
| `http.post()` (path from file tree)                 | Flow    | A request arrives                |
| `clock.every("name", "10m")`                        | Clock   | A time interval elapses          |
| `signal.once("name", {…})` / `.broadcast` / `.live` | Signal  | Another flow announces something |
| `db.table(users).changed("email")`                  | Store   | A database row changes           |
| `mcp.tool("name")`                                  | AI      | An AI agent calls it             |

The Elements section walks through each element in depth — this is just enough to recognize them when you see them.

## Where this goes next

You now hold the whole model: the drift, the rule, the eight elements, the five-piece anatomy. Don't read more — run it. From an empty folder to this exact signup Flow answering a real request, in one sitting:

<Cards>
  <Card
    title="Try It"
    description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
    href="/docs/understand/try-it"
  />
  <Card
    title="Elements"
    description="When you're back: Flow, Signal, Store, Clock, Gate, Vault, Channel, AI — each in depth."
    href="/docs/elements"
  />
</Cards>


# Try It (/docs/understand/try-it)

## What you need

Bun ≥ 1.4.2, and Docker running. `oke dev` starts everything else — Postgres, Redis, mail — for you.

```bash
bun --version
docker info
```

## Scaffold and run

```bash
bunx create-oke@latest my-app
cd my-app
bun run dev
```

Open the Console at the address printed in the terminal and claim it with the code shown there. The Flows listed weren't configured anywhere — they were derived from the code you just scaffolded.

## Call it

The starter ships a health Flow. Call it from a typed client, the same way your frontend would:

```typescript
import { createClient } from "okengine/client";
import type { App } from "./app";

const api = createClient<App>("http://localhost:6530");
const { data, error } = await api.main.health({});
```

`data` and `error` are inferred straight from the Flow you just ran — not from a separate schema you maintain by hand.

## Prove it, don't just believe it

```typescript
test("boots — health flow", async () => {
  const t = await createTestApp(app);
  const { data } = await t.api.main.health({});
  expect(data).toEqual({ ok: true });
});
```

```bash
bun test
```

No mocked HTTP server, no real clock, no live mail provider. `createTestApp` swaps every driver behind `fx` for a deterministic one — because effects were never allowed to happen any other way.

## Where this goes next

If this went smoothly, the next page is the honest one: what's solid today, what's still moving, and who this is actually ready for right now.


# Anonymous (/docs/plugins/anonymous)

`anonymous()` creates a throwaway principal: one public Flow returns hybrid session tokens for a
new random `userId`. Use it for guest carts or try-before-account flows.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(anonymous())`. Treat the session like any other Bearer principal —
  gates still decide what it may do.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { anonymous } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(anonymous());
```

</Step>

<Step>
### Sign in anonymously

```typescript
const { data } = await api.auth.signInAnonymous();
// data.userId is a fresh OKID; store tokens like any other session
```

`POST /auth/sign-in/anonymous` — no body.

</Step>

<Step>
### Gate what guests can do

Attach real policies to guest-capable Flows (`gate.scope`, custom policies). Anonymous only
issues a session — it does not grant scopes.

</Step>

</Steps>

## Options

| Option        | Type           | Default    | Meaning                                                     |
| ------------- | -------------- | ---------- | ----------------------------------------------------------- |
| `secret`      | `string`       | active\*   | HMAC secret (\*from `gate.auth` when plugged after `oke()`) |
| `sessions`    | `SessionStore` | active\*   | Session store shared with Gate auth                         |
| `now`         | `() => number` | `Date.now` | Injectable clock                                            |
| `emailDomain` | `string`       | —          | Reserved; unused in v1                                      |

## Surfaces

| Flow                   | Path                           | Gate                         |
| ---------------------- | ------------------------------ | ---------------------------- |
| `auth.signInAnonymous` | `POST /auth/sign-in/anonymous` | `gate.public` + sign-in rate |

## Troubleshooting

<Accordions>
<Accordion title="plugin boot failed — needs &quot;auth&quot;">

Set `oke({ gate: { auth: { … } } })` before `.plug(anonymous())`.

</Accordion>
</Accordions>

## Learn more

- [Gate](/docs/elements/gate) — policies on the new principal
- [Username](/docs/plugins/username) — upgrade path to a real credential
- [Plugins](/docs/plugins) — all auth method plugins

## Next

<Cards>
  <Card title="Username" description="Username + password." href="/docs/plugins/username" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
</Cards>


# Apple (/docs/plugins/apple)

Sign in with Apple is OIDC with three twists: the web flow **posts** its
response, every exchange needs an ES256 client-secret **JWT you sign**, and
`email_verified` can arrive as the _string_ `"false"`.

<Callout title="The one rule">
  Create a Sign in with Apple key (Team ID, Key ID, `.p8` private key), seed the key in Vault, and
  register your exact callback URI — Apple validates all three on every exchange.
</Callout>

## Quick start

<Steps>

<Step>
### Create a key

In the Apple Developer portal: Identifiers → register an App ID with _Sign In
with Apple_; Keys → create a key with that capability; note the **Team ID**
and **Key ID**, and download the `.p8` once.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      apple: {
        enabled: true,
        teamId: "ABCDE12345",
        keyId: "XYZ6789012",
      },
    },
  }),
);
```

</Step>

<Step>
### Seed the private key

```text
# .env.local
OAUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEH...
-----END PRIVATE KEY-----"
```

The driver mints a fresh ES256 client-secret JWT per exchange (`iss` = team,
`kid` = key, one-hour life) and discards nothing to disk.

</Step>

</Steps>

## How identity works

| Aspect      | Behavior                                                                  |
| ----------- | ------------------------------------------------------------------------- |
| Callback    | Apple posts `code` + `state` as a form body — both GET and POST are bound |
| Signature   | ES256 against `appleid.apple.com` JWKS                                    |
| Issuer      | must equal `https://appleid.apple.com`                                    |
| Name        | delivered only on first authorization via the form-posted `user` field    |
| Email trust | strict parse — only boolean `true`, `"true"`, or `"1"` count              |

**Consequence:** the string `"false"` stays unverified. Naive truthiness would
mark every private-relay email verified — that is the takeover bug class this
parse exists to close.

Default scopes: `name`, `email`.

## Options

| Option                | Type       | Default            | Meaning                                 |
| --------------------- | ---------- | ------------------ | --------------------------------------- |
| `enabled`             | `boolean`  | `false`            | Turn the provider on                    |
| `clientId`            | `string`   | Vault/env\*        | `\*OAUTH_APPLE_CLIENT_ID` (Services ID) |
| `teamId`              | `string`   | required           | Apple Developer Team ID                 |
| `keyId`               | `string`   | required           | Private-key identifier                  |
| `redirectUri`         | `string`   | `{baseUrl}…/apple` | Exact registered URI                    |
| `scopes`              | `string[]` | `name email`       | Requested scopes                        |
| `storeProviderTokens` | `boolean`  | `false`            | Keep tokens in Vault                    |

## Surfaces

| Flow     | Path                                  |
| -------- | ------------------------------------- |
| Start    | `POST /auth/oauth/apple/start`        |
| Callback | `GET+POST /auth/oauth/callback/apple` |
| Link     | `POST /auth/oauth/apple/link`         |

## Troubleshooting

<Accordions>
<Accordion title="invalid_client at token exchange">

Team ID, Key ID, or the `.p8` does not match the App ID / Services ID you are
signing for. The JWT is minted fresh per exchange, so fixing the inputs is
enough — no restart cache to clear.

</Accordion>
<Accordion title="invalid_request mentioning response_mode">

Your Services ID must allow the callback you registered. Check the return URLs
on the Sign in with Apple key configuration.

</Accordion>
<Accordion title="Callback never fires">

Browsers post to `/auth/oauth/callback/apple`; make sure proxies do not strip
form bodies. The route accepts POST with
`application/x-www-form-urlencoded`.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [Vault](/docs/elements/vault) — seeding `OAUTH_APPLE_PRIVATE_KEY`
- [Gate](/docs/elements/gate) — `gate.auth`

## Next

<Cards>
  <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
  <Card
    title="Microsoft"
    description="Entra tenants and issuer templates."
    href="/docs/plugins/microsoft"
  />
  <Card title="X" description="PKCE public client, never-verified emails." href="/docs/plugins/x" />
</Cards>


# Compression (/docs/plugins/compression)

`compression()` gzips HTTP response bodies with the native `Bun.gzipSync` binding when the client advertises `Accept-Encoding: gzip`. `Bun.serve` never compresses on its own — without this plugin every byte goes over the wire raw, no matter how large the JSON.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { compression } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(compression());
```

A client sending `Accept-Encoding: gzip` now receives `Content-Encoding: gzip` with a `Vary: Accept-Encoding` marker; a client that does not ask gets the untouched body.

## Options

| Option    | Type     | Default                                      | Does                                                                    |
| --------- | -------- | -------------------------------------------- | ----------------------------------------------------------------------- |
| `minSize` | `number` | `1024`                                       | Bodies smaller than this pass through raw (gzip can grow tiny payloads) |
| `match`   | `RegExp` | JSON · `+json` · javascript · xml · `text/*` | Which `Content-Type`s are compressible                                  |

```typescript
.plug(compression({ minSize: 0 })) // compress even tiny bodies (tests, debugging)
```

## Notes

| Behavior          | Detail                                                                 |
| ----------------- | ---------------------------------------------------------------------- |
| Negotiation       | Runs only when `Accept-Encoding` allows gzip — `gzip;q=0` is respected |
| Already encoded   | Skips responses that already carry `Content-Encoding`                  |
| `no-transform`    | Skips responses whose `Cache-Control` forbids transformation           |
| Content-Length    | Deleted after compression — stale lengths would corrupt the response   |
| Non-HTTP triggers | No-op — nothing to compress outside HTTP                               |

## Runtime configuration

Thresholds and matchers can follow the database like every other official plugin — pass a `configSource()` as the options. See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Next

<Cards>
  <Card
    title="Headers"
    description="The full secure-headers set on every response."
    href="/docs/plugins/headers"
  />
  <Card
    title="IP Allowlist"
    description="Allow/deny rules by client IP."
    href="/docs/plugins/ip-allowlist"
  />
  <Card title="Plugin API" description="Build your own plugin." href="/docs/reference/plugins" />
</Cards>


# CORS (/docs/plugins/cors)

`cors()` decides which websites may call your app from a browser. It answers preflight `OPTIONS` requests itself — even for paths bound to other methods, which would otherwise 405 or 404 before any middleware could run — and stamps `Access-Control-*` headers on matched responses.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { cors } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(
  cors({ origin: "https://app.example.com" }),
);
```

Browsers on `https://app.example.com` can now call every flow; every other origin gets a quiet `204` on preflight with **no** CORS headers, so the browser blocks it. Same-origin traffic never needs this plugin — browsers only enforce CORS across origins.

<Callout title="Closed by default">
  `cors()` with no `origin` opens nothing. Cross-origin access is a deliberate decision — pass
  `"*"`, one origin, or an exact-match list when you mean it.
</Callout>

## Options

| Option           | Type                          | Default                                                    | Does                                                                      |
| ---------------- | ----------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| `origin`         | `"*"` · `string` · `string[]` | none (closed)                                              | Origins allowed cross-origin; lists are exact matches                     |
| `methods`        | `string[]`                    | GET · HEAD · POST · PUT · PATCH · DELETE · OPTIONS · QUERY | Methods answered on preflight                                             |
| `allowedHeaders` | `string[]`                    | reflect the request's `Access-Control-Request-Headers`     | `Access-Control-Allow-Headers` on preflight                               |
| `exposedHeaders` | `string[]`                    | omit                                                       | `Access-Control-Expose-Headers` on actual responses                       |
| `credentials`    | `boolean`                     | `false`                                                    | Send `Access-Control-Allow-Credentials`; requires an explicit origin list |
| `maxAge`         | `number`                      | omit                                                       | `Access-Control-Max-Age` seconds on preflight                             |

```typescript
.plug(cors({
  origin: ["https://app.example.com", "https://admin.example.com"],
  credentials: true,
  maxAge: 600,
}))
```

<Callout type="error">
  `cors({ origin: "*", credentials: true })` throws at construction. Browsers reject that literal
  pair; reflecting the request origin would grant **any** site credentialed access. List exact
  origins for cookies/`Authorization` — no any-origin + credentials shortcut.
</Callout>

## Notes

| Behavior          | Detail                                                                                     |
| ----------------- | ------------------------------------------------------------------------------------------ |
| Preflight         | Answered by the plugin's **edge handler** — runs even when no flow matches the path/method |
| Denied preflight  | `204` with no CORS headers — the correct, quiet failure; the browser blocks it             |
| Credentials + `*` | Construction throws — enumerate origins; never reflect `*` into credentialed access        |
| `Vary`            | `Origin` (plus request-method/headers on preflight) is appended, never duplicated          |
| Non-HTTP triggers | No-op                                                                                      |

## Runtime configuration

Origin lists belong to the class of config you want to change without a redeploy — an emergency integration, a partner cutover. Pass a `configSource()` instead of static options and the origin rule follows the database:

```typescript
const origins = configSource({
  plugin: "cors",
  code: { origin: "https://app.example.com" },
  db: { store: db },
  kv: cache,
});
const corsSyncClock = clock.every("cors.sync", "30s");
on(corsSyncClock, origins.sync());
export const app = oke({ name: "shop", env: "dev" }).plug(cors(origins));
```

See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Next

<Cards>
  <Card
    title="CSRF"
    description="Block cross-site state changes with fetch metadata."
    href="/docs/plugins/csrf"
  />
  <Card
    title="Headers"
    description="The full secure-headers set on every response."
    href="/docs/plugins/headers"
  />
  <Card
    title="Plugin API"
    description="Edge handlers and runtime configuration."
    href="/docs/reference/plugins"
  />
</Cards>


# CSRF (/docs/plugins/csrf)

`csrf()` blocks browsers on other sites from mutating your state. It runs at `onAuth` — before gate policies and the flow body — using the fetch-metadata headers every modern browser sends, with an `Origin` check as the fallback. No tokens to mint, no cookies to double-submit, no session reads: the defense is stateless.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { csrf } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(csrf());
```

`GET`/`HEAD`/`OPTIONS`/`QUERY` always pass. A `POST` with `Sec-Fetch-Site: cross-site` now gets the gate element's typed `Forbidden` — unless its `Origin` is yours or allow-listed.

When `gate.auth.cookies.enabled`, the server **soft-requires** this plugin: prod boots refuse without it; dev/test warn. Use `allowNoHeader: false` for cookie-only SPAs.

## How it decides

<Steps>

<Step>
### Fetch metadata first

`Sec-Fetch-Site: same-origin` and `none` pass. `same-site` passes by default (your subdomains) — set `allowSameSite: false` when subdomains host untrusted content. `cross-site` falls through to the Origin check.

</Step>

<Step>
### Origin fallback

With `cross-site` (or no metadata at all — older browsers), the `Origin` header must be the app's own origin or an entry in `allowOrigins`; anything else is `Forbidden`.

</Step>

<Step>
### Headerless clients

Requests carrying neither header — curl, server-to-server calls, webhooks — are not browser CSRF vectors, so they pass by default. Set `allowNoHeader: false` to fail closed when your auth is purely cookie-based.

</Step>

</Steps>

## Options

| Option          | Type       | Default | Does                                                                           |
| --------------- | ---------- | ------- | ------------------------------------------------------------------------------ |
| `allowOrigins`  | `string[]` | none    | Absolute origins allowed to mutate cross-site (e.g. a separately-hosted admin) |
| `allowSameSite` | `boolean`  | `true`  | Allow `Sec-Fetch-Site: same-site` (your subdomains)                            |
| `allowNoHeader` | `boolean`  | `true`  | Allow mutating requests with neither metadata nor `Origin`                     |

## Notes

| Behavior          | Detail                                                                                                     |
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
| Denial shape      | The gate element's typed `Forbidden` (`error.data.reason: "csrf"`) — same as any gate denial               |
| Legacy browsers   | No fetch metadata → the `Origin` check carries the defense                                                 |
| Token patterns    | Double-submit tokens can layer on later for defense-in-depth; fetch metadata alone covers current browsers |
| Non-HTTP triggers | No-op — clock and signal flows are never browser-driven                                                    |

## Runtime configuration

Allow-listed origins are exactly the config you want to change live. Pass a `configSource()` and the rules follow the database within one sync interval:

```typescript
const rules = configSource({
  plugin: "csrf",
  code: { allowOrigins: ["https://admin.example.com"] },
  db: { store: db },
  kv: cache,
});
const csrfSyncClock = clock.every("csrf.sync", "30s");
on(csrfSyncClock, rules.sync());
export const app = oke({ name: "shop", env: "dev" }).plug(csrf(rules));
```

See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Next

<Cards>
  <Card title="CORS" description="Cross-origin rules at the edge." href="/docs/plugins/cors" />
  <Card
    title="Gate"
    description="The element behind the typed denials."
    href="/docs/elements/gate"
  />
  <Card
    title="Headers"
    description="The full secure-headers set on every response."
    href="/docs/plugins/headers"
  />
</Cards>


# Discord (/docs/plugins/discord)

Discord is OAuth2 with one quirk: `/users/@me` `email` can be **null**
(phone-only accounts). `oauth()` signs those people in without an email
instead of failing them.

<Callout title="The one rule">
  Keep the `email` scope in the request (it is on by default). Without it Discord never reports
  `verified: true`, and unverified emails cannot claim existing accounts.
</Callout>

## Quick start

<Steps>

<Step>
### Create an application

Discord Developer Portal → Applications → **New Application** → OAuth2. Add a
redirect under OAuth2 → Redirects:
`https://app.example.com/auth/oauth/callback/discord`.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      discord: { enabled: true },
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_DISCORD_CLIENT_SECRET=...
```

</Step>

</Steps>

## How identity works

| Aspect      | Behavior                                                                            |
| ----------- | ----------------------------------------------------------------------------------- |
| Profile     | `GET https://discord.com/api/users/@me` (string `id` is the subject)                |
| Email       | taken as-is when present; `null` flows through as _no email_                        |
| Email trust | `verified: true` only; the flag silently going missing keeps the address unverified |
| Name        | `global_name`, falling back to `username`                                           |

**Consequence:** an integration bug that drops the `verified` field degrades
to unverified — never to falsely verified. That direction of failure is what
keeps account takeover off the table.

Default scopes: `identify`, `email`. The authorize URL always carries
`prompt=consent`.

## Options

| Option                | Type       | Default              | Meaning                     |
| --------------------- | ---------- | -------------------- | --------------------------- |
| `enabled`             | `boolean`  | `false`              | Turn the provider on        |
| `clientId`            | `string`   | Vault/env\*          | `\*OAUTH_DISCORD_CLIENT_ID` |
| `redirectUri`         | `string`   | `{baseUrl}…/discord` | Exact registered URI        |
| `scopes`              | `string[]` | driver defaults      | Extra scopes                |
| `storeProviderTokens` | `boolean`  | `false`              | Keep tokens in Vault        |

## Surfaces

| Flow     | Path                                    |
| -------- | --------------------------------------- |
| Start    | `POST /auth/oauth/discord/start`        |
| Callback | `GET+POST /auth/oauth/callback/discord` |
| Link     | `POST /auth/oauth/discord/link`         |

## Troubleshooting

<Accordions>
<Accordion title="Users sign in with no email attached">

Phone-only Discord accounts expose `email: null`. The session works; the user
row simply has no address until they add one at Discord.

</Accordion>
<Accordion title="invalid_oauth2 error code">

The client secret was rotated in the portal while old codes were in flight.
Restart the flow — flow rows are single-use and expire after ten minutes.

</Accordion>
<Accordion title="Email never marked verified">

The app lacks the `email` scope or the user has not confirmed their address at
Discord. Unverified emails provision new accounts but never take over
existing ones.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [GitHub](/docs/plugins/github) — primary-email lookup pattern
- [Vault](/docs/elements/vault) — where secrets live

## Next

<Cards>
  <Card
    title="GitHub"
    description="OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
  <Card title="Facebook" description="Never-verified emails." href="/docs/plugins/facebook" />
  <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
</Cards>


# Facebook (/docs/plugins/facebook)

Facebook Login is OAuth2 against the Graph API, and its trust story is the
simplest one in `oauth()`: the platform offers **no verification signal to
apps**, so emails from Facebook are treated as unverified — always.

<Callout title="The one rule">
  Treat every Facebook-provided address as unverified. The flow provisions new accounts with them
  but refuses to let them claim accounts that already exist.
</Callout>

## Quick start

<Steps>

<Step>
### Create an app

Meta for Developers → **Create App** → _Authentication_. Under Facebook Login
→ Settings, add `https://app.example.com/auth/oauth/callback/facebook` as a
Valid OAuth Redirect URI.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      facebook: { enabled: true },
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_FACEBOOK_CLIENT_SECRET=...
```

</Step>

</Steps>

## How identity works

| Aspect         | Behavior                                                      |
| -------------- | ------------------------------------------------------------- |
| Authorize      | `www.facebook.com/v21.0/dialog/oauth`                         |
| Token exchange | `graph.facebook.com/v21.0/oauth/access_token`                 |
| Profile        | `GET /me?fields=id,name,email` (the `id` is the subject)      |
| Email          | present when the user has one; phone-only accounts have none  |
| Email trust    | **always unverified** — no trustworthy provider signal exists |

**Consequence:** an attacker completing Facebook login with _your_ email gets
`email_in_use`, not your session. This exact scenario is the takeover class
the trust matrix closes.

Default scopes: `email`, `public_profile`.

## Options

| Option                | Type       | Default               | Meaning                               |
| --------------------- | ---------- | --------------------- | ------------------------------------- |
| `enabled`             | `boolean`  | `false`               | Turn the provider on                  |
| `clientId`            | `string`   | Vault/env\*           | `\*OAUTH_FACEBOOK_CLIENT_ID` (App ID) |
| `redirectUri`         | `string`   | `{baseUrl}…/facebook` | Exact registered URI                  |
| `scopes`              | `string[]` | driver defaults       | Extra scopes                          |
| `storeProviderTokens` | `boolean`  | `false`               | Keep tokens in Vault                  |

## Surfaces

| Flow     | Path                                     |
| -------- | ---------------------------------------- |
| Start    | `POST /auth/oauth/facebook/start`        |
| Callback | `GET+POST /auth/oauth/callback/facebook` |
| Link     | `POST /auth/oauth/facebook/link`         |

## Troubleshooting

<Accordions>
<Accordion title="URL Blocked: redirect_uri">

The URI is not on the Valid OAuth Redirect URIs list, or the app is in
development mode and the user lacks a role. Byte-exact matching applies.

</Accordion>
<Accordion title="Sign-in works but there is no email">

The user declined the email permission or has none on file. The flow proceeds
without an address — same behavior as Discord phone-only accounts.

</Accordion>
<Accordion title="Error code 190 at exchange">

The app secret was rotated or the code was replayed. Codes are single-use;
restart from `/start`.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [X](/docs/plugins/x) — also never-verified emails
- [Vault](/docs/elements/vault) — where secrets live

## Next

<Cards>
  <Card title="X" description="PKCE public client." href="/docs/plugins/x" />
  <Card title="Figma" description="Basic-auth token endpoint." href="/docs/plugins/figma" />
  <Card title="Discord" description="Nullable emails." href="/docs/plugins/discord" />
</Cards>


# Figma (/docs/plugins/figma)

Figma is OAuth2 with two twists: the token endpoint uses **HTTP Basic**
instead of a body field, and the API exposes **no verification flag** — so
emails are always unverified.

<Callout title="The one rule">
  Register your exact callback URI in the Figma app, seed the client secret, and expect unverified
  emails — new accounts only, never takeovers.
</Callout>

## Quick start

<Steps>

<Step>
### Create an app

Figma → Settings → Security → **Personal access tokens / OAuth apps** → create
an OAuth app. Add `https://app.example.com/auth/oauth/callback/figma` as a
callback URL.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      figma: { enabled: true },
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_FIGMA_CLIENT_SECRET=...
```

The driver sends it as `Authorization: Basic base64(client_id:client_secret)`
on the token call.

</Step>

</Steps>

## How identity works

| Aspect         | Behavior                                                                          |
| -------------- | --------------------------------------------------------------------------------- |
| Token exchange | `POST https://api.figma.com/v1/oauth/token` with the Basic header + PKCE verifier |
| Profile        | `GET https://api.figma.com/v1/me`                                                 |
| Subject        | `id`, falling back to `handle` when absent                                        |
| Email trust    | **always unverified** — no verification field exists                              |

Default scopes: `file_read`. Trim this to what you actually need; sign-in
itself requires nothing beyond defaults.

## Options

| Option                | Type       | Default            | Meaning                   |
| --------------------- | ---------- | ------------------ | ------------------------- |
| `enabled`             | `boolean`  | `false`            | Turn the provider on      |
| `clientId`            | `string`   | Vault/env\*        | `\*OAUTH_FIGMA_CLIENT_ID` |
| `redirectUri`         | `string`   | `{baseUrl}…/figma` | Exact registered URI      |
| `scopes`              | `string[]` | driver defaults    | Extra scopes              |
| `storeProviderTokens` | `boolean`  | `false`            | Keep tokens in Vault      |

## Surfaces

| Flow     | Path                                  |
| -------- | ------------------------------------- |
| Start    | `POST /auth/oauth/figma/start`        |
| Callback | `GET+POST /auth/oauth/callback/figma` |
| Link     | `POST /auth/oauth/figma/link`         |

## Troubleshooting

<Accordions>
<Accordion title="401 on token exchange">

The Basic header is built from _your_ pair — a mismatched id/secret rotation
or a secret seeded under the wrong provider key. Check
`OAUTH_FIGMA_CLIENT_SECRET`.

</Accordion>
<Accordion title="invalid_grant">

Codes are single-use and expire quickly. Flow rows are too — restart from
`/start`.

</Accordion>
<Accordion title="Email missing on the profile">

Some Figma accounts expose no email. The session signs in without one;
nothing else changes.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [GitHub](/docs/plugins/github) — OAuth2 with real verification signal
- [Vault](/docs/elements/vault) — where secrets live

## Next

<Cards>
  <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
  <Card
    title="GitHub"
    description="OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
  <Card title="X" description="PKCE public client." href="/docs/plugins/x" />
</Cards>


# GitHub (/docs/plugins/github)

GitHub is OAuth2 without discovery or ID tokens, so `oauth()` builds the
identity assertion from two REST calls: `/user` for the account and
`/user/emails` for the address that actually matters.

<Callout title="The one rule">
  Request the `user:email` scope. The public profile email is usually null; the verified primary
  address only exists on the emails endpoint.
</Callout>

## Quick start

<Steps>

<Step>
### Create an OAuth App

GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**.
Authorization callback URL:
`https://app.example.com/auth/oauth/callback/github`.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      github: { enabled: true },
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_GITHUB_CLIENT_SECRET=...
```

</Step>

</Steps>

## How identity works

| Aspect         | Behavior                                                                       |
| -------------- | ------------------------------------------------------------------------------ |
| Token exchange | form POST to `github.com/login/oauth/access_token`, JSON accepted              |
| Profile        | `GET https://api.github.com/user` (numeric `id` is the subject)                |
| Email          | `GET https://api.github.com/user/emails`; picks the entry with `primary: true` |
| Email trust    | verified only when the selected entry has an explicit `verified: true`         |

**Consequence:** a GitHub account whose primary email is unverified never
claims that address during sign-up — the flow provisions without verified
status instead of risking someone else's inbox.

Default scopes: `read:user`, `user:email`. PKCE parameters are sent; GitHub
ignores them but the protection stays uniform across providers.

## Options

| Option                | Type       | Default             | Meaning                    |
| --------------------- | ---------- | ------------------- | -------------------------- |
| `enabled`             | `boolean`  | `false`             | Turn the provider on       |
| `clientId`            | `string`   | Vault/env\*         | `\*OAUTH_GITHUB_CLIENT_ID` |
| `redirectUri`         | `string`   | `{baseUrl}…/github` | Exact registered URI       |
| `scopes`              | `string[]` | driver defaults     | Extra scopes               |
| `storeProviderTokens` | `boolean`  | `false`             | Keep tokens in Vault       |

## Surfaces

| Flow     | Path                                   |
| -------- | -------------------------------------- |
| Start    | `POST /auth/oauth/github/start`        |
| Callback | `GET+POST /auth/oauth/callback/github` |
| Link     | `POST /auth/oauth/github/link`         |

## Troubleshooting

<Accordions>
<Accordion title="Sign-in succeeds with no email">

The token lacks `user:email` (custom scopes dropped it) or the account has no
verified addresses. The session is valid; add the scope for future flows.

</Accordion>
<Accordion title="401 on /user/emails">

App permissions changed or the user revoked the grant under Settings →
Applications. The driver degrades to no email rather than failing sign-in.

</Accordion>
<Accordion title="redirect_uri mismatch">

GitHub compares the registered callback URL exactly. Re-copy the value your
config produces.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [Discord](/docs/plugins/discord) — same OAuth2 shape, nullable email
- [Vault](/docs/elements/vault) — where secrets live

## Next

<Cards>
  <Card
    title="Discord"
    description="Verified flag with nullable email."
    href="/docs/plugins/discord"
  />
  <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
  <Card title="X" description="PKCE public client." href="/docs/plugins/x" />
</Cards>


# Google (/docs/plugins/google)

Google is the reference OIDC provider for `oauth()`: discovery, ID-token
signature checks, and issuer pinning all work the textbook way. If you are
wiring your first social provider, start here.

<Callout title="The one rule">
  Register `https://app.example.com/auth/oauth/callback/google` (your exact origin) as an Authorized
  Redirect URI, then enable it with `providers.google.enabled`. The comparison is byte-exact.
</Callout>

## Quick start

<Steps>

<Step>
### Create OAuth credentials

In Google Cloud Console → APIs & Services → Credentials, create an **OAuth
client ID** of type _Web application_. Add your callback URI under Authorized
Redirect URIs.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      google: { enabled: true },
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...
```

Boot fails loudly if the secret is missing — the contract is declared by the
plugin itself.

</Step>

</Steps>

## How identity works

The driver discovers Google's endpoints from
`https://accounts.google.com/.well-known/openid-configuration` and caches them.
On callback it verifies the ID token end-to-end:

| Check     | Rule                                                    |
| --------- | ------------------------------------------------------- |
| Signature | RS256 / ES256 against Google's published JWKS           |
| Issuer    | must equal `https://accounts.google.com`                |
| Audience  | must include your client id (`azp` when multi-audience) |
| Expiry    | rejected when stale                                     |
| Nonce     | single-use, bound to the flow row                       |

Email trust follows the OIDC claim: `email_verified: true` marks the address
verified; anything else — including string `"false"` — stays unverified.

Default scopes: `openid`, `email`, `profile`.

## Options

| Option                | Type       | Default             | Meaning                    |
| --------------------- | ---------- | ------------------- | -------------------------- |
| `enabled`             | `boolean`  | `false`             | Turn the provider on       |
| `clientId`            | `string`   | Vault/env\*         | `\*OAUTH_GOOGLE_CLIENT_ID` |
| `redirectUri`         | `string`   | `{baseUrl}…/google` | Exact registered URI       |
| `scopes`              | `string[]` | driver defaults     | Extra scopes               |
| `storeProviderTokens` | `boolean`  | `false`             | Keep tokens in Vault       |

## Surfaces

| Flow     | Path                                   |
| -------- | -------------------------------------- |
| Start    | `POST /auth/oauth/google/start`        |
| Callback | `GET+POST /auth/oauth/callback/google` |
| Link     | `POST /auth/oauth/google/link`         |

## Troubleshooting

<Accordions>
<Accordion title="redirect_uri_mismatch at Google">

The registered URI and the sent URI differ. Byte-exact means scheme, host,
port, path, no trailing slash drift. Copy the value from
`providers.google.redirectUri` or `baseUrl` synthesis.

</Accordion>
<Accordion title="invalid_client at token exchange">

`OAUTH_GOOGLE_CLIENT_SECRET` is missing or stale. Secrets resolve through the
Vault chain — update `.env.local` or your Vault driver, then restart.

</Accordion>
<Accordion title="aud_mismatch">

You are using credentials from a different Google project than the one that
issued the code, or swapped client ids between environments.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [Vault](/docs/elements/vault) — where secrets live
- [Gate](/docs/elements/gate) — `gate.auth`

## Next

<Cards>
  <Card
    title="Apple"
    description="form_post + ES256 client-secret JWT."
    href="/docs/plugins/apple"
  />
  <Card
    title="Microsoft"
    description="Entra tenants and issuer templates."
    href="/docs/plugins/microsoft"
  />
  <Card
    title="GitHub"
    description="OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
</Cards>


# Headers (/docs/plugins/headers)

`headers()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { headers } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(headers());
```

Every HTTP response now carries:

| Header                              | Value         |
| ----------------------------------- | ------------- |
| `X-Content-Type-Options`            | `nosniff`     |
| `X-Frame-Options`                   | `DENY`        |
| `Referrer-Policy`                   | `no-referrer` |
| `Origin-Agent-Cluster`              | `?1`          |
| `X-DNS-Prefetch-Control`            | `off`         |
| `X-Download-Options`                | `noopen`      |
| `X-Permitted-Cross-Domain-Policies` | `none`        |
| `X-XSS-Protection`                  | `0`           |
| `X-Powered-By`                      | removed       |

## Helmet parity

Every [helmet.js](https://helmet.js.org/) middleware maps to an option here — same defaults wherever helmet's default is safe for an API, plus three deliberate deviations marked `opt-in`:

| Helmet middleware               | This plugin                                                                               | Default match?                                                              |
| ------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `contentSecurityPolicy`         | `contentSecurityPolicy` — string **or** `{ directives, useDefaults, reportOnly }` builder | Same default directives; ours is opt-in like helmet's structure suggests    |
| `crossOriginEmbedderPolicy`     | `crossOriginEmbedderPolicy`                                                               | Yes — off unless set                                                        |
| `crossOriginOpenerPolicy`       | `crossOriginOpenerPolicy`                                                                 | **opt-in** — helmet's `same-origin` default breaks cross-origin API clients |
| `crossOriginResourcePolicy`     | `crossOriginResourcePolicy`                                                               | **opt-in** — same API rationale                                             |
| `originAgentCluster`            | `originAgentCluster`                                                                      | Yes — `?1`                                                                  |
| `referrerPolicy`                | `referrerPolicy`                                                                          | Yes — `no-referrer`                                                         |
| `strictTransportSecurity`       | `hsts`                                                                                    | **opt-in** — HSTS is sticky; never on plain-HTTP local dev                  |
| `xContentTypeOptions`           | always on                                                                                 | Yes — `nosniff`                                                             |
| `xDnsPrefetchControl`           | `dnsPrefetchControl`                                                                      | Yes — `off`; `{ allow: true }` → `on`                                       |
| `xDownloadOptions`              | `downloadOptions`                                                                         | Yes — `noopen`                                                              |
| `xFrameOptions`                 | `frameOptions`                                                                            | Yes — stricter: `DENY` over helmet's `SAMEORIGIN`                           |
| `xPermittedCrossDomainPolicies` | `permittedCrossDomainPolicies`                                                            | Yes — `none`                                                                |
| `xPoweredBy`                    | `poweredBy`                                                                               | Yes — removed; a string sets a decoy value                                  |
| `xXssProtection`                | `xssProtection`                                                                           | Yes — `0` (disables the legacy buggy auditor)                               |

Beyond parity: headers land on **failures too** (middleware that only wraps happy paths skips error responses), app-set values win by default, and every option can be driven live from the database (below).

## Options

| Option                         | Type                                                             | Default         | Does                                                                                     |
| ------------------------------ | ---------------------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------- |
| `contentSecurityPolicy`        | `string` · `{ directives, useDefaults?, reportOnly? }`           | — (omitted)     | CSP header; object form merges over helmet's default directives, camelCase or kebab keys |
| `frameOptions`                 | `"DENY"` · `"SAMEORIGIN"`                                        | `"DENY"`        | `X-Frame-Options` value                                                                  |
| `referrerPolicy`               | `string`                                                         | `"no-referrer"` | `Referrer-Policy` value                                                                  |
| `hsts`                         | `boolean` · `{ maxAge?, includeSubDomains?, preload? }`          | `false`         | `Strict-Transport-Security` — `true` = one year                                          |
| `permissionsPolicy`            | `string`                                                         | — (omitted)     | `Permissions-Policy` value                                                               |
| `crossOriginOpenerPolicy`      | `"same-origin"` · `"same-origin-allow-popups"` · `"unsafe-none"` | — (omitted)     | `Cross-Origin-Opener-Policy` value                                                       |
| `crossOriginResourcePolicy`    | `"same-origin"` · `"same-site"` · `"cross-origin"`               | — (omitted)     | `Cross-Origin-Resource-Policy` value                                                     |
| `crossOriginEmbedderPolicy`    | `"require-corp"` · `"credentialless"`                            | — (omitted)     | `Cross-Origin-Embedder-Policy` value                                                     |
| `originAgentCluster`           | `boolean`                                                        | `true`          | `Origin-Agent-Cluster: ?1` — `false` omits                                               |
| `dnsPrefetchControl`           | `boolean` · `{ allow: boolean }`                                 | `true` → `off`  | `X-DNS-Prefetch-Control`; `false` omits                                                  |
| `downloadOptions`              | `boolean`                                                        | `true`          | `X-Download-Options: noopen` — `false` omits                                             |
| `permittedCrossDomainPolicies` | `"none"` · `"master-only"` · `"by-content-type"` · `"all"`       | `"none"`        | `X-Permitted-Cross-Domain-Policies` value                                                |
| `poweredBy`                    | `boolean` · `string`                                             | `true`          | `true` removes `X-Powered-By`, a string sets a decoy, `false` keeps the app's            |
| `xssProtection`                | `boolean`                                                        | `true`          | `X-XSS-Protection: 0` — `false` omits                                                    |
| `override`                     | `boolean`                                                        | `false`         | Replace values the app set explicitly                                                    |

```typescript
.plug(
  headers({
    contentSecurityPolicy: {
      directives: { scriptSrc: ["'self'", "https://cdn.example.com"] }, // merged over the defaults
      reportOnly: true, // Content-Security-Policy-Report-Only while you tune
    },
    hsts: { maxAge: 63072000, includeSubDomains: true },
    permissionsPolicy: "camera=(), microphone=()",
  }),
)
```

<Callout type="warn">
  HSTS is off by default because it is **sticky** — once a browser sees it, it insists on HTTPS for
  the whole `max-age`. Enable it only on deployments that already serve HTTPS (never on plain-HTTP
  local dev). The same caution applies to `upgrade-insecure-requests` in the default CSP.
</Callout>

## Runtime configuration

Header policy is exactly the config you want to flip without a redeploy — enable HSTS the day HTTPS lands, tighten the CSP after an audit. Pass a `configSource()` and options follow the database within one sync interval:

```typescript
const headerConfig = configSource({
  plugin: "headers",
  code: { hsts: false }, // safe floor for local dev
  db: { store: db },
  kv: cache,
});
const headerSyncClock = clock.every("headers.sync", "30s");
on(headerSyncClock, headerConfig.sync());
export const app = oke({ name: "shop", env: "dev" }).plug(headers(headerConfig));
```

```sql
INSERT INTO headers_config ("key", "value")
VALUES ('config', '{"hsts": true}');
```

See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Notes

| Behavior          | Detail                                                                |
| ----------------- | --------------------------------------------------------------------- |
| Failures included | `onResponse` runs after `onError` — denials get the same headers      |
| App wins          | A header set earlier (app hook, flow) is kept unless `override: true` |
| Non-HTTP triggers | No-op — nothing to stamp outside HTTP                                 |
| Scope             | Flow responses — infra routes (`/_oke/*`, 404s) bypass the pipeline   |

## Next

<Cards>
  <Card title="CORS" description="Cross-origin rules at the edge." href="/docs/plugins/cors" />
  <Card title="CSRF" description="Block cross-site state changes." href="/docs/plugins/csrf" />
  <Card
    title="Plugin API"
    description="Runtime configuration contract."
    href="/docs/reference/plugins"
  />
</Cards>


# Plugins (/docs/plugins)

First-party plugins you `.plug()` onto an app. Each page is one export from `okengine/plugins`.

## Authentication

<Cards>
  <Card title="Username" description="Username + password." href="/docs/plugins/username" />
  <Card
    title="Anonymous"
    description="Guest session, no password."
    href="/docs/plugins/anonymous"
  />
  <Card title="Magic link" description="One-time email link." href="/docs/plugins/magic-link" />
  <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
  <Card title="Two-factor" description="TOTP enable / verify." href="/docs/plugins/two-factor" />
  <Card title="Passkey" description="WebAuthn-shaped passkeys." href="/docs/plugins/passkey" />
</Cards>

## OAuth

<Cards>
  <Card
    title="OAuth"
    description="Social sign-in — Authorization Code + PKCE for eight providers."
    href="/docs/plugins/oauth"
  />
  <Card
    title="Apple"
    description="Sign in with Apple — form_post + ES256 JWT."
    href="/docs/plugins/apple"
  />
  <Card
    title="Discord"
    description="Discord OAuth2 with nullable email."
    href="/docs/plugins/discord"
  />
  <Card
    title="Facebook"
    description="Facebook Login — never-verified emails."
    href="/docs/plugins/facebook"
  />
  <Card
    title="Figma"
    description="Figma OAuth2 with HTTP Basic token auth."
    href="/docs/plugins/figma"
  />
  <Card
    title="GitHub"
    description="GitHub OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
  <Card
    title="Google"
    description="Google OIDC — JWKS-verified ID tokens."
    href="/docs/plugins/google"
  />
  <Card
    title="Microsoft"
    description="Entra ID OIDC with tenant-aware issuer checks."
    href="/docs/plugins/microsoft"
  />
  <Card title="X" description="X OAuth2 public client — PKCE only." href="/docs/plugins/x" />
</Cards>

## Security

<Cards>
  <Card
    title="Headers"
    description="Secure headers on every HTTP response."
    href="/docs/plugins/headers"
  />
  <Card
    title="CORS"
    description="Cross-origin rules; closed by default."
    href="/docs/plugins/cors"
  />
  <Card
    title="CSRF"
    description="Fetch-metadata forgery defense, no tokens."
    href="/docs/plugins/csrf"
  />
  <Card
    title="IP Allowlist"
    description="Allow/deny by client IP at the edge."
    href="/docs/plugins/ip-allowlist"
  />
</Cards>

## Operations

<Cards>
  <Card
    title="Maintenance Mode"
    description="Drain HTTP with 503 and Retry-After."
    href="/docs/plugins/maintenance-mode"
  />
</Cards>

## Performance

<Cards>
  <Card
    title="Compression"
    description="gzip responses when the client accepts it."
    href="/docs/plugins/compression"
  />
</Cards>


# IP Allowlist (/docs/plugins/ip-allowlist)

`ipAllowlist()` enforces IP rules at `onAuth`, before any gate policy or flow body runs. Internal admin surfaces, staging environments, and webhook endpoints stop unknown clients at the edge with the same typed `Forbidden` denial the [gate element](/docs/elements/gate) produces.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { ipAllowlist } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(
  ipAllowlist({ allow: ["203.0.113.7", "2001:db8::42"] }),
);
```

A client whose IP is not on the list receives `403` with a typed denial:

```json
{
  "data": null,
  "error": {
    "code": "Forbidden",
    "data": { "reason": "ip_not_allowed", "ip": "198.51.100.9" }
  }
}
```

## Options

| Option              | Type       | Default             | Does                                                                   |
| ------------------- | ---------- | ------------------- | ---------------------------------------------------------------------- |
| `allow`             | `string[]` | — (everyone passes) | Exact IPs permitted — every other client is denied                     |
| `deny`              | `string[]` | — (nobody blocked)  | Exact IPs blocked — checked first, so deny wins on overlap             |
| `header`            | `string`   | `"x-forwarded-for"` | Header carrying the client IP                                          |
| `trustedProxyDepth` | `number`   | `1`                 | Trusted proxies that append XFF; client IP is that many from the right |

```typescript
.plug(ipAllowlist({ deny: ["198.51.100.9"], trustedProxyDepth: 1 }))
```

<Callout type="error">
  Standard reverse proxies **append** to `X-Forwarded-For` — left-side hops are attacker-controlled.
  The plugin trusts the hop `trustedProxyDepth` from the **right** (default `1` = last hop). Set
  this to your real proxy count — wrong depth bypasses the allowlist.
</Callout>

## Notes

| Behavior          | Detail                                                                             |
| ----------------- | ---------------------------------------------------------------------------------- |
| XFF parsing       | Last hop (depth `1`) is the client; left-side spoofed entries are ignored          |
| Missing header    | Denied when `allow` is set (unknown is not allowed); permitted for deny-only rules |
| Deny wins         | An IP in both lists is blocked                                                     |
| Non-HTTP triggers | No-op — there is no client IP outside HTTP                                         |

## Runtime configuration

Block an abusive IP from the database and every instance picks it up on the next sync:

```typescript
const rules = configSource({
  plugin: "ip-allowlist",
  code: { deny: [] },
  db: { store: db },
  kv: cache,
});
const ipRulesSyncClock = clock.every("ip-allowlist.sync", "30s");
on(ipRulesSyncClock, rules.sync());
export const app = oke({ name: "shop", env: "dev" }).plug(ipAllowlist(rules));
```

See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Next

<Cards>
  <Card
    title="Maintenance Mode"
    description="Drain traffic with one flag."
    href="/docs/plugins/maintenance-mode"
  />
  <Card
    title="Gate"
    description="Policies and rate limits after the IP edge."
    href="/docs/elements/gate"
  />
  <Card title="Plugin API" description="Build your own plugin." href="/docs/reference/plugins" />
</Cards>


# Magic link (/docs/plugins/magic-link)

`magicLink()` issues a hashed, single-use token (default 10 minutes). Request sends the link
via Channel (`auth-magic-link`); verify exchanges it for a hybrid session and creates the user
on first success.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(magicLink())`. Tokens are hashed at rest — never log the raw link.
  Delivery goes through `fx.send`; use `exposeDevToken` only for local DX without SMTP.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { magicLink } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(magicLink({ baseUrl: "http://127.0.0.1:6530" }));
```

</Step>

<Step>
### Request a link

```typescript
const { data } = await api.auth.requestMagicLink({ email: "ali@example.com" });
// data.ok === true; Channel delivers auth-magic-link
// data.devToken only when exposeDevToken
```

`POST /auth/magic-link/request`. Under `oke test` the `console` driver captures mail; with
`oke dev`, Mailpit receives the real SMTP message.

</Step>

<Step>
### Verify

```typescript
const { data } = await api.auth.verifyMagicLink({ token });
// session tokens + userId
```

`POST /auth/magic-link/verify`. Bad or reused tokens → `AuthFailed` /
`invalid_credentials`.

</Step>

</Steps>

<Callout type="info" title="Pre-account hijack defense">
  Completing verify proves inbox ownership. An **unverified** email+password account parked on that
  address is reclaimed (sessions revoked, hashes cleared, `emailVerified` set). Verified owners
  re-auth only — no credential purge.
</Callout>

## Options

| Option           | Type                | Default                    | Meaning                                   |
| ---------------- | ------------------- | -------------------------- | ----------------------------------------- |
| `secret`         | `string`            | active\*                   | HMAC secret (\*from `gate.auth`)          |
| `sessions`       | `SessionStore`      | active\*                   | Session store                             |
| `ttlMs`          | `number`            | 10m                        | Challenge lifetime                        |
| `baseUrl`        | `string`            | `OKE_APP_URL` or `:6530`   | Origin used to build the magic link       |
| `from`           | `string`            | `OKE <no-reply@oke.local>` | Template From address                     |
| `exposeDevToken` | `boolean`           | `false`                    | Include raw token in the request response |
| `identities`     | `IdentityStore`     | new                        | Email → user map                          |
| `verifications`  | `VerificationStore` | new                        | Challenge store                           |

## Surfaces

| Flow                    | Path                            | Gate                     |
| ----------------------- | ------------------------------- | ------------------------ |
| `auth.requestMagicLink` | `POST /auth/magic-link/request` | `gate.public` + otp rate |
| `auth.verifyMagicLink`  | `POST /auth/magic-link/verify`  | `gate.public` + otp rate |

**Consequence:** the plugin contributes the `auth-magic-link` Channel template and EN/AR
catalog bodies (`{{link}}`, `{{token}}`). Override copy by merging your own catalog at boot.

## Delivery drivers

| `drivers.channel.email` | Delivery                                              |
| ----------------------- | ----------------------------------------------------- |
| `console`               | Dev inbox (local/test default)                        |
| `smtp`                  | Any SMTP host — Mailpit under `oke dev` (dev default) |
| `resend` / `sndr`       | Hosted email APIs                                     |
| `taqnyat-mail`          | Taqnyat Mail API (additive option)                    |

### Taqnyat Mail

```typescript title="oke.config.ts"
export default {
  drivers: {
    channel: {
      email: { dev: "smtp", test: "console", prod: "taqnyat-mail" },
    },
  },
};
```

| Env                  | Meaning                                          |
| -------------------- | ------------------------------------------------ |
| `TAQNYAT_MAIL_TOKEN` | Taqnyat bearer token enabled for Email           |
| `TAQNYAT_CAMPAIGN`   | Campaign name required by Taqnyat `mailSend.php` |

The plugin needs no change — delivery stays Channel-mediated via `fx.send`. SMTP/Mailpit
remains the default docker path; `taqnyat-mail` is strictly additive.

## Live tests (opt-in)

The Taqnyat live suite sends real email and burns real quota, so it is double-gated: it runs
only when `OKE_EMAIL_LIVE=1` **and** the real credentials (`TAQNYAT_MAIL_TOKEN`,
`TAQNYAT_CAMPAIGN`, plus `OKE_TEST_TAQNYAT_MAIL`) are all present.

Credentials alone never send; without the flag the suite skips visibly — never a silent pass.

```bash
OKE_EMAIL_LIVE=1 TAQNYAT_MAIL_TOKEN=… TAQNYAT_CAMPAIGN=auth \
  OKE_TEST_TAQNYAT_MAIL=you@example.com bun test src/plugins
```

## Troubleshooting

<Accordions>
<Accordion title="verify returns invalid_credentials">

Token expired (default 10m), already used, or mistyped. Request a new link.

</Accordion>
<Accordion title="No email arrived">

In `local` / `test` the `console` driver captures mail into the inbox — nothing hits a mailbox.
Run `oke dev` and open Mailpit (`MAILPIT_UI_URL`) to see the rendered message. For
unit tests without SMTP, set `exposeDevToken: true`.

</Accordion>
</Accordions>

## Learn more

- [OTP](/docs/plugins/otp) — numeric code instead of a link
- [Gate](/docs/elements/gate) — `gate.auth`
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents

## Next

<Cards>
  <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
</Cards>


# Maintenance Mode (/docs/plugins/maintenance-mode)

`maintenanceMode()` short-circuits every HTTP invocation at `onRequest` — the earliest pipeline stage — with a typed `503 ServiceUnavailable` envelope. Deploys, migrations, and incidents stop traffic with one flag instead of a deploy-time firewall rule.

## Quick start

```typescript title="src/app.ts"
import { oke } from "okengine";
import { maintenanceMode } from "okengine/plugins";

export const app = oke({ name: "shop", env: "dev" }).plug(
  maintenanceMode({ enabled: process.env.MAINTENANCE_MODE === "1" }),
);
```

With `MAINTENANCE_MODE=1` in the environment, every HTTP flow answers:

```json
{
  "data": null,
  "error": {
    "code": "ServiceUnavailable",
    "data": { "retryAfter": 300 },
    "message": "Service is under maintenance."
  }
}
```

## Options

| Option         | Type       | Default                           | Does                                                 |
| -------------- | ---------- | --------------------------------- | ---------------------------------------------------- |
| `enabled`      | `boolean`  | `true`                            | Master switch — drive it from the environment        |
| `retryAfter`   | `number`   | — (omitted)                       | `Retry-After` seconds on the 503                     |
| `allowPaths`   | `string[]` | — (none)                          | Path prefixes that keep serving (e.g. `["/health"]`) |
| `bypassHeader` | `string`   | — (none)                          | Header whose non-empty value lets a request through  |
| `message`      | `string`   | `"Service is under maintenance."` | Failure message in the envelope                      |

```typescript
.plug(
  maintenanceMode({
    enabled: process.env.MAINTENANCE_MODE === "1",
    retryAfter: 300,
    allowPaths: ["/health"],       // load-balancer checks stay green
    bypassHeader: "x-ops-token",   // operators keep working
  }),
)
```

<Callout type="info">
  The bypass header is presence-based — an ops convenience, not authentication. Anyone who learns
  the header name passes through, so treat its value as a light secret and never as a security
  boundary.
</Callout>

## Notes

| Behavior          | Detail                                                                     |
| ----------------- | -------------------------------------------------------------------------- |
| Pipeline position | `onRequest` — flows never parse, authenticate, or execute                  |
| Still shaped      | The 503 flows through `onResponse`, so Headers and Compression apply to it |
| Non-HTTP triggers | No-op — clock flows and signal subscribers keep running                    |
| Infra routes      | `/_oke/*` bypass the pipeline entirely and stay up                         |

## Runtime configuration

The flagship `configSource()` use case — flip `enabled` from the database and drain traffic without a redeploy:

```typescript
const maintenance = configSource({
  plugin: "maintenance-mode",
  code: { enabled: false },
  db: { store: db },
  kv: cache,
});
const maintenanceSyncClock = clock.every("maintenance.sync", "30s");
on(maintenanceSyncClock, maintenance.sync());
export const app = oke({ name: "shop", env: "dev" }).plug(maintenanceMode(maintenance));
```

See [Plugins → Runtime configuration](/docs/reference/plugins#runtime-configuration-code-or-db) for the full contract.

## Next

<Cards>
  <Card
    title="IP Allowlist"
    description="Allow/deny rules by client IP."
    href="/docs/plugins/ip-allowlist"
  />
  <Card
    title="Headers"
    description="The full secure-headers set on every response."
    href="/docs/plugins/headers"
  />
  <Card title="Plugin API" description="Build your own plugin." href="/docs/reference/plugins" />
</Cards>


# Microsoft (/docs/plugins/microsoft)

Microsoft (Entra ID) is OIDC with a tenant twist: `common` discovery advertises
an issuer with the `{tenantid}` placeholder, so the value you pin at start is
a _template_ validated against the concrete token at callback.

<Callout title="The one rule">
  Pick the audience first: `tenant: "common"` for everyone, `organizations` for work/school only,
  `consumers` for personal accounts, or a tenant GUID to lock sign-in to one directory.
</Callout>

## Quick start

<Steps>

<Step>
### Register an app

In the Azure portal → Microsoft Entra ID → App registrations → New
registration. Choose _Accounts in any organizational directory and personal
Microsoft accounts_ for `common`. Add your redirect URI under **Web**.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      microsoft: { enabled: true }, // tenant defaults to "common"
    },
  }),
);
```

</Step>

<Step>
### Set the client secret

```text
# .env.local
OAUTH_MICROSOFT_CLIENT_SECRET=...
```

Certificates are not used — the driver authenticates with the shared secret
form field.

</Step>

</Steps>

## How identity works

| Aspect         | Behavior                                                                                              |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| Discovery      | `{tenant}/oauth2/v2.0/.well-known/openid-configuration`, cached                                       |
| Issuer pinning | flow stores `https://login.microsoftonline.com/{tenantid}/v2.0`; tokens must match it shape-for-shape |
| Tenant claim   | the token's `tid` must be GUID-shaped and consistent with `iss`                                       |
| Signature      | RS256 against the tenant's published keys                                                             |
| Email trust    | OIDC `email_verified` claim                                                                           |

**Consequence:** an ID token minted by a _different_ tenant fails the issuer
template even though its signature is perfectly valid — the mix-up defense
survives multi-tenancy.

Default scopes: `openid`, `email`, `profile`.

## Options

| Option                | Type       | Default                | Meaning                                 |
| --------------------- | ---------- | ---------------------- | --------------------------------------- |
| `enabled`             | `boolean`  | `false`                | Turn the provider on                    |
| `clientId`            | `string`   | Vault/env\*            | `\*OAUTH_MICROSOFT_CLIENT_ID`           |
| `tenant`              | `string`   | `"common"`             | `organizations`, `consumers`, or a GUID |
| `redirectUri`         | `string`   | `{baseUrl}…/microsoft` | Exact registered URI                    |
| `scopes`              | `string[]` | driver defaults        | Extra scopes                            |
| `storeProviderTokens` | `boolean`  | `false`                | Keep tokens in Vault                    |

## Surfaces

| Flow     | Path                                      |
| -------- | ----------------------------------------- |
| Start    | `POST /auth/oauth/microsoft/start`        |
| Callback | `GET+POST /auth/oauth/callback/microsoft` |
| Link     | `POST /auth/oauth/microsoft/link`         |

## Troubleshooting

<Accordions>
<Accordion title="issuer_mismatch on every login">

Your app registration's _supported account types_ disagree with the configured
`tenant`. `common` requires multi-tenant + personal accounts; a single-tenant
GUID needs the matching registration.

</Accordion>
<Accordion title="AADSTS50011 (redirect mismatch)">

The reply URL registered in Azure does not byte-match the stored
`redirectUri`. Re-copy from your plugin config; trailing slashes count.

</Accordion>
<Accordion title="No email arrives">

Personal Microsoft accounts can omit email entirely. The flow signs the person
in without one — exactly like Discord phone-only accounts.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [Google](/docs/plugins/google) — plain single-issuer OIDC
- [Vault](/docs/elements/vault) — where secrets live

## Next

<Cards>
  <Card
    title="Apple"
    description="form_post + ES256 client-secret JWT."
    href="/docs/plugins/apple"
  />
  <Card
    title="Discord"
    description="Nullable emails, verified flag."
    href="/docs/plugins/discord"
  />
  <Card
    title="GitHub"
    description="OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
</Cards>


# OAuth (/docs/plugins/oauth)

`oauth()` adds social login to Gate auth. Start at
`/auth/oauth/{provider}/start`, approve, then land on
`/auth/oauth/callback/{provider}` with a session — eight providers.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(oauth({ providers: { ... } }))`. Every
  provider runs Authorization Code + PKCE with an exact registered redirect URI
  — implicit and password grants do not exist here.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      google: { enabled: true },
      github: { enabled: true },
    },
  }),
);
```

`baseUrl` synthesizes `{baseUrl}/auth/oauth/callback/{provider}` — register
that exact string. Client secrets resolve from Vault
(`OAUTH_GOOGLE_CLIENT_SECRET`, `OAUTH_GITHUB_CLIENT_SECRET`, …).

</Step>

<Step>
### Start the flow

```typescript
const { data } = await api.auth.oauthStart({ provider: "google" });
// redirect the browser to data.authorizationUrl
```

The response carries `authorizationUrl`, `expiresInMs`, and the provider echo.
The flow row (state hash, PKCE verifier, nonce) lives for ten minutes.

</Step>

<Step>
### Callback issues a session

The provider redirects to your callback route; the flow verifies state,
exchanges the code, and signs the person in:

```typescript
const body = {
  accessToken: "...",
  refreshToken: "...",
  userId: "usr_...",
};
```

A brand-new visitor gets a fresh account. Someone whose email already belongs
to another credential is refused with `email_in_use`.

</Step>

</Steps>

## Providers

| Provider  | Shape  | Identity source          | Email verified                       |
| --------- | ------ | ------------------------ | ------------------------------------ |
| Google    | OIDC   | JWKS-verified ID token   | `email_verified` claim               |
| Apple     | OIDC   | JWKS-verified ID token   | strict parse — `"false"` stays false |
| Microsoft | OIDC   | JWKS-verified ID token   | `email_verified` claim               |
| GitHub    | OAuth2 | `/user` + `/user/emails` | primary `verified` flag              |
| Discord   | OAuth2 | `/users/@me`             | `verified` flag                      |
| X         | OAuth2 | `/2/users/me`            | never — no trustworthy signal        |
| Facebook  | OAuth2 | Graph `/me`              | never — no trustworthy signal        |
| Figma     | OAuth2 | `/v1/me`                 | never — no verification field        |

Each provider has its own page under this category — endpoints, scopes, setup,
and trust rules.

## Security model

| Threat                             | Defense                                                                 |
| ---------------------------------- | ----------------------------------------------------------------------- |
| Code interception                  | PKCE S256 on every provider                                             |
| CSRF / forged callbacks            | single-use `state`, SHA-256-hashed at rest                              |
| Mix-up across providers (RFC 9700) | per-provider callback routes + issuer pinning on every assertion        |
| Unverified-email takeover          | `linkOrProvision` refuses `email_in_use` without an authenticated owner |
| Redirect manipulation              | byte-exact stored `redirect_uri` at token exchange                      |

**Consequence:** an attacker who completes a social login claiming your email
gains nothing — the account stays untouched unless they already own a session
for it.

## Options

| Option          | Type                | Default  | Meaning                                  |
| --------------- | ------------------- | -------- | ---------------------------------------- |
| `providers`     | map                 | `{}`     | Per-provider config, disabled by default |
| `baseUrl`       | `string`            | —        | Origin for synthesized callback URIs     |
| `secret`        | `string`            | active\* | HMAC secret (\*from `gate.auth`)         |
| `sessions`      | `SessionStore`      | active\* | Session store                            |
| `identities`    | `IdentityStore`     | active\* | Shared user store                        |
| `verifications` | `VerificationStore` | new      | Flow-state store                         |
| `fetch`         | `typeof fetch`      | global   | Injectable transport (tests)             |

Per-provider config: `enabled`, `clientId`, `redirectUri`, `scopes`,
`storeProviderTokens`, plus Microsoft `tenant` and Apple `teamId` / `keyId`.

## Surfaces

| Flow                  | Path                                       | Gate                     |
| --------------------- | ------------------------------------------ | ------------------------ |
| `auth.oauthStart`     | `POST /auth/oauth/{provider}/start`        | `gate.public` + otp rate |
| `auth.oauthCallback`  | `GET+POST /auth/oauth/callback/{provider}` | `gate.public` + otp rate |
| `auth.oauthLinkStart` | `POST /auth/oauth/{provider}/link`         | session + bearer         |

The GET + POST callback pair exists because Apple posts its response instead of
redirecting.

## Troubleshooting

<Accordions>
<Accordion title="Boot fails listing OAUTH_…_CLIENT_SECRET">

Every enabled provider needs its secret in Vault before boot. Seed
`OAUTH_{PROVIDER}_CLIENT_SECRET` (Apple uses `OAUTH_APPLE_PRIVATE_KEY`) through
your Vault driver or `.env.local`.

</Accordion>
<Accordion title="Callback returns invalid_state">

The state was consumed, expired (ten-minute TTL), or minted by a different
provider's start call. Restart from `/start`.

</Accordion>
<Accordion title="Callback returns issuer_mismatch">

The ID token's issuer does not match the provider that started the flow — the
signature was valid but the token came from elsewhere. This rejection is the
mix-up defense working; retry the real provider.

</Accordion>
<Accordion title="Callback returns email_in_use">

The provider returned an email already registered to another account without
proof you own that account. Sign in with the original method first, then link
via `POST /auth/oauth/{provider}/link`.

</Accordion>
</Accordions>

## Learn more

- [Gate](/docs/elements/gate) — `gate.auth`
- [Passkey](/docs/plugins/passkey) — phishing-resistant alternative
- [Client · Auth](/docs/client/auth) — calling `/auth` from the browser

## Next

<Cards>
  <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
  <Card
    title="GitHub"
    description="OAuth2 with verified-email lookup."
    href="/docs/plugins/github"
  />
  <Card title="Passkey" description="WebAuthn-shaped passkeys." href="/docs/plugins/passkey" />
</Cards>


# OTP (/docs/plugins/otp)

`otp()` signs people in with a one-time code. You must set `mode` — there is no
auto-detect. Provider mode is SMS Verify via the bound driver; app mode is
app-owned delivery across the channels you declare.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(otp({ mode: "provider" }))` or
  `.plug(otp({ mode: "app", channels: [...] }))`. Never omit `mode`. Never log
  raw OTPs.
</Callout>

<Callout type="info" title="One otp() per app">
  Provider and app mode cannot both be active — they claim the same fixed `/auth/otp/*` routes. Need
  two OTP-like mechanisms? Combine `otp()` with a different plugin (e.g. `magicLink()`), not a
  second `otp()`.
</Callout>

<Callout type="info" title="fx.sendOtp vs otp()">
  `fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
  build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
  it and call the raw methods in your own flow without losing the provider connection.
</Callout>

## Quick start

<Steps>

<Step>
### Plug app mode (multi-channel)

```typescript title="src/app.ts"
import { oke } from "okengine";
import { otp } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  otp({
    mode: "app",
    channels: ["sms", "whatsapp", "email"],
    exposeDevOtp: true, // local DX only
  }),
);
```

</Step>

<Step>
### Request a code

```typescript
const { data } = await api.auth.requestOtp({
  phone: "+15551234567",
  email: "ali@example.com",
});
```

`POST /auth/otp/request`. Prior active challenges for that principal are
invalidated. Delivery follows `channels` order for addresses you pass.

</Step>

<Step>
### Resend on another channel (app mode only)

```typescript
const { data } = await api.auth.resendOtp({
  phone: "+15551234567",
  email: "ali@example.com",
  channel: "email",
});
```

Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
resend surface — the provider owns the code.

</Step>

<Step>
### Verify

```typescript
const { data } = await api.auth.verifyOtp({
  phone: "+15551234567",
  otp,
});
```

`POST /auth/otp/verify` — five failed attempts consume the challenge.

</Step>

</Steps>

<Callout type="info" title="Pre-account hijack defense (email)">
  App-mode email verify proves inbox ownership. An **unverified** parked email+password account is
  reclaimed (sessions revoked, hashes cleared, `emailVerified` set) before the OTP session. Verified
  owners re-auth only.
</Callout>

## Modes

|                      | Provider mode                 | App mode                                      |
| -------------------- | ----------------------------- | --------------------------------------------- |
| Config               | `otp({ mode: "provider" })`   | `otp({ mode: "app", channels: [...] })`       |
| Who owns the code    | Provider (Verify API)         | Your app                                      |
| Delivery             | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates)           |
| Channels             | SMS only                      | `sms` · `whatsapp` · `email` (declared order) |
| Resend other channel | Impossible                    | `POST /auth/otp/resend`                       |
| `exposeDevOtp`       | Forbidden                     | Optional (default off)                        |

<Callout type="warn" title="Provider mode limitation">
  Resend-via-different-channel is impossible in provider mode — the code value is never visible to
  OKE. Use app mode when you need SMS → email fallback for the same code.
</Callout>

### Provider mode setup

```typescript title="oke.config.ts"
export default {
  drivers: {
    channel: {
      sms: { test: "console", prod: "taqnyat" },
    },
  },
};
```

Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
app mode, or bind a Verify-capable driver (for example `taqnyat`).

### App mode delivery

| Concern         | Behavior                                                                                                                    |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Storage         | SHA-256 hash for verify + sealed AES-GCM copy (HKDF `oke-otp-seal-v1`) for redelivery                                       |
| Seal lifetime   | Wiped on verify, lockout, or TTL expiry — never left after the challenge dies                                               |
| Challenge TTL   | Default 10 minutes (`ttlMs`)                                                                                                |
| Resend cooldown | Default 60 seconds (`resendCooldownMs`) — separate from TTL                                                                 |
| Auto failover   | On real provider send errors, sently `FallbackTransport` walks remaining media; Taqnyat WhatsApp may use `sendWithFailover` |
| User resend     | Explicit `resend` with `channel` — not automatic                                                                            |

Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
`{{otp}}`). SMS here is a plain message — not Taqnyat Verify.

## Options

| Option             | Type                             | Default                    | Meaning                             |
| ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
| `mode`             | `"provider" \| "app"`            | required                   | Delivery mechanism — no auto        |
| `channels`         | `("sms"\|"whatsapp"\|"email")[]` | required in app mode       | Build-time preferred order          |
| `ttlMs`            | `number`                         | 10m                        | Challenge lifetime                  |
| `resendCooldownMs` | `number`                         | 60s                        | App-mode resend spacing             |
| `exposeDevOtp`     | `boolean`                        | `false`                    | App mode only — raw OTP in response |
| `from`             | `string`                         | `OKE <no-reply@oke.local>` | Email template From                 |
| `secret`           | `string`                         | active\*                   | Auth secret (\*from `gate.auth`)    |
| `sessions`         | `SessionStore`                   | active\*                   | Session store                       |
| `identities`       | `IdentityStore`                  | new                        | Email → user                        |
| `phones`           | `PhoneStore`                     | new                        | Phone → user                        |
| `verifications`    | `VerificationStore`              | new                        | Challenge store                     |

## Surfaces

| Flow              | Path                     | Gate                     | Mode          |
| ----------------- | ------------------------ | ------------------------ | ------------- |
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both          |
| `auth.verifyOtp`  | `POST /auth/otp/verify`  | `gate.public` + otp rate | both          |
| `auth.resendOtp`  | `POST /auth/otp/resend`  | `gate.public` + otp rate | app mode only |

## Troubleshooting

<Accordions>
<Accordion title='otp(): mode is required'>

You omitted `mode`. Set `mode: "provider"` or `mode: "app"` explicitly — OKE
never infers which mechanism you meant.

</Accordion>
<Accordion title="Boot fails in provider mode">

No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
(or another driver with `sendOtp`/`verifyOtp`), or switch to
`otp({ mode: "app", channels: [...] })`.

</Accordion>
<Accordion title="resend_cooldown">

Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
only delivery is rate-limited.

</Accordion>
<Accordion title="No email / SMS with the code (app mode)">

In `local` / `test` the `console` driver captures messages. Use
`exposeDevOtp: true` for unit tests without a real provider.

</Accordion>
</Accordions>

## Learn more

- [Magic link](/docs/plugins/magic-link) — link instead of a code
- [Two-factor](/docs/plugins/two-factor) — email OTP / TOTP as a **second** factor after password (distinct from this primary `/auth/otp` sign-in)
- [Channel](/docs/elements/channel) — `fx.send`, `fx.sendOtp`, drivers, Mailpit
- [Gate](/docs/elements/gate) — `gate.auth`

## Next

<Cards>
  <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
  <Card
    title="Two-factor"
    description="TOTP / email OTP second factor."
    href="/docs/plugins/two-factor"
  />
  <Card title="Channel" description="Delivery drivers and Mailpit." href="/docs/elements/channel" />
</Cards>


# Passkey (/docs/plugins/passkey)

`passkey()` adds register and authenticate Flows for WebAuthn credentials
(`oke_passkeys`). Options return a challenge plus a ceremony `sessionId`;
register/authenticate verify client data, authenticator data, and ECDSA P-256.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(passkey())`. Registration needs a Bearer session; authenticate is
  public. Echo the options `sessionId` with every ceremony — UV=false assertions never mint a
  session.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { passkey } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(passkey({ origins: ["http://localhost", "https://localhost"] }));
```

</Step>

<Step>
### Register (session required)

Wire Bearer on `createClient` (`auth.getToken` / `memorySession`) — calls take input only.

```typescript
const opts = await api.auth.passkeyRegisterOptions({});
// opts.data: { challenge, sessionId, rpId, userId }

await api.auth.passkeyRegister({
  credentialId: "...", // base64url
  publicKey: "...", // base64url SPKI (ECDSA P-256)
  userId: opts.data!.userId,
  challenge: opts.data!.challenge,
  sessionId: opts.data!.sessionId,
  clientDataJSON: "...", // base64url JSON { type: "webauthn.create", challenge, origin }
  authenticatorData: "...", // base64url (UP|UV required)
  signature: "...", // base64url ECDSA over authData || SHA-256(clientDataJSON)
});
```

Paths: `POST /auth/passkey/register/options`, `POST /auth/passkey/register`.

</Step>

<Step>
### Authenticate

```typescript
const opts = await api.auth.passkeyAuthenticateOptions({});
const { data } = await api.auth.passkeyAuthenticate({
  credentialId: "...",
  challenge: opts.data!.challenge,
  sessionId: opts.data!.sessionId,
  clientDataJSON: "...", // type must be "webauthn.get"
  authenticatorData: "...",
  signature: "...",
});
```

Paths: `POST /auth/passkey/authenticate/options`, `POST /auth/passkey/authenticate`.
Challenges are single-use and bound to `sessionId`; wrong origin → `invalid_origin`; UV
missing → `user_not_verified`; cloned counter → `reregister_required`.

</Step>

</Steps>

## Server checks

| Check             | Behavior                                                                                                                |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Ceremony type     | `clientDataJSON.type` must be `webauthn.create` (register) or `webauthn.get` (authenticate)                             |
| User verification | AuthenticatorData UV bit must be set — UV=false is always rejected                                                      |
| Challenge binding | Challenge hash + `sessionId` must match; TTL ≤ 5 minutes; single-use                                                    |
| Signature counter | Stored per credential; if stored ≠ 0 and incoming `signCount` ≤ stored → delete credential, warn, `reregister_required` |

## Options

| Option       | Type                | Default                                    | Meaning                          |
| ------------ | ------------------- | ------------------------------------------ | -------------------------------- |
| `secret`     | `string`            | active\*                                   | HMAC secret (\*from `gate.auth`) |
| `sessions`   | `SessionStore`      | active\*                                   | Session store                    |
| `now`        | `() => number`      | `Date.now`                                 | Injectable clock                 |
| `passkeys`   | `PasskeyStore`      | new                                        | Credential → user mapping        |
| `challenges` | `VerificationStore` | new                                        | Registration / auth challenges   |
| `rpId`       | `string`            | `"localhost"`                              | Relying party id                 |
| `origins`    | `string[]`          | `["http://localhost","https://localhost"]` | Allowed `clientDataJSON.origin`  |

## Surfaces

| Flow                              | Path                                      | Gate                     |
| --------------------------------- | ----------------------------------------- | ------------------------ |
| `auth.passkeyRegisterOptions`     | `POST /auth/passkey/register/options`     | session + bearer         |
| `auth.passkeyRegister`            | `POST /auth/passkey/register`             | session + bearer         |
| `auth.passkeyAuthenticateOptions` | `POST /auth/passkey/authenticate/options` | `gate.public` + otp rate |
| `auth.passkeyAuthenticate`        | `POST /auth/passkey/authenticate`         | `gate.public` + otp rate |

**Consequence:** a stolen `credentialId` without the private key (and UV) cannot mint a session.
Set `origins` to your real app origins before production.

## Troubleshooting

<Accordions>
<Accordion title="register fails with unauthenticated">

Sign in with another method first. `userId` in the body must match the Bearer session.

</Accordion>
<Accordion title="authenticate returns invalid_origin">

`clientDataJSON.origin` must be in `passkey({ origins })`. Default allows only localhost HTTP/S.

</Accordion>
<Accordion title="authenticate returns user_not_verified">

AuthenticatorData UV bit was not set. Require user verification on the client
(`userVerification: "required"`) — the server never accepts UV=false.

</Accordion>
<Accordion title="authenticate returns reregister_required">

Signature counter did not increase (possible cloned authenticator). That credential was
deleted — register a fresh passkey for the account.

</Accordion>
<Accordion title="authenticate returns invalid_credentials">

Unknown `credentialId`, wrong/expired/`sessionId` mismatch, bad signature, wrong ceremony
type, or rpId hash mismatch. Re-run authenticate options for a fresh challenge + `sessionId`.

</Accordion>
</Accordions>

## Learn more

- [Two-factor](/docs/plugins/two-factor) — TOTP step-up
- [Gate](/docs/elements/gate) — `gate.auth`
- [Client · Auth](/docs/client/auth) — calling `/auth` from the browser

## Next

<Cards>
  <Card title="Two-factor" description="TOTP enable / verify." href="/docs/plugins/two-factor" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Anonymous" description="Guest sessions." href="/docs/plugins/anonymous" />
</Cards>


# Two-factor (/docs/plugins/two-factor)

`twoFactor()` adds a second factor after password (or username) sign-in: RFC 6238
TOTP or email OTP. When 2FA is enabled, first-factor success withholds session
tokens and returns a **method-locked** challenge.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(twoFactor())`. Login challenges lock the configured method (`totp`
  or `email_otp`). Mid-challenge QR enrollment returns `Forbidden`. Method change needs step-up
  verify → provision → confirm.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { twoFactor } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(twoFactor());
```

</Step>

<Step>
### First-time enable (session required)

Wire Bearer on `createClient` (`auth.getToken` / `memorySession`) — calls take input only.

```typescript
const { data } = await api.auth.twoFactorEnable({});
// data.secret, data.otpauthUrl, data.recoveryCodes, data.method === "totp"
```

`POST /auth/two-factor/enable` — writes `oke_two_factor`. Store recovery codes once.
Re-enrollment when 2FA is already enabled requires step-up first
(`twoFactorStepUp`). An active login challenge → `Forbidden` / `active_2fa_challenge`.

</Step>

<Step>
### Sign-in then verify

```typescript
const signIn = await api.auth.signInUsername({ username, password });
if ("twoFactorRequired" in signIn.data && signIn.data.twoFactorRequired) {
  const { data } = await api.auth.twoFactorVerify({
    challengeId: signIn.data.challengeId,
    code: "123456",
  });
  // hybrid session tokens
}
```

`POST /auth/two-factor/verify` takes `{ challengeId, code }` — not a bare
`userId`. Missing challenge, method mismatch, or bad code → `AuthFailed` /
`invalid_credentials`.

</Step>

</Steps>

## Method lock

When a login challenge is issued, the active method is recorded on the pending
challenge. Until that challenge is consumed or expires:

- Only codes for the **locked** method are accepted.
- `twoFactorEnable`, `twoFactorChangeMethod`, `twoFactorConfirmChange`, and
  `twoFactorDisable` return `Forbidden` with `active_2fa_challenge` if called
  for that user (including with an older Bearer session).

This closes the July–August 2026 method-switching bypass pattern (switch to
TOTP enrollment mid email-OTP challenge without proving the current factor).

## Step-up and method change

1. `POST /auth/two-factor/step-up` — `{ code, purpose }` verifies the **current**
   method and grants a short-lived step-up (`enroll` | `change` | `disable`).
2. `POST /auth/two-factor/change-method` — `{ method, email? }` provisions the new
   method in a pending state (TOTP QR or email OTP). Requires step-up.
3. `POST /auth/two-factor/confirm-change` — `{ code }` activates the new method and
   **immediately invalidates** the old secret / recovery codes.

Disable also requires step-up when 2FA is already enabled.

## Options

| Option          | Type                    | Default    | Meaning                                      |
| --------------- | ----------------------- | ---------- | -------------------------------------------- |
| `secret`        | `string`                | active\*   | HMAC secret (\*from `gate.auth`)             |
| `sessions`      | `SessionStore`          | active\*   | Session store                                |
| `now`           | `() => number`          | `Date.now` | Injectable clock                             |
| `factors`       | `TwoFactorStore`        | new        | Per-user method + TOTP / recovery            |
| `issuer`        | `string`                | `"oke"`    | Label in `otpauth://` URLs                   |
| `pending`       | `PendingTwoFactorStore` | active\*   | Login challenges                             |
| `stepUp`        | `StepUpStore`           | active\*   | Privileged-op grants                         |
| `verifications` | `VerificationStore`     | active\*   | Email OTP as second factor (`2fa:{userId}`)  |
| `exposeDevOtp`  | `boolean`               | `false`    | Return `devOtp` on email_otp challenge paths |

## Surfaces

| Flow                            | Path                                      | Gate                     |
| ------------------------------- | ----------------------------------------- | ------------------------ |
| `auth.twoFactorEnable`          | `POST /auth/two-factor/enable`            | session + bearer         |
| `auth.twoFactorVerify`          | `POST /auth/two-factor/verify`            | `gate.public` + otp rate |
| `auth.twoFactorStepUp`          | `POST /auth/two-factor/step-up`           | session + bearer         |
| `auth.twoFactorChangeMethod`    | `POST /auth/two-factor/change-method`     | session + bearer         |
| `auth.twoFactorConfirmChange`   | `POST /auth/two-factor/confirm-change`    | session + bearer         |
| `auth.twoFactorRequestEmailOtp` | `POST /auth/two-factor/request-email-otp` | public + otp rate        |
| `auth.twoFactorDisable`         | `POST /auth/two-factor/disable`           | session + bearer         |

**Consequence:** email/password and username sign-in return
`{ twoFactorRequired, challengeId, method, userId }` (no tokens) when the
account has 2FA enabled. Complete login only via `twoFactorVerify`.

## Troubleshooting

<Accordions>
<Accordion title="twoFactorEnable returns Forbidden active_2fa_challenge">

Finish or wait out the login 2FA challenge first. Enrollment is blocked while
an unresolved challenge exists for that user.

</Accordion>
<Accordion title="twoFactorEnable returns Forbidden step_up_required">

2FA is already enabled. Call `twoFactorStepUp` with a valid current-method code
(`purpose: "enroll"`), then enable again.

</Accordion>
<Accordion title="twoFactorEnable returns AuthFailed unauthenticated">

Wire a session into `createClient` (`auth.getToken` / `memorySession`) first —
enable is gated on Bearer. First-time enroll must happen **before** 2FA is
required on sign-in (or after a full session from a completed challenge).

</Accordion>
<Accordion title="verify always fails">

Submit `{ challengeId, code }` from the sign-in challenge response. TOTP must be
six digits (±1 window). Email OTP uses the code from the locked `email_otp`
challenge. A recovery code works once for TOTP, then is consumed.

</Accordion>
</Accordions>

## Learn more

- [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
- [Gate](/docs/elements/gate) — session + policies
- [Username](/docs/plugins/username) — first factor to enroll against
- Primary OTP sign-in (not a second factor)? See [OTP](/docs/plugins/otp) /
  [Magic link](/docs/plugins/magic-link)

## Next

<Cards>
  <Card title="Passkey" description="WebAuthn register and assert." href="/docs/plugins/passkey" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
</Cards>


# Username (/docs/plugins/username)

`username()` lets people register and sign in with a username instead of email. It adds two public
Flows under `/auth` and contributes the `oke_usernames` table.

<Callout title="The one rule">
  Enable `gate.auth` first, then `.plug(username())`. The plugin `.needs("auth")` and joins the HTTP
  router via Bindings — not registry metadata alone.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { username } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(username());
```

</Step>

<Step>
### Sign up

```typescript
const { data, error } = await api.auth.signUpUsername({
  username: "ali",
  password: "CorrectHorse1",
});
```

`POST /auth/sign-up/username` — usernames are normalized to lowercase; allowed pattern
`[a-z0-9._-]{3,64}`.

</Step>

<Step>
### Sign in

```typescript
const { data } = await api.auth.signInUsername({
  username: "ali",
  password: "CorrectHorse1",
});
// data: accessToken, refreshToken, accessExpiresAt, userId
```

Unknown username and bad password both return `AuthFailed` with
`reason: "invalid_credentials"` (enumeration-safe).

</Step>

</Steps>

## Options

| Option      | Type            | Default    | Meaning                                                     |
| ----------- | --------------- | ---------- | ----------------------------------------------------------- |
| `secret`    | `string`        | active\*   | HMAC secret (\*from `gate.auth` when plugged after `oke()`) |
| `sessions`  | `SessionStore`  | active\*   | Session store shared with Gate auth                         |
| `now`       | `() => number`  | `Date.now` | Injectable clock                                            |
| `usernames` | `UsernameStore` | new map    | Shared in-memory credential store                           |

## Surfaces

| Flow                  | Path                          | Gate                         |
| --------------------- | ----------------------------- | ---------------------------- |
| `auth.signUpUsername` | `POST /auth/sign-up/username` | `gate.public` + sign-up rate |
| `auth.signInUsername` | `POST /auth/sign-in/username` | `gate.public` + sign-in rate |

## Troubleshooting

<Accordions>
<Accordion title="plugin boot failed — needs &quot;auth&quot;">

Set `oke({ gate: { auth: { … } } })` before `.plug(username())`.

</Accordion>
<Accordion title="AuthFailed invalid_credentials on sign-up">

Username taken, or it fails the `[a-z0-9._-]{3,64}` pattern after lowercasing. Same error shape
on purpose — do not treat it as "exists" in the UI.

</Accordion>
</Accordions>

## Learn more

- [Gate](/docs/elements/gate) — `gate.auth` and posture
- [Plugins](/docs/plugins) — all auth method plugins
- [Client · Auth](/docs/client/auth) — `createClient` + `memorySession`

## Next

<Cards>
  <Card
    title="Anonymous"
    description="Session without a password."
    href="/docs/plugins/anonymous"
  />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
</Cards>


# X (/docs/plugins/x)

X is the one provider where `oauth()` behaves as a **public client**: the
token exchange carries the code verifier but no secret. PKCE is the only
proof the callback is yours.

<Callout title="The one rule">
  Enable OAuth 2.0 in the X Developer Portal, set up your exact callback URI, and request
  `users.email` access if you need addresses. No `OAUTH_X_CLIENT_SECRET` exists — none is read.
</Callout>

## Quick start

<Steps>

<Step>
### Configure the app

X Developer Portal → Project → User authentication settings → **Set up**.
Choose _Web App_, enable OAuth 2.0, and add
`https://app.example.com/auth/oauth/callback/x` as a callback URI.

</Step>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { oauth } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  oauth({
    baseUrl: "https://app.example.com",
    providers: {
      x: { enabled: true },
    },
  }),
);
```

</Step>

</Steps>

That is the whole setup — no Vault contract for this provider.

## How identity works

| Aspect         | Behavior                                                                                    |
| -------------- | ------------------------------------------------------------------------------------------- |
| Token exchange | `POST https://api.x.com/2/oauth2/token` with the code verifier, no secret                   |
| Profile        | `GET /2/users/me?user.fields=id,name,username,confirmed_email`                              |
| Subject        | the string `id` inside `data`                                                               |
| Email trust    | **always unverified** — `confirmed_email` gates API access, it does not attest verification |

**Consequence:** an X email never claims an existing account during sign-up.
It can attach to a fresh account or to an existing one you already control via
authenticated linking.

Default scopes: `users.read`, `tweet.read`.

## Options

| Option                | Type       | Default         | Meaning                            |
| --------------------- | ---------- | --------------- | ---------------------------------- |
| `enabled`             | `boolean`  | `false`         | Turn the provider on               |
| `clientId`            | `string`   | Vault/env\*     | `\*OAUTH_X_CLIENT_ID`              |
| `redirectUri`         | `string`   | `{baseUrl}…/x`  | Exact registered URI               |
| `scopes`              | `string[]` | driver defaults | Extra scopes (`offline.access`, …) |
| `storeProviderTokens` | `boolean`  | `false`         | Keep tokens in Vault               |

## Surfaces

| Flow     | Path                              |
| -------- | --------------------------------- |
| Start    | `POST /auth/oauth/x/start`        |
| Callback | `GET+POST /auth/oauth/callback/x` |
| Link     | `POST /auth/oauth/x/link`         |

## Troubleshooting

<Accordions>
<Accordion title="invalid_request on token exchange">

Callback URIs must match exactly and the code must be fresh. X codes are
single-use and short-lived; flow rows expire after ten minutes too.

</Accordion>
<Accordion title="No email ever arrives">

`confirmed_email` requires elevated access plus the `users.email` scope. Even
then the address stays unverified by design — see the trust table above.

</Accordion>
<Accordion title="403 forbidden">

Your app tier lacks user-context authentication, or the requested scope set
exceeds what the portal grants.

</Accordion>
</Accordions>

## Learn more

- [OAuth](/docs/plugins/oauth) — shared flows and security model
- [Facebook](/docs/plugins/facebook) — also never-verified emails
- [Vault](/docs/elements/vault) — optional token storage

## Next

<Cards>
  <Card title="Facebook" description="Never-verified emails." href="/docs/plugins/facebook" />
  <Card title="Figma" description="Basic-auth token endpoint." href="/docs/plugins/figma" />
  <Card title="Discord" description="Nullable emails." href="/docs/plugins/discord" />
</Cards>


# Agents (/docs/elements/ai/agents)

An agent (`ai.agent`) is a **bounded tool-calling loop**. Tools are your app’s Flows (and
allowlisted MCP refs) — each step goes through `fx.call`. The loop stops at `maxSteps` or
`budget.maxCostPerRun`.

For developers who need a planner or support assistant without writing a custom tool runtime —
declare the bag, run with `fx.run`.

<Callout title="The one rule">
  Pass Flow names (or handles) in `tools`, set `maxSteps` and `budget.maxCostPerRun`, then
  `fx.run(agent, {message})`. There is no `instructions` / `in` / `out` on `ai.agent` — shape the
  user message yourself.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare model + agent

```typescript title="src/core/ai.ts"
import { ai } from "okengine";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const supportAgent = ai.agent("support.assistant", {
  model: "smart",
  tools: ["docs.search", "tickets.create"],
  maxSteps: 5,
  budget: { maxCostPerRun: 0.1 },
});
```

</Step>

<Step>
### Run from a Flow

```typescript title="src/flows/support/assist.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { supportAgent } from "@/core/ai";
import { member } from "@/core/gate";

export const assist = on(
  http
    .post({
      in: z.object({ query: z.string().min(1) }),
    })
    .gate(member),
  flow({
    do: async ({ query }, fx) => {
      return await fx.run(supportAgent, {
        message: `Help the member: ${query}`,
      });
    },
  }),
);
```

Runtime records the agent name under `asks`. Prefer declaring `effects: { asks: ["support.assistant"] }`
when inference does not see `fx.run`.

</Step>

<Step>
### Bound termination

Default `maxSteps` is **6** when omitted. Hitting the step cap ends the loop; exceeding
`budget.maxCostPerRun` throws `AiBudgetExceededError`:
`ai: agent "…" exceeded maxCostPerRun N`.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Flow tools", "MCP tools", "Ask tools", "Budget"]}>

<Tab value="Flow tools">

String names or Flow handles — same capability path as `fx.call`:

```typescript
import { searchDocs } from "@/flows/docs/search";
import { createTicket } from "@/flows/tickets/create";

export const supportAgent = ai.agent("support.assistant", {
  model: smart,
  tools: [searchDocs, createTicket],
  maxSteps: 8,
});
```

</Tab>

<Tab value="MCP tools">

Outbound MCP refs join the same bag (`mcp:<server>/<tool>`):

```typescript
const github = ai.mcpServer("github", {
  url: "https://mcp.example/github",
  auth: { bearer: vault.secret("GITHUB_MCP_TOKEN") },
  tools: ["create_issue"],
});

export const planner = ai.agent("planner", {
  model: "smart",
  tools: ["tasks.list", github.tool("create_issue")],
  maxSteps: 8,
  budget: { maxCostPerRun: 0.25 },
});
```

See [MCP](/docs/elements/ai/mcp).

</Tab>

<Tab value="Ask tools">

One-shot tool loop on `fx.ask` without a named agent:

```typescript
await fx.ask(triage, input, {
  tools: ["docs.search"],
  maxSteps: 3,
});
```

</Tab>

<Tab value="Budget">

Run cost is on `budget`, not a top-level `maxCostPerRun` field:

```typescript
ai.agent("planner", {
  tools: ["tasks.list", "tasks.create"],
  budget: { maxCostPerRun: 0.25, maxCostPerCall: 0.05 },
});
```

</Tab>

</Tabs>

## Options

| Option     | Type                                  | Default     | Meaning                      |
| ---------- | ------------------------------------- | ----------- | ---------------------------- |
| `model`    | `AiModelDecl` \| `string`             | first model | Logical binding for the loop |
| `tools`    | Flow / MCP refs                       | `[]`        | Callable via `fx.call`       |
| `maxSteps` | `number`                              | `6`         | Hard cap on tool rounds      |
| `budget`   | `{ maxCostPerCall?, maxCostPerRun? }` | —           | Cost contracts for the run   |

## `fx.run` input

Pass a string or `{ message: string }` (and any extra fields your tools need in context). The
agent declaration does **not** take Zod `in` / `out` — validate on the surrounding Flow.

```typescript
await fx.run(supportAgent, "Summarize open tickets");
await fx.run(supportAgent, { message: "Summarize open tickets" });
```

## Agents vs ask-with-tools

| Surface                        | When                                         |
| ------------------------------ | -------------------------------------------- |
| `ai.agent` + `fx.run`          | Reusable tool bag, shared step/budget policy |
| `fx.ask(prompt, …, { tools })` | One prompt, occasional tools                 |

Both default `maxSteps` to `6`. Denied or unknown tools surface as
`ai: all tool calls denied for prompt "…"` / `ai: model requested unknown tool "…"`.

## Troubleshooting

<Accordions>

<Accordion title='ai: unknown agent "…"'>
  `fx.run` named an agent that was never declared, or the declaring module was not imported before
  `oke()`.
</Accordion>

<Accordion title='ai: agent "…" exceeded maxCostPerRun'>
  `AiBudgetExceededError` — raise `budget.maxCostPerRun`, lower `maxSteps`, or shrink tools.
</Accordion>

<Accordion title='ai: model requested unknown tool "…"'>
  The model emitted a tool name outside the declared `tools` bag. Align names with Flow / MCP refs,
  or tighten instructions in the user `message`.
</Accordion>

<Accordion title="OKE1005 on agent runs">
  Runtime gates `fx.run` as an ask of the agent name. List `effects: { asks: ["support.assistant"] }`
  when the compiler does not infer `fx.run`.
</Accordion>

</Accordions>

## Learn more

- [Prompts](/docs/elements/ai/prompts) — `fx.ask` and ask-time tools
- [MCP](/docs/elements/ai/mcp) — inbound tools and outbound servers
- [Flow](/docs/elements/flow) — tools are ordinary Flows
- [AI](/docs/elements/ai) — guardrails figure and drivers

## Next

<Cards>
  <Card
    title="MCP"
    description="Expose and consume Model Context Protocol tools."
    href="/docs/elements/ai/mcp"
  />
  <Card
    title="Prompts"
    description="Versioned fx.ask artifacts."
    href="/docs/elements/ai/prompts"
  />
  <Card title="AI" description="Element overview." href="/docs/elements/ai" />
</Cards>


# Overview (/docs/elements/ai)

AI is how your backend **calls a model as a declared effect**. Ticket triage, a weekly
summary, a planner that tools your own Flows — each ask is a named prompt or agent with
budgets, not an SDK call buried in `do`.

For developers wiring OpenRouter or any OpenAI-compatible `/v1` endpoint — bind a model,
mint a prompt, ask through `fx`.

<Callout title="The one rule">
  Declare `ai.model` → `model.prompt` (or `ai.agent` / `ai.embed`), then call through `fx.ask` /
  `fx.run` / `fx.embed`. There is no top-level `ai.prompt`. Budgets and `maxSteps` are contracts;
  PII to a third-party model needs explicit `allowPii`.
</Callout>


> Four AI building blocks — model bindings, versioned prompts, embedding pipelines into store.index, and bounded agents whose tools are flows.


## Smallest Example

<Steps>

<Step>
### Bind a model and mint a prompt

```typescript title="src/core/ai.ts"
import { ai } from "okengine";
import { z } from "zod";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const triage = smart.prompt("ticket-triage", {
  version: 1,
  budget: { maxCostPerCall: 0.02 },
  out: z.object({
    category: z.enum(["billing", "technical", "other"]),
    urgency: z.enum(["low", "medium", "high"]),
  }),
});
```

Import this module before `oke()` so registries adopt the decls.

</Step>

<Step>
### Ask from a Flow

```typescript title="src/flows/support/triage.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { triage } from "@/core/ai";

export const classify = on(
  http.post({
    in: z.object({ message: z.string().min(1) }),
  }),
  flow({
    do: async ({ message }, fx) => {
      return await fx.ask(triage, { message });
    },
  }),
);
```

The compiler stamps `asks: ["ticket-triage"]` on the Flow’s effects.

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/support/triage \
  -H "accept: application/json" \
  -H "content-type: application/json" \
  -d '{"message":"Invoice double-charged last week"}'
```

With `out` set, the model must return JSON matching the schema or the ask throws
`AiSchemaValidationError`.

</Step>

</Steps>

## Progressive Patterns

From a bare ask to fallback models, tools on ask, and a bounded agent:

<Tabs items={["Minimal", "via", "Tools", "Agent"]}>

<Tab value="Minimal">

Prompt handle + input — model comes from the parent `ai.model`:

```typescript
const result = await fx.ask(triage, { message: "Cannot reset password" });
```

</Tab>

<Tab value="via">

Ordered recovery across logical bindings. Each name opens its **own** client — keys and
`baseUrl` never mix:

```typescript
export const triage = smart.prompt("ticket-triage", {
  version: 1,
  via: ["smart", "local"],
  out: z.object({ category: z.string() }),
});
```

Override per call with `fx.ask(triage, input, { via: ["local"] })`.

</Tab>

<Tab value="Tools">

Offer Flows as tools on a single ask. Each invocation goes through `fx.call`
(default `maxSteps: 6`):

```typescript
await fx.ask(
  triage,
  { message },
  {
    tools: [searchDocs, createTicket],
    maxSteps: 4,
  },
);
```

**Consequence:** undeclared tools fail capability checks the same way as `fx.call`.

</Tab>

<Tab value="Agent">

Multi-step loop with a declared tool bag and run budget:

```typescript
export const supportAgent = ai.agent("support.assistant", {
  model: "smart",
  tools: ["docs.search", "tickets.create"],
  maxSteps: 5,
  budget: { maxCostPerRun: 0.1 },
});

// In a Flow:
return await fx.run(supportAgent, { message: "Help with billing" });
```

</Tab>

</Tabs>

## Declaration Reference

| Declaration    | Signature                      | Purpose                                       |
| -------------- | ------------------------------ | --------------------------------------------- |
| `ai.model`     | `ai.model(name, options?)`     | Logical binding (provider / wire model / key) |
| `model.prompt` | `model.prompt(name, options?)` | Versioned prompt on that binding              |
| `ai.agent`     | `ai.agent(name, options?)`     | Bounded tool-calling agent                    |
| `ai.embed`     | `ai.embed(name, options?)`     | Embedding pipeline into `store.index`         |
| `ai.mcpServer` | `ai.mcpServer(name, options)`  | Outbound MCP client (allowlisted tools)       |

There is **no** `ai.prompt(...)` — mint prompts only via `model.prompt`.

## `fx` surface

| Method                           | Capability          | Meaning                                          |
| -------------------------------- | ------------------- | ------------------------------------------------ |
| `fx.ask(prompt, input?, opts?)`  | `asks`              | Complete a versioned prompt (optional tool loop) |
| `fx.run(agent, input?)`          | `asks` (agent name) | Run a declared agent                             |
| `fx.stream(model, opts?)`        | `asks`              | Token stream from a model binding                |
| `fx.embed(model, text)`          | `embeds`            | Return a vector (does not write an index)        |
| `fx.search(embed, query, opts?)` | `reads`             | Similarity search over an embed / index          |

### `fx.ask` options

| Option     | Type          | Default                  | Meaning                        |
| ---------- | ------------- | ------------------------ | ------------------------------ |
| `via`      | refs          | prompt `via` / `[model]` | Recovery chain for this call   |
| `timeout`  | `"30s"` \| ms | prompt `timeout`         | Per-call deadline              |
| `tools`    | Flow refs     | —                        | Offered as tools via `fx.call` |
| `maxSteps` | `number`      | `6`                      | Cap on tool rounds             |

Undeclared ask → **OKE1005** `UNDECLARED_ASK`. Undeclared embed → **OKE1009**.

## Guardrails

Budgets, version pins, step limits, and PII egress are contracts — not guidelines:


> AI guardrails chain: versioned prompt, PII build gate, maxSteps and budget, then prod driver. First denial wins — AiPiiBuildError without allowPii, AiBudgetExceededError on runaway cost, or missing prod driver. Later stages are skipped. fx.ask proceeds only when every contract cleared.


### PII egress

Sending a `.pii()` field to a **third-party** provider fails the build unless the Flow
declares `allowPii: true` (or `pii: "allow"`). `mock`, `local`, and `openai-compatible` are
not third-party egress.


> PII egress physics: sending a .pii() field to anthropic fails the build without allowPii; the same ask against openai-compatible local is on-premise and proceeds.


```typescript
flow({
  allowPii: true,
  do: async (input, fx) => fx.ask(triage, { email: input.email }),
});
```

## Per-environment drivers

`AiDriverId`: `mock` · `anthropic` · `openai-compatible` · `bedrock` · `vertex`.

Boot implements `mock`, `anthropic`, and `openai-compatible`. `bedrock` / `vertex` are
reserved and throw until implemented. Dev/test fall back to **`mock`** when unset — **prod
must declare** `drivers.ai`.

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    ai: {
      dev: "openai-compatible",
      test: "mock",
      prod: "openai-compatible",
    },
  },
});
```

| Driver              | Runs as             | Best for                                       |
| ------------------- | ------------------- | ---------------------------------------------- |
| `mock`              | In-process stub     | Tests, CI, no network                          |
| `openai-compatible` | OpenAI HTTP API     | OpenRouter, cloud registries, any `/v1` server |
| `anthropic`         | Native Messages API | Production Claude + reliable tools             |

Env knobs: `OKE_AI_DRIVER`, `OKE_AI_URL` / `OPENAI_BASE_URL`, provider API keys.
Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes/openrouter).

## The Capabilities of AI

<Cards>
  <Card
    title="Models"
    description="Verified providers, limited-compatibility caveats, cloud and BYO bindings."
    href="/docs/elements/ai/models"
  />
  <Card
    title="Prompts"
    description="Versioned artifacts, via chains, budgets, out-schema validation."
    href="/docs/elements/ai/prompts"
  />
  <Card
    title="Agents"
    description="Tool bags are Flows — maxSteps and maxCostPerRun bound the loop."
    href="/docs/elements/ai/agents"
  />
  <Card
    title="Model Context Protocol"
    description="Expose Flows as MCP tools; consume external servers via ai.mcpServer."
    href="/docs/elements/ai/mcp"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title='ai: unknown prompt "…"'>
  `fx.ask` named a prompt that was never minted with `model.prompt`, or the declaring module was not
  imported before `oke()`.
</Accordion>

<Accordion title="OKE1005 — UNDECLARED_ASK">
  Cause: `Flow "{flow}" asks "{resource}" without declaring it.` Touch the prompt handle
  inside `do` (inference) or list `effects: { asks: ["ticket-triage"] }`.
</Accordion>

<Accordion title="AiSchemaValidationError / AiSchemaInvalid">
  Cause: `ai: schema validation failed for prompt "…"@N: …` The model reply did not match `out`. Fix
  the schema, the input, or the model — there is no automatic “schema correction” retry.
</Accordion>

<Accordion title="AiBudgetExceededError">
  Cause: `ai: prompt "…" exceeded maxCostPerCall N` or `ai: agent "…" exceeded maxCostPerRun N`.
  Raise the budget or shrink the work.
</Accordion>

<Accordion title="build failed: … without allowPii">
  Cause: `build failed: flow "…" sends pii field(s) […] to a third-party model without allowPii`.
  Set `allowPii: true` on the Flow, or keep the ask on `mock` / `local` / `openai-compatible`.
</Accordion>

<Accordion title="oke boot: unknown AI driver / reserved">
  Prod must pin a real `drivers.ai` id. `bedrock` and `vertex` throw `oke boot: AI driver "…" is
  reserved but not implemented yet`.
</Accordion>

</Accordions>

## Learn more

- [Models](/docs/elements/ai/models) — provider registry and `baseUrl` rules
- [Prompts](/docs/elements/ai/prompts) — `via`, budgets, versions, evals
- [Agents](/docs/elements/ai/agents) — `fx.run`, tools, `maxSteps`
- [MCP](/docs/elements/ai/mcp) — inbound `mcp.tool` and outbound `ai.mcpServer`
- [OpenRouter](/docs/recipes/openrouter) — zero-Docker cloud path
- [fx](/docs/reference/fx) — `fx.ask` / `fx.run` / `fx.embed`
- [Errors](/docs/reference/errors) — OKE1005 · OKE1009

## Next

<Cards>
  <Card
    title="Models"
    description="Bind providers and wire model ids."
    href="/docs/elements/ai/models"
  />
  <Card
    title="Channel Element"
    description="Reach humans with email, SMS, WhatsApp, and push."
    href="/docs/elements/channel"
  />
  <Card
    title="The Model"
    description="Eight elements overview."
    href="/docs/understand/the-architecture"
  />
</Cards>


# MCP (/docs/elements/ai/mcp)

Model Context Protocol (MCP) connects OKE to Cursor, Claude Desktop, and other agents in
**two directions**: your app exposes selected Flows as tools, and your Flows call tools on
external MCP servers.

For developers wiring Cursor to a backend or letting an agent open a GitHub issue — opt-in
gates inbound; allowlists outbound.

<Callout title="The one rule">
  Inbound: `on(mcp.tool("name").gate(...), flow)` — ungated tools are not exposed. Outbound:
  `ai.mcpServer` with a required `tools` allowlist — the runtime never trusts raw `tools/list`.
</Callout>

## Smallest Example — expose a Flow

<Steps>

<Step>
### Bind a gated MCP tool

```typescript title="src/flows/bookings/create.ts"
import { on, flow, mcp } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

export const create = on(
  mcp
    .tool("bookings.create", {
      in: z.object({
        guest: z.string().min(1),
        night: z.string(),
      }),
      out: z.object({ id: z.string(), guest: z.string(), night: z.string() }),
    })
    .gate(member),
  flow({
    do: async (input, fx) => {
      return { id: fx.id(), ...input };
    },
  }),
);
```

Deny-by-default: `on(mcp.tool("…"), flow)` without `.gate(...)` fails gate posture at boot.

</Step>

<Step>
### Connect a client

App MCP listens on port **6535**, path **`/mcp`** (not the backend `:6530` HTTP port). Bearer
auth is required.

```json
{
  "mcpServers": {
    "notes": {
      "url": "http://127.0.0.1:6535/mcp",
      "headers": {
        "Authorization": "Bearer <token>"
      }
    }
  }
}
```

`GET /health` on the same port probes liveness. Override with `ports.mcp` in config when needed.

</Step>

<Step>
### Call the tool

The client lists `bookings.create` (and other gated tools). Invocations run the Flow under the
same gate chain as HTTP.

</Step>

</Steps>

## Smallest Example — consume a server

<Steps>

<Step>
### Declare an outbound server

```typescript title="src/core/mcp.ts"
import { ai, vault } from "okengine";

const githubToken = vault.secret("GITHUB_MCP_TOKEN");

export const github = ai.mcpServer("github", {
  url: "https://mcp.example/github",
  auth: { bearer: githubToken },
  tools: ["create_issue", "list_repos"],
});

export const createIssue = github.tool("create_issue"); // mcp:github/create_issue
```

Exactly one of `url` (Streamable HTTP) or `command` (+ optional `args` for stdio). Bearer must
be a `vault.secret` handle or contract name — never a token literal in source.

</Step>

<Step>
### Use in ask / agent tools

```typescript
await fx.ask(triage, input, {
  tools: [github.tool("create_issue")],
  maxSteps: 2,
});
```

Capability ref is `mcp:github/create_issue`. Model-facing names use `server__tool`
(`github__create_issue`).

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Inbound gate", "stdio server", "Agent bag", "Docs MCP"]}>

<Tab value="Inbound gate">

Chain policies like HTTP — first denial wins:

```typescript
on(
  mcp.tool("admin.wipe").gate(member, gate.scope("admin")),
  flow({ do: async (_input, fx) => fx.json.empty() }),
);
```

</Tab>

<Tab value="stdio server">

Local executable transport:

```typescript
export const localTools = ai.mcpServer("local", {
  command: "npx",
  args: ["-y", "some-mcp-server"],
  tools: ["lookup"],
});
```

</Tab>

<Tab value="Agent bag">

Mix Flows and MCP refs on one agent:

```typescript
ai.agent("planner", {
  model: "smart",
  tools: ["tasks.create", github.tool("create_issue")],
  maxSteps: 8,
  budget: { maxCostPerRun: 0.25 },
});
```

</Tab>

<Tab value="Docs MCP">

Read-only docs MCP is a **separate** process on port **6536** — not your app’s `:6535` tool
server. Use it for documentation search, not business Flows.

</Tab>

</Tabs>

## Inbound reference

| Piece   | Detail                                                 |
| ------- | ------------------------------------------------------ |
| Trigger | `mcp.tool(name, { in, out, errors? })` from `okengine` |
| Gates   | `.gate(...)` required for exposure                     |
| Port    | **6535** (`ports.mcp`)                                 |
| Path    | `POST /mcp` · `GET /health`                            |
| Auth    | Bearer required (`Bearer token required`)              |

Empty name throws `TypeError: mcp.tool(name): name is required`.

## Outbound reference — `ai.mcpServer`

| Option        | Type          | Required      | Meaning                            |
| ------------- | ------------- | ------------- | ---------------------------------- |
| `url`         | `string`      | XOR `command` | Streamable HTTP endpoint           |
| `command`     | `string`      | XOR `url`     | stdio executable (no shell string) |
| `args`        | `string[]`    | no            | Arguments for `command`            |
| `auth.bearer` | secret / name | no            | Vault contract for Bearer          |
| `tools`       | `string[]`    | **yes**       | Allowlist — never raw `tools/list` |

| Method              | Returns                       | Meaning                                    |
| ------------------- | ----------------------------- | ------------------------------------------ |
| `server.tool(name)` | `{ name: "mcp:server/tool" }` | Capability ref for ask / agent / `fx.call` |

### Declare errors

| Message                                 | Fix                                         |
| --------------------------------------- | ------------------------------------------- |
| `ai.mcpServer: name is required`        | Pass a non-empty server id                  |
| `name "…" must not contain "/" or "__"` | Use a simple id (`github`, not `org/repo`)  |
| `tools allowlist is required`           | Pass `tools: ["…"]`                         |
| `declare exactly one of url or command` | Pick HTTP **or** stdio                      |
| `tool "…" is not in the allowlist`      | Add the name to `tools` before `.tool(...)` |

## Ports

| Port     | Surface                  |
| -------- | ------------------------ |
| **6530** | Backend HTTP             |
| **6535** | App MCP (Flows as tools) |
| **6536** | Docs MCP (read-only)     |

Mnemonic: O·K·E = 6·5·3.

## Troubleshooting

<Accordions>

<Accordion title="Bearer token required">
  Clients must send `Authorization: Bearer …`. Missing or invalid Bearer is rejected before tool
  dispatch.
</Accordion>

<Accordion title="Tool missing from Cursor / client list">
  The Flow is not bound with `mcp.tool(…).gate(…)`, gates deny the identity, or the client points at
  `:6530` instead of `:6535/mcp`.
</Accordion>

<Accordion title="Ungated mcp.tool fails at boot">
  Deny-by-default posture: attach at least one gate. Public-style exposure still needs an explicit
  gate policy you intend (there is no `.public()` on MCP triggers).
</Accordion>

<Accordion title="ai.mcpServer allowlist / transport errors">
  See the declare-error table above. Runtime `ai.mcp: …` covers unknown server, non-allowlisted
  tool, and unsupported HITL prompts from the remote.
</Accordion>

</Accordions>

## Learn more

- [Agents](/docs/elements/ai/agents) — MCP refs in tool bags
- [Gate](/docs/elements/gate) — policies on `mcp.tool`
- [Vault](/docs/elements/vault) — bearer secret contracts
- [AI](/docs/elements/ai) — element overview
- [Security](/docs/reference/security) — ports 6530 · 6533 · 6535 · 6536

## Next

<Cards>
  <Card
    title="Agents"
    description="Run bounded agents that call MCP tools."
    href="/docs/elements/ai/agents"
  />
  <Card title="AI" description="Models, prompts, and fx.ask." href="/docs/elements/ai" />
  <Card title="Flow" description="Flows are the tools MCP exposes." href="/docs/elements/flow" />
</Cards>


# Models (/docs/elements/ai/models)

An `ai.model` binding names a logical model (`smart`, `local`, …), the wire `model` id, and how
to reach it (`provider`, optional `baseUrl` / `apiKey` / `driverId`).

For developers swapping OpenRouter for a self-hosted OpenAI-compatible `/v1` — one logical
name, different bindings per environment.

<Callout title="The one rule">
  Known OpenAI-compatible `provider` names resolve `baseUrl` automatically. Explicit `baseUrl`
  always wins. Unknown providers require `baseUrl` — they fail loud. Per-binding `apiKey` isolates
  tokens when several providers run together.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare cloud + BYO bindings

```typescript title="src/core/ai.ts"
import { ai } from "okengine";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const local = ai.model("local", {
  provider: "openai-compatible",
  model: process.env.OKE_AI_MODEL ?? "your-model-id",
  ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),
});
```

OpenRouter fills `https://openrouter.ai/api/v1` without typing it. Any
OpenAI-compatible `/v1` uses the same driver — set `OKE_AI_URL` yourself;
Compose does not manage inference.

</Step>

<Step>
### Attach a versioned prompt

```typescript
export const triage = smart.prompt("ticket-triage", {
  version: 1,
  via: ["smart", "local"],
});
```

</Step>

<Step>
### Ask from a Flow

```typescript title="src/flows/support/triage.ts"
import { on, flow, http } from "okengine";
import { triage } from "@/core/ai";

export const classify = on(
  http.post(),
  flow({
    do: async (input, fx) => await fx.ask(triage, input),
  }),
);
```

`fx.ask` uses the prompt’s model chain. Recovery via `via` opens a **separate** client per
binding — keys and base URLs do not mix.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["OpenRouter", "BYO /v1", "Native Anthropic", "Multi-key"]}>

<Tab value="OpenRouter">

Zero Docker — registry fills the URL:

```typescript
export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});
```

See [OpenRouter](/docs/recipes/openrouter) for router aliases (`openrouter/auto`, fusion, …).

</Tab>

<Tab value="BYO /v1">

Same `openai-compatible` driver — you supply the base URL (no Compose AI recipe):

```typescript
export const local = ai.model("local", {
  provider: "openai-compatible",
  model: process.env.OKE_AI_MODEL ?? "your-model-id",
  baseUrl: process.env.OKE_AI_URL, // must end in /v1
});
```

Pin `drivers.ai.dev: "openai-compatible"`. Set `OKE_AI_URL` / `OKE_AI_MODEL`
in env, or pass `baseUrl` on the binding.

</Tab>

<Tab value="Native Anthropic">

Production Claude — native Messages API, not the OpenAI-compat shim:

```typescript
export const claude = ai.model("claude", {
  provider: "anthropic",
  model: "claude-sonnet-4-20250514",
  driverId: "anthropic",
  apiKey: process.env.ANTHROPIC_API_KEY,
});
```

</Tab>

<Tab value="Multi-key">

One binding per provider so tokens never collide:

```typescript
export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/auto",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const gpt = ai.model("gpt", {
  provider: "openai",
  model: "gpt-4.1-mini",
  apiKey: process.env.OPENAI_API_KEY,
});
```

</Tab>

</Tabs>

## Options

| Option     | Type     | Default     | Meaning                                                       |
| ---------- | -------- | ----------- | ------------------------------------------------------------- |
| `provider` | `string` | —           | Registry name or exempt/local label                           |
| `model`    | `string` | —           | Wire model id sent to the provider                            |
| `baseUrl`  | `string` | auto / omit | Override (always wins over registry)                          |
| `apiKey`   | `string` | —           | Per-binding key (isolates multi-provider apps)                |
| `driverId` | `string` | app default | Protocol driver (`openai-compatible`, `anthropic`, `mock`, …) |
| `tier`     | `string` | —           | Optional app label (not the registry status below)            |

Empty name throws `TypeError: ai.model: name is required`.

## Verified providers

These names auto-resolve a verified OpenAI-compatible `baseUrl`:

| Provider     | Base URL (auto)                       |
| ------------ | ------------------------------------- |
| `openai`     | `https://api.openai.com/v1`           |
| `openrouter` | `https://openrouter.ai/api/v1`        |
| `groq`       | `https://api.groq.com/openai/v1`      |
| `together`   | `https://api.together.ai/v1`          |
| `deepinfra`  | `https://api.deepinfra.com/v1/openai` |
| `xai`        | `https://api.x.ai/v1`                 |
| `mistral`    | `https://api.mistral.ai/v1`           |
| `deepseek`   | `https://api.deepseek.com`            |
| `vercel`     | `https://ai-gateway.vercel.sh/v1`     |

**Not** registered (pass `baseUrl` if you still need them): Cloudflare Workers AI
(account-scoped URL); Meta (retired Llama OpenAI-compat API / unverified Muse Spark
host — do not guess a URL).

Exempt labels (no registry URL): `mock`, `local`, `openai-compatible`.

## Limited compatibility

`anthropic`, `google`, and alias `gemini` also auto-resolve a URL, with documented limits.
Prefer native Anthropic for production Claude.

| Provider            | Base URL (auto)                                           |
| ------------------- | --------------------------------------------------------- |
| `anthropic`         | `https://api.anthropic.com/v1`                            |
| `google` / `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai` |

<Callout type="warn" title="Anthropic OpenAI-compat is evaluation-only">
  Anthropic’s OpenAI-compatible endpoint is for testing/comparison only. `tools[].function.strict`
  is ignored; `n` must be 1; no embeddings here. **Production:** `driverId: "anthropic"` (native
  Messages API).
</Callout>

<Callout type="warn" title="Google OpenAI-compat tool schemas">
  Tool/parameter schemas are not full OpenAI JSON Schema fidelity. Complex Flow-as-tool schemas can
  fail — do not rely on this path for agent tool calling in production.
</Callout>

Declare-time and `oke extract` both warn when these providers auto-resolve.

## Native vs OpenAI-compat Anthropic

| Path                                          | When                                            |
| --------------------------------------------- | ----------------------------------------------- |
| `driverId: "anthropic"`                       | Production Claude — native Messages API         |
| `provider: "anthropic"` without native driver | Quick eval via openai-compatible + registry URL |

They are not interchangeable for agents that depend on reliable tool calling.

## Streaming

`fx.stream(model, { prompt, data?, via? })` records `asks` and yields token chunks. Drivers
that do not support stream throw
`ai: model "…" (driver …) does not support stream`.

```typescript
for await (const chunk of fx.stream(smart, { prompt: "Say hello" })) {
  // chunk is a string token
}
```

## Per-environment drivers

See [AI Overview](/docs/elements/ai#per-environment-drivers). create-oke does **not** pin
`drivers.ai` by default — unset dev/test → `mock`; prod must declare.

## Troubleshooting

<Accordions>

<Accordion title='ai.model: unknown provider "…" requires an explicit baseUrl'>
  The name is not in the registry (and not an exempt local label). Pass `baseUrl`, or use a known
  provider id from the tables above. Exact message lists known providers and notes that Cloudflare
  and Meta always need `baseUrl`.
</Accordion>

<Accordion title="Limited-compatibility warn on extract / declare">
  Expected for `anthropic` / `google` / `gemini` when auto-resolving the OpenAI-compat URL. Switch
  to native `driverId: "anthropic"` for production Claude.
</Accordion>

<Accordion title='ai: no client for model "…" and no defaultDriver'>
  The binding has no resolvable client and boot has no default AI driver. Pin `drivers.ai` or set
  `OKE_AI_DRIVER` / `OKE_AI_URL` for the active environment.
</Accordion>

<Accordion title="TypeError: ai.model: name is required">
  Pass a non-empty logical name (`"smart"`, `"local"`, …).
</Accordion>

</Accordions>

## Learn more

- [OpenRouter](/docs/recipes/openrouter) — free / auto / fusion routers
- [Prompts](/docs/elements/ai/prompts) — `model.prompt` and `via`
- [AI](/docs/elements/ai) — element overview and `fx` surface

## Next

<Cards>
  <Card
    title="Prompts"
    description="Versioned prompts and via chains."
    href="/docs/elements/ai/prompts"
  />
  <Card title="OpenRouter" description="Zero-cost cloud default." href="/docs/recipes/openrouter" />
  <Card title="AI" description="Element overview." href="/docs/elements/ai" />
</Cards>


# Prompts (/docs/elements/ai/prompts)

A prompt is a **versioned artifact** minted on a model binding (`smart.prompt(...)`). It names
the ask, optional `in` / `out` shapes, a recovery chain, and a cost cap — then Flows call it
with `fx.ask`.

For developers who need typed triage JSON and a fallback model — declare once, ask everywhere.

<Callout title="The one rule">
  Mint prompts with `model.prompt(name, options)` — there is no top-level `ai.prompt`. Input is
  JSON-serialized (no `template` / `{{var}}` API). When `out` is set, the reply must match or
  the ask throws.
</Callout>

## Smallest Example

<Steps>

<Step>
### Mint a prompt on a model

```typescript title="src/core/ai.ts"
import { ai } from "okengine";
import { z } from "zod";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const classifyTicket = smart.prompt("support.classify", {
  version: 1,
  budget: { maxCostPerCall: 0.01 },
  in: z.object({ message: z.string() }),
  out: z.object({
    category: z.enum(["billing", "technical", "feature_request"]),
    urgency: z.enum(["low", "medium", "high"]),
  }),
});
```

</Step>

<Step>
### Ask from a Flow

```typescript title="src/flows/support/classify.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { classifyTicket } from "@/core/ai";

export const classify = on(
  http.post({
    in: z.object({ message: z.string().min(1) }),
  }),
  flow({
    do: async ({ message }, fx) => {
      return await fx.ask(classifyTicket, { message });
    },
  }),
);
```

Manifest lists `asks: ["support.classify"]`. Pin a version at call time with
`fx.ask("support.classify@1", input)`.

</Step>

<Step>
### See structured output

With `out` set, the runtime appends a schema instruction and validates the JSON reply. Invalid
shape → `AiSchemaValidationError` (`code: "AiSchemaInvalid"`) — **no** automatic correction
retry.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Minimal", "via", "Budget", "Tools"]}>

<Tab value="Minimal">

Name + optional version — model is the parent binding:

```typescript
export const summarize = smart.prompt("docs.summarize", { version: 1 });

await fx.ask(summarize, { text: "…" });
```

</Tab>

<Tab value="via">

Ordered logical model names for recovery. Resolution:
`ask.via ?? prompt.via ?? [prompt.model]`:

```typescript
export const triage = smart.prompt("ticket-triage", {
  version: 1,
  via: ["smart", "local"],
  out: z.object({ category: z.string() }),
});

await fx.ask(triage, input, { via: ["local"] }); // call override
```

**Consequence:** each via step uses that binding’s own `apiKey` / `baseUrl`.

</Tab>

<Tab value="Budget">

Per-call cost cap — exceeded throws `AiBudgetExceededError`:

```typescript
smart.prompt("ticket-triage", {
  budget: { maxCostPerCall: 0.02 },
});
```

`maxCostPerRun` on a prompt budget is reserved for multi-step asks; agents use
`budget.maxCostPerRun` on `ai.agent`.

</Tab>

<Tab value="Tools">

Offer Flows for one ask without declaring an agent:

```typescript
await fx.ask(
  triage,
  { message },
  {
    tools: ["docs.search", "tickets.create"],
    maxSteps: 4,
  },
);
```

Default `maxSteps` is `6`. Tool names also stamp `effects.calls`.

</Tab>

</Tabs>

## Options

| Option    | Type                                  | Default          | Meaning                                                         |
| --------- | ------------------------------------- | ---------------- | --------------------------------------------------------------- |
| `version` | `number`                              | —                | Pin with `fx.ask("name@N")`                                     |
| `evals`   | `string`                              | —                | Path for `oke eval` JSONL                                       |
| `budget`  | `{ maxCostPerCall?, maxCostPerRun? }` | —                | Cost contracts                                                  |
| `via`     | `string[]`                            | `[parent model]` | Recovery chain of logical names                                 |
| `timeout` | `"30s"` \| ms                         | —                | Ask deadline (not a cost budget)                                |
| `in`      | Schema                                | —                | Declared / Manifest / doctor — **not** runtime-validated on ask |
| `out`     | Schema                                | —                | Runtime-validated (Zod / JSON Schema / field shorthand)         |

## How input reaches the model

There is **no** template string API. The runtime JSON-stringifies the ask input (redacting
secrets), and when `out` is set appends:

```text
Reply with JSON only matching this schema: …
```

Pass the fields you need as the second argument to `fx.ask` — do not invent `template` /
`{{message}}` options.

## `fx.ask` options

| Option     | Type      | Default          | Meaning                         |
| ---------- | --------- | ---------------- | ------------------------------- |
| `via`      | refs      | prompt chain     | Override recovery for this call |
| `timeout`  | duration  | prompt `timeout` | Per-call deadline               |
| `tools`    | Flow refs | —                | Tool loop via `fx.call`         |
| `maxSteps` | `number`  | `6`              | Cap on tool rounds              |

## Embeds (related)

`ai.embed` is a separate declaration for vector pipelines into `store.index`:

```typescript
export const kb = ai.embed("kb", {
  model: "smart",
  into: "knowledge",
});

const vector = await fx.embed(smart, "install guide");
const hits = await fx.search(kb, "how to install", { topK: 5 });
```

`fx.embed` records `embeds` (not `asks`). Missing `into` or a non-vector index fails at
runtime with `ai: embed "…" has no into` / vector-index errors.

## Troubleshooting

<Accordions>

<Accordion title='ai: unknown prompt "…"'>
  The name was never registered with `model.prompt`, or the AI module was not imported before boot.
</Accordion>

<Accordion title="OKE1005 — UNDECLARED_ASK">
  Cause: `Flow "{flow}" asks "{resource}" without declaring it.` Use the prompt handle in `do`
  or list `effects: { asks: ["support.classify"] }`.
</Accordion>

<Accordion title='ai: schema validation failed for prompt "…"@N'>
  `AiSchemaValidationError` — the model reply failed `out`. Message includes missing / extra / type
  issues. There is no retry-with-corrections loop.
</Accordion>

<Accordion title='ai: prompt "…" exceeded maxCostPerCall'>
  `AiBudgetExceededError` — raise `budget.maxCostPerCall` or reduce tokens / tool rounds.
</Accordion>

<Accordion title='ai: all models failed for prompt "…"'>
  Every entry in the `via` chain failed (network, auth, or provider error). Check each binding’s
  `apiKey` / `baseUrl` and driver health.
</Accordion>

</Accordions>

## Learn more

- [Models](/docs/elements/ai/models) — bindings and providers
- [Agents](/docs/elements/ai/agents) — multi-step `fx.run`
- [AI](/docs/elements/ai) — guardrails and PII egress
- [OpenRouter](/docs/recipes/openrouter) — cloud ask recipe

## Next

<Cards>
  <Card
    title="Agents"
    description="Bounded agents whose tools are Flows."
    href="/docs/elements/ai/agents"
  />
  <Card
    title="Models"
    description="Provider registry and drivers."
    href="/docs/elements/ai/models"
  />
  <Card title="AI" description="Element overview." href="/docs/elements/ai" />
</Cards>


# Email (/docs/elements/channel/email)

Email (`channel.email`) is the default Channel medium. Declare a binder with a From address,
register templates, put bodies in the catalog, and send with `fx.send`.

For developers who need local catchers and production HTTP MTAs — same `fx.send` path, swap the
driver.

<Callout title="The one rule">
  Use `channel.email({ from }).template(name, { schema, locales })` — never
  `channel.email("name", { subject, body })`. Subject and body live in the catalog.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare binder + template

```typescript title="src/core/channel.ts"
import { channel } from "okengine";
import { z } from "zod";

const mail = channel.email({ from: "App <noreply@example.com>" });

export const passwordReset = mail.template("auth.resetPassword", {
  description: "Password reset link",
  locales: ["en"],
  schema: z.object({ resetLink: z.string().url() }),
});
```

</Step>

<Step>
### Catalog + send

```typescript title="src/app.ts"
import { oke } from "okengine";

oke({
  name: "app",
  channel: {
    catalog: {
      "auth.resetPassword": {
        en: {
          subject: "Reset your password",
          text: "Open {{resetLink}} to choose a new password.",
          html: '<p><a href="{{resetLink}}">Reset your password</a></p>',
        },
      },
    },
  },
});
```

```typescript title="src/flows/auth/reset.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { passwordReset } from "@/core/channel";

export const reset = on(
  http
    .post({
      in: z.object({ email: z.string().email() }),
    })
    .public(),
  flow({
    do: async ({ email }, fx) => {
      await fx.send(passwordReset, {
        to: email,
        data: { resetLink: `https://example.com/reset?t=${fx.id()}` },
      });
      return { ok: true };
    },
  }),
);
```

</Step>

<Step>
### Inspect locally

With Mailpit (`images.channel.email` + `SMTP_URL`), open `MAILPIT_UI_URL`. Manifest lists
`sends: ["auth.resetPassword"]` on the Flow.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Minimal", "Catalog", "Failover", "Plugin catalog"]}>

<Tab value="Minimal">

No catalog — runtime uses `subject: templateName` and `text: JSON.stringify(data)`:

```typescript
await fx.send(passwordReset, {
  to: "alice@example.com",
  data: { resetLink: "https://example.com/r/1" },
});
```

</Tab>

<Tab value="Catalog">

Per-locale `subject` / `text` / `html` with `{{field}}` interpolation from `data`:

```typescript
catalog: {
  "auth.resetPassword": {
    en: { subject: "Reset", text: "{{resetLink}}", html: "<a href=\"{{resetLink}}\">Reset</a>" },
    ar: { subject: "إعادة التعيين", text: "{{resetLink}}" },
  },
}
```

</Tab>

<Tab value="Failover">

Pin multiple email drivers in `oke({ channel: { drivers } })` or rely on the boot chain, then
order them per send:

```typescript
await fx.send(passwordReset, {
  to: "alice@example.com",
  data: { resetLink: "…" },
  via: ["smtp", "resend"],
});
```

Receipt status `fallback` means an earlier attempt failed and a later one succeeded.

</Tab>

<Tab value="Plugin catalog">

Official plugins contribute catalogs with `.channelCatalog(…)` (e.g. OTP, magic link). App
`channel.catalog` merges with plugin contributions at boot.

</Tab>

</Tabs>

## Options Reference

### `channel.email(options?)`

| Option   | Type     | Meaning               |
| -------- | -------- | --------------------- |
| `from`   | `string` | Default From / sender |
| `sender` | `string` | Alias for `from`      |

### `.template(name, options?)`

| Option        | Type       | Meaning                     |
| ------------- | ---------- | --------------------------- |
| `description` | `string`   | Console label               |
| `locales`     | `string[]` | Declared locales            |
| `schema`      | Schema     | Payload contract for `data` |

Default From when neither binder nor template sets one: `"oke@localhost.test"`.

## Drivers

| Driver id      | Opens at boot | Env / keys                                                                    |
| -------------- | ------------- | ----------------------------------------------------------------------------- |
| `smtp`         | yes (default) | `SMTP_URL` or `OKE_CHANNEL_EMAIL_URL`; optional `SMTP_USER` / `SMTP_PASSWORD` |
| `console`      | yes (test)    | In-process inbox — no network                                                 |
| `resend`       | yes           | `RESEND_API_KEY`                                                              |
| `sndr`         | yes           | `SNDR_API_KEY`; optional `SNDR_BASE_URL`                                      |
| `taqnyat-mail` | yes           | `TAQNYAT_MAIL_TOKEN` + `TAQNYAT_CAMPAIGN`                                     |

```typescript title="oke.config.ts"
drivers: {
  channel: {
    email: { dev: "smtp", test: "console", prod: "resend" },
  },
},
images: {
  channel: { email: "axllent/mailpit:v1.31.1" },
},
```

**Consequence:** `dev` and `prod` both speak SMTP protocol by default — Mailpit locally, your
relay in production. Swap `prod` to `resend` / `sndr` / `taqnyat-mail` when you want HTTP APIs.

Unknown id → `oke boot: unknown email channel driver "…"`. Missing SMTP URL →
`oke boot: smtp driver needs SMTP_URL`.

## Locale & consent

Locale chain and suppression run before the driver (see [Overview](/docs/elements/channel) and
[Receipts](/docs/elements/channel/receipts)). Opted-out or prior hard-bounce addresses never
reach SMTP / Resend.

Channel catalogs are **not** ICU — do not use `fx.t` for email bodies ([i18n](/docs/reference/i18n)).

## Troubleshooting

<Accordions>

<Accordion title="oke boot: smtp driver needs SMTP_URL">
  Pin Mailpit / set `SMTP_URL=smtp://…`. URL must use the `smtp://` scheme (`oke boot: SMTP_URL must
  use smtp://`).
</Accordion>

<Accordion title="oke boot: resend channel needs RESEND_API_KEY">
  Email driver is `resend` but the key is missing. Export `RESEND_API_KEY` for that env.
</Accordion>

<Accordion title="oke boot: sndr channel needs SNDR_API_KEY">
  Same pattern for `sndr` — set `SNDR_API_KEY` (and `SNDR_BASE_URL` if non-default).
</Accordion>

<Accordion title="oke boot: taqnyat-mail channel needs TAQNYAT_MAIL_TOKEN / TAQNYAT_CAMPAIGN">
  Taqnyat Email requires both the mail token and a campaign name.
</Accordion>

<Accordion title="Message in Mailpit has JSON body / template name as subject">
  No catalog entry for that template + locale. Add `channel.catalog` (or a plugin catalog) with
  `subject` / `text` / `html`.
</Accordion>

<Accordion title="suppressed/opted-out or prior-bounce — no provider call">
  Consent or hard-bounce suppression blocked the send. Check the receipt ledger; see
  [Receipts](/docs/elements/channel/receipts).
</Accordion>

</Accordions>

## Learn more

- [Channel overview](/docs/elements/channel) — declare → send → drivers
- [Mailpit](/docs/recipes/mailpit) — local SMTP catcher
- [Receipts](/docs/elements/channel/receipts) — delivery ledger
- [Environment variables](/docs/reference/environment-variables) — email boot binder
- [OTP plugin](/docs/plugins/otp) — `auth-otp-email` catalog

## Next

<Cards>
  <Card
    title="SMS"
    description="SMS templates and provider OTP."
    href="/docs/elements/channel/sms"
  />
  <Card
    title="Receipts"
    description="Ledger, outcomes, and suppression."
    href="/docs/elements/channel/receipts"
  />
  <Card
    title="Channel Overview"
    description="Physics, fx surface, and driver table."
    href="/docs/elements/channel"
  />
</Cards>


# Overview (/docs/elements/channel)

Channel is how your backend **reaches a person** — the order-confirmation email, the SMS
sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium, fill a
`{{field}}` body catalog, and send from a Flow with `fx.send`.

For developers wiring Mailpit locally and Resend / Taqnyat / FCM in production — templates and
drivers first, never vendor SDKs inside `do`.

<Callout title="The one rule">
  Declare a template (`channel.email(…).template(…)`), then `fx.send(template, { to, data })`.
  Bodies live in the catalog (`subject` / `text` / `html`), not on the declare call. Consent and
  prior bounces suppress before any driver runs.
</Callout>


> Channel physics around one fx.send: consent can suppress before any provider; locale resolves then the catalog body falls back to the default or en; via orders same-medium driver ids with first success winning; every attempt lands on a receipt, with status fallback after recovery.


## Smallest Example

<Steps>

<Step>
### Declare an email template

```typescript title="src/core/channel.ts"
import { channel } from "okengine";
import { z } from "zod";

const mail = channel.email({ from: "Notes <notes@localhost>" });

export const noteCreatedMail = mail.template("note-created", {
  locales: ["en"],
  schema: z.object({
    id: z.string(),
    title: z.string(),
  }),
});
```

Import this module before `oke()` so auto-registry adopts the template (or pass it in
`oke({ channel: { templates: […] } })`).

</Step>

<Step>
### Send from a Flow

```typescript title="src/flows/notes/on-created.ts"
import { on, flow } from "okengine";
import { noteCreatedMail } from "@/core/channel";
import { noteCreated } from "./signals";

export const onCreated = on(
  noteCreated,
  flow("notes.onCreated", {
    do: async (payload, fx) => {
      await fx.send(noteCreatedMail, {
        to: "you@localhost",
        data: { id: payload.id, title: payload.title },
      });
    },
  }),
);
```

The compiler stamps `sends: ["note-created"]` on the Flow’s effects (template name — not
`email:note-created`).

</Step>

<Step>
### See it locally

With `drivers.channel.email.dev: "smtp"` and Mailpit pinned, open the Mailpit UI
(`MAILPIT_UI_URL`). Missing catalog bodies fall back to `subject: note-created` and
`text: JSON.stringify(data)`.

</Step>

</Steps>

## Progressive Patterns

From a bare send to catalog bodies, locale, and same-medium failover:

<Tabs items={["Minimal", "Catalog", "Locale", "via"]}>

<Tab value="Minimal">

Template handle + recipient — catalog optional for local smoke tests:

```typescript
await fx.send(noteCreatedMail, {
  to: "alice@example.com",
  data: { id: "n1", title: "Hello" },
});
```

</Tab>

<Tab value="Catalog">

Bodies are `{{field}}` strings per locale — not ICU, not React. Pass them on boot or via a
plugin `.channelCatalog(…)`:

```typescript title="src/app.ts"
import { oke } from "okengine";

export const app = oke({
  name: "notes",
  channel: {
    catalog: {
      "note-created": {
        en: {
          subject: "Note created",
          text: "Your note {{title}} ({{id}}) is ready.",
          html: "<p>Your note <strong>{{title}}</strong> is ready.</p>",
        },
      },
    },
  },
});
```

</Tab>

<Tab value="Locale">

Precedence: explicit `locale` → `profileLocale` → `Accept-Language` →
`channel.defaultLocale` / `i18n.default` (`"en"`). Omit send locale opts and the send uses
`fx.locale`.

```typescript
await fx.send(noteCreatedMail, {
  to: "alice@example.com",
  data: { id: "n1", title: "مرحبا" },
  locale: "ar",
});
```

Missing exact locale falls back to default / `en` in the catalog. Chain steps are recorded on
the receipt (`profile:…` · `accept-language:…` · `default:…`).

</Tab>

<Tab value="via">

Order same-medium drivers for failover. Provider / 5xx errors advance; permanent client errors
(400, invalid address) do **not**:

```typescript
await fx.send(noteCreatedMail, {
  to: "alice@example.com",
  data: { id: "n1", title: "Hello" },
  via: ["smtp", "resend"],
});
```

**Consequence:** receipt status is `fallback` when an earlier attempt failed and a later one
succeeded — every attempt is kept on the receipt.

</Tab>

</Tabs>

## Declaration Reference

| Declaration        | Signature                          | Purpose                                   |
| ------------------ | ---------------------------------- | ----------------------------------------- |
| `channel.email`    | `channel.email(options?)`          | Email medium binder                       |
| `channel.sms`      | `channel.sms(options?)`            | SMS medium binder                         |
| `channel.whatsapp` | `channel.whatsapp(options?)`       | WhatsApp medium binder                    |
| `channel.push`     | `channel.push(options?)`           | Push medium binder                        |
| `binder.template`  | `binder.template(name, options?)`  | Auto-registered template                  |
| `channel.template` | `channel.template(name, options?)` | Medium-agnostic — **not** auto-registered |

### Medium options

| Option   | Type     | Default | Meaning                                     |
| -------- | -------- | ------- | ------------------------------------------- |
| `from`   | `string` | —       | Default sender (email From / SMS sender id) |
| `sender` | `string` | —       | Alias stored as `from`                      |

### Template options

| Option        | Type       | Default   | Meaning                                             |
| ------------- | ---------- | --------- | --------------------------------------------------- |
| `description` | `string`   | name      | Console / docs label                                |
| `locales`     | `string[]` | —         | Declared locale tags for the template               |
| `schema`      | Schema     | —         | Payload shape (Zod / Standard Schema)               |
| `from`        | `string`   | binder’s  | Override sender on agnostic `channel.template` only |
| `medium`      | medium     | `"email"` | Only on `channel.template()`                        |

Empty name throws `TypeError: channel.template: name is required`.

There is **no** `subject` / `body` / `html` on declare — those belong in the catalog.

## `fx` surface

| Method                     | Capability / `sends` | Meaning                                     |
| -------------------------- | -------------------- | ------------------------------------------- |
| `fx.send(template, opts?)` | template name        | Deliver through the medium’s driver chain   |
| `fx.sendOtp(opts)`         | `"sms-otp"`          | Provider-managed SMS OTP (Taqnyat Verify)   |
| `fx.verifyOtp(opts)`       | `"sms-otp"`          | Check a provider OTP code                   |
| `fx.deliverOtp(opts)`      | `"auth-otp"`         | App-owned OTP across email / SMS / WhatsApp |

### `fx.send` options

| Option           | Type     | Meaning                                      |
| ---------------- | -------- | -------------------------------------------- |
| `to`             | `string` | Recipient (email / E.164 / FCM token / …)    |
| `data`           | object   | Interpolated into `{{field}}` catalog bodies |
| `via`            | refs     | Same-medium driver order for failover        |
| `locale`         | `string` | Explicit locale (wins)                       |
| `profileLocale`  | `string` | Profile locale step                          |
| `acceptLanguage` | `string` | Raw `Accept-Language` header                 |

Dry-run records _would have fired_ and never contacts a provider. Undeclared send → **OKE1004**
`UNDECLARED_SEND`.

## Per-environment drivers

Email defaults from `DRIVER_DEFAULTS.channel.email`. SMS / WhatsApp / push are **opt-in**:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    channel: {
      email: { dev: "smtp", test: "console", prod: "smtp" },
      // sms: { prod: "taqnyat" },
      // whatsapp: { prod: "wa-cloud" },
    },
  },
  images: {
    channel: { email: "axllent/mailpit:v1.31.1" },
  },
});
```

| Key                | Default (dev / test / prod) | Driver ids                                                       |
| ------------------ | --------------------------- | ---------------------------------------------------------------- |
| `channel.email`    | `smtp` / `console` / `smtp` | `console` · `smtp` · `resend` · `sndr` · `taqnyat-mail`          |
| `channel.sms`      | none                        | `taqnyat` · `msegat` · `unifonic` (`console` opens nothing)      |
| `channel.whatsapp` | none                        | `wa-cloud` · `taqnyat-whatsapp`                                  |
| `channel.push`     | none — **not auto-bound**   | Pass `oke({ channel: { drivers: […] } })` with `webpush` / `fcm` |

Env knobs: [Environment variables](/docs/reference/environment-variables). Local SMTP catcher:
[Mailpit](/docs/recipes/mailpit).

## The Capabilities of Channel

<Cards>
  <Card
    title="Email"
    description="SMTP / Mailpit, Resend, SNDR, Taqnyat Mail — templates and catalogs."
    href="/docs/elements/channel/email"
  />
  <Card
    title="SMS"
    description="Transactional SMS and provider-managed OTP (Taqnyat Verify)."
    href="/docs/elements/channel/sms"
  />
  <Card
    title="WhatsApp"
    description="wa-cloud and Taqnyat WhatsApp medium binders."
    href="/docs/elements/channel/whatsapp"
  />
  <Card
    title="Push"
    description="FCM device tokens and Web Push (VAPID) driver binding."
    href="/docs/elements/channel/push"
  />
  <Card
    title="Receipts"
    description="In-memory ledger, outcomes taxonomy, consent and bounce suppression."
    href="/docs/elements/channel/receipts"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title='channel: unknown template "…"'>
  `fx.send` named a template that was never declared or not adopted at boot. Import the medium
  binder module before `oke()`, or pass `channel.templates` explicitly.
</Accordion>

<Accordion title="OKE1004 — UNDECLARED_SEND">
  Cause: `Flow "{flow}" sends "{resource}" without declaring it.` Touch the template handle
  inside `do` (inference) or list `effects: { sends: ["note-created"] }`.
</Accordion>

<Accordion title="channel: no email transport in driver chain">
  No email-capable driver is bound (or `via` filtered them all out). Check `drivers.channel.email`
  and `SMTP_URL` / provider API keys for the active env.
</Accordion>

<Accordion title="oke boot: smtp driver needs SMTP_URL">
  Email is pinned to `smtp` but neither `SMTP_URL` nor `OKE_CHANNEL_EMAIL_URL` is set. For local
  Docker, run the Mailpit stack so compose writes the URL.
</Accordion>

<Accordion title="Process-local suppression / receipts warning">
  Boot warns: `Channel suppression/consent/receipts default to process-local memory…` Opt-out on one
  instance is invisible to others until you inject shared stores or run a single Channel consumer.
  See [Receipts](/docs/elements/channel/receipts).
</Accordion>

</Accordions>

## Learn more

- [Email](/docs/elements/channel/email) — drivers, catalog, Mailpit
- [SMS](/docs/elements/channel/sms) — `fx.sendOtp` / `fx.verifyOtp`
- [WhatsApp](/docs/elements/channel/whatsapp) — `channel.whatsapp` + boot drivers
- [Push](/docs/elements/channel/push) — FCM / Web Push binding
- [Receipts](/docs/elements/channel/receipts) — ledger, outcomes, suppression
- [OTP plugin](/docs/plugins/otp) — `/auth/otp/*` over Channel
- [fx](/docs/reference/fx) — `fx.send` options
- [i18n](/docs/reference/i18n) — Channel catalogs vs `fx.t`

## Next

<Cards>
  <Card
    title="Email"
    description="Declare email templates and pin SMTP / Resend drivers."
    href="/docs/elements/channel/email"
  />
  <Card title="AI Element" description="Models, prompts, and agents." href="/docs/elements/ai" />
  <Card
    title="The Model"
    description="Eight elements overview."
    href="/docs/understand/the-architecture"
  />
</Cards>


# Push (/docs/elements/channel/push)

Push (`channel.push`) delivers device notifications. Unlike email / SMS / WhatsApp, push
drivers are **not** opened by the default boot binder — pass `webpush` / `fcm` drivers on
`oke({ channel: { drivers } })`.

For developers notifying installed apps — FCM uses the device token as `to`; Web Push needs
VAPID keys on the driver.

<Callout title="The one rule">
  Declare with `channel.push().template(…)`. Bind push drivers yourself — `drivers.channel.push` in
  config is not auto-opened today. WhatsApp is a different medium
  ([WhatsApp](/docs/elements/channel/whatsapp)).
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a push template

```typescript title="src/core/channel.ts"
import { channel } from "okengine";
import { z } from "zod";

const push = channel.push();

export const orderPush = push.template("order.status", {
  locales: ["en"],
  schema: z.object({
    title: z.string(),
    body: z.string(),
  }),
});
```

</Step>

<Step>
### Bind an FCM driver

```typescript title="src/app.ts"
import { oke } from "okengine";
import { openFcmChannel } from "okengine/drivers";

export const app = oke({
  name: "shop",
  channel: {
    drivers: [
      openFcmChannel({
        projectId: process.env.FCM_PROJECT_ID,
        clientEmail: process.env.FCM_CLIENT_EMAIL,
        privateKey: process.env.FCM_PRIVATE_KEY,
      }),
    ],
    catalog: {
      "order.status": {
        en: { subject: "{{title}}", text: "{{body}}" },
      },
    },
  },
});
```

FCM maps catalog `subject` → notification title, `text` → body; `to` is the device token.

</Step>

<Step>
### Send

```typescript
await fx.send(orderPush, {
  to: deviceToken,
  data: { title: "Shipped", body: "Your order is on the way." },
});
```

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["FCM", "Web Push", "Catalog"]}>

<Tab value="FCM">

`openFcmChannel` requires `projectId` (or `from`) plus either service-account
`clientEmail` + `privateKey` or a pre-fetched access `token` / `apiKey`.

```text
fcm channel: projectId (or from) is required
fcm channel: clientEmail+privateKey (service account) or token (access token) required
```

</Tab>

<Tab value="Web Push">

```typescript
import { openWebPushChannel } from "okengine/drivers";

openWebPushChannel({
  vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
  vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
  vapidSubject: "mailto:ops@example.com",
});
```

```text
webpush: vapidPublicKey and vapidPrivateKey are required
webpush: pushSubscription with endpoint + keys is required
```

Web Push needs `pushSubscription` (`endpoint` + `p256dh` / `auth`) on the runtime send
options — `fx.send` does not forward it. Prefer FCM for token-based `fx.send`, or bind
Web Push where the subscription is supplied on the runtime path.

</Tab>

<Tab value="Catalog">

```typescript
"order.status": {
  en: { subject: "{{title}}", text: "{{body}}" },
}
```

`subject` becomes the notification title on FCM; `text` is the body. Extra `data` fields pass
through as FCM data payload when present.

</Tab>

</Tabs>

## Drivers

| Driver id | How it binds                             | Role                             |
| --------- | ---------------------------------------- | -------------------------------- |
| `fcm`     | `openFcmChannel(…)` in `channel.drivers` | Firebase Cloud Messaging HTTP v1 |
| `webpush` | `openWebPushChannel(…)`                  | RFC 8030 + VAPID                 |
| `console` | `openConsoleChannel()`                   | Dev inbox (all mediums)          |

Config key `drivers.channel.push` exists for Manifest / tooling ids (`console` · `webpush` ·
`fcm`) but the boot binder does **not** open push from that map yet — pass drivers on
`CreateChannelRuntimeOptions.drivers`.

## Options Reference

### `channel.push(options?)`

| Option   | Type     | Meaning          |
| -------- | -------- | ---------------- |
| `from`   | `string` | Optional default |
| `sender` | `string` | Alias for `from` |

### `.template(name, options?)`

`description` · `locales` · `schema` — same as other mediums.

## Troubleshooting

<Accordions>

<Accordion title="Push template sends but nothing is delivered">
  No push driver in `channel.drivers`. Email/SMS/WhatsApp boot chain does not include FCM or Web
  Push — add `openFcmChannel` / `openWebPushChannel`.
</Accordion>

<Accordion title="fcm channel: projectId / credentials required">
  Open options missing project id or service-account pair / access token. See Progressive Patterns →
  FCM.
</Accordion>

<Accordion title="webpush: pushSubscription with endpoint + keys is required">
  Web Push driver received a message without subscription keys. Supply `pushSubscription` on the
  runtime send path; token-only `to` is not enough for Web Push.
</Accordion>

<Accordion title="channel: unknown template">
  Import the push binder module before `oke()`, or list the template under `channel.templates`.
</Accordion>

</Accordions>

## Learn more

- [WhatsApp](/docs/elements/channel/whatsapp) — separate medium for chat
- [Channel overview](/docs/elements/channel) — `fx.send` and catalogs
- [Receipts](/docs/elements/channel/receipts) — delivery ledger
- [Configuration](/docs/reference/configuration) — driver id tables

## Next

<Cards>
  <Card
    title="Receipts"
    description="Ledger, outcomes, and suppression."
    href="/docs/elements/channel/receipts"
  />
  <Card
    title="WhatsApp"
    description="wa-cloud and Taqnyat WhatsApp."
    href="/docs/elements/channel/whatsapp"
  />
  <Card
    title="Channel Overview"
    description="Declare, send, and drivers."
    href="/docs/elements/channel"
  />
</Cards>


# Receipts (/docs/elements/channel/receipts)

Every Channel send records a **receipt** — success (`sent` / `fallback`), suppression, or a
classified failure. Provider bounces and complaints update that ledger through normalized
outcomes. There is no `oke_receipts` SQL table and no `fx.channel.getReceipt` helper.

For operators watching deliverability — Console projects the ledger; Flows only see send
results via `fx.send`’s `{ ok: true }` gate.

<Callout title="The one rule">
  Consent and prior hard bounces suppress **before** any driver runs. Default suppression / consent
  / receipts stores are **process-local memory** — inject shared stores for multi-instance, or run a
  single Channel consumer, until a durable driver ships.
</Callout>

## Smallest Example

<Steps>

<Step>
### Send and accept the receipt

```typescript
const result = await fx.send(noteCreatedMail, {
  to: "alice@example.com",
  data: { id: "n1", title: "Hi" },
});
// result.ok === true on the fx gate when the capability succeeds;
// the runtime ledger still stores status, attempts, and messageId.
```

Dry-run never contacts a provider and still records _would have fired_.

</Step>

<Step>
### Understand statuses

| Status                    | Meaning                                                         |
| ------------------------- | --------------------------------------------------------------- |
| `sent`                    | First (or only) attempt succeeded                               |
| `fallback`                | An earlier attempt failed; a later same-medium driver succeeded |
| `suppressed/opted-out`    | Consent store blocked the address                               |
| `suppressed/prior-bounce` | Prior hard bounce on the suppression list                       |
| `failed` / `opted-out`    | Legacy aliases kept for older callers                           |

</Step>

<Step>
### Watch the boot warning

```text
oke boot: Channel suppression/consent/receipts default to process-local memory —
opt-out, bounce, and receipt state on one instance is invisible to others.
Inject shared stores for multi-instance, or run a single Channel consumer, until a
durable driver ships.
```

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Suppression", "Outcomes", "Inject stores"]}>

<Tab value="Suppression">

Opt-out or prior bounce never hits SMTP / SMS:

```typescript
// Runtime path (not on fx): consent.optOut(subject, medium | "all")
// Next fx.send → receipt status suppressed/opted-out, driverId "suppression", ok: false
```

Errors on the receipt: `"opted out"` or `"prior hard bounce"`.

</Tab>

<Tab value="Outcomes">

Post-send taxonomy (`DeliveryOutcomeState`):

| State                       | Verdict    |
| --------------------------- | ---------- |
| `suppressed/opted-out`      | `correct`  |
| `suppressed/prior-bounce`   | `correct`  |
| `blocked/invalid-address`   | `review`   |
| `soft-bounce`               | `retry`    |
| `hard-bounce`               | `suppress` |
| `provider-error`            | `retry`    |
| `delivered-then-complained` | `review`   |

`ingestOutcome({ messageId, state })` updates the receipt. Hard bounce also adds the address
to suppression (`prior-bounce`). Invalid-address heuristics include patterns such as
`550 5.1.1` on attempt errors.

</Tab>

<Tab value="Inject stores">

Pass shared implementations on boot:

```typescript
oke({
  channel: {
    suppression: mySuppressionStore,
    consent: myConsentStore,
    receipts: myReceiptLedger,
  },
});
```

Shapes: `SuppressionStore`, `ConsentStore`, `ReceiptLedger` from `okengine` — `record` /
`all` / `forTemplate` / `byMessageId` / `updateStatus` on the ledger.

</Tab>

</Tabs>

## Receipt shape

| Field                    | Meaning                                     |
| ------------------------ | ------------------------------------------- |
| `id`                     | Runtime receipt id                          |
| `template`               | Template name                               |
| `to`                     | Recipient                                   |
| `medium`                 | `email` · `sms` · `whatsapp` · `push` · …   |
| `locale` / `localeChain` | Resolved locale + chain steps               |
| `status`                 | Success, legacy, or outcome state           |
| `messageId`              | Provider / runtime message id               |
| `driverId`               | Winning driver (or `suppression`)           |
| `attempts`               | Every try (`driverId`, `ok`, `error`, `at`) |
| `at`                     | Epoch ms                                    |
| `error`                  | Aggregated attempt errors when failed       |

## Consent & suppression

| Store       | Role                                                             |
| ----------- | ---------------------------------------------------------------- |
| Consent     | `isOptedOut` / `optOut` / `optIn` / `list` per subject + medium  |
| Suppression | Reasons `"opted-out"` \| `"prior-bounce"`; checked on every send |

**Consequence:** suppression is not failure — verdict `correct` for opted-out and prior-bounce
rows. Complaints (`delivered-then-complained`) outrank many hard bounces in consequence weight.

There is no Flow helper to query receipts today — use Console projection or an injected ledger
from operator tooling.

## Troubleshooting

<Accordions>

<Accordion title="Process-local memory boot warning">
  Expected on single-node `oke dev`. For replicas, inject shared `suppression` / `consent` /
  `receipts` or pin Channel work to one consumer.
</Accordion>

<Accordion title="Send returns ok but Mailpit / provider empty">
  Check receipt status — `suppressed/*` never calls a driver. Opt-in the address or clear prior
  bounce on the suppression store.
</Accordion>

<Accordion title="Status fallback with multiple attempts">
  Same-medium `via` (or ordered driver chain) recovered after a provider error. Inspect `attempts[]`
  for which driver failed and which succeeded.
</Accordion>

<Accordion title="No fx.channel.getReceipt">
  That API does not exist. Use the runtime `receipts` store (injected or Console), not a Flow
  method. `fx.send` only returns `{ ok: true }` after the capability gate.
</Accordion>

<Accordion title="Hard bounce still getting mail">
  `ingestOutcome` with `hard-bounce` must run (webhook → normalized outcome). Without ingestion,
  suppression never learns the bounce.
</Accordion>

</Accordions>

## Learn more

- [Channel overview](/docs/elements/channel) — send path and `ChannelPhysics`
- [Email](/docs/elements/channel/email) — SMTP / provider drivers
- [fx](/docs/reference/fx) — dry-run send behavior

## Next

<Cards>
  <Card
    title="Channel Overview"
    description="Templates, fx.send, and drivers."
    href="/docs/elements/channel"
  />
  <Card
    title="Email"
    description="Mailpit and production email drivers."
    href="/docs/elements/channel/email"
  />
  <Card title="AI Element" description="Models, prompts, and agents." href="/docs/elements/ai" />
</Cards>


# SMS (/docs/elements/channel/sms)

SMS (`channel.sms`) delivers short text and provider-managed one-time codes. Pin an SMS driver
(no default in any env), declare templates for app-owned messages, or call `fx.sendOtp` when the
vendor owns the code.

For developers verifying phones — choose raw Channel OTP vs the [`otp`](/docs/plugins/otp) plugin.

<Callout title="The one rule">
  SMS is opt-in: set `drivers.channel.sms` (e.g. `"taqnyat"`) or boot has no SMS transport. Provider
  OTP needs a Verify-capable driver (`taqnyat`); app-owned codes use templates + `fx.deliverOtp` /
  the OTP plugin.
</Callout>

## Smallest Example

<Steps>

<Step>
### Pin an SMS driver

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    channel: {
      sms: { prod: "taqnyat" },
    },
  },
});
```

Set `TAQNYAT_BEARER_TOKEN` (or `TAQNYAT_TOKEN`) and `TAQNYAT_SENDER`.

</Step>

<Step>
### Declare a template and send

```typescript title="src/core/channel.ts"
import { channel } from "okengine";
import { z } from "zod";

const sms = channel.sms({ sender: "ACME" });

export const orderShippedSms = sms.template("order.shipped", {
  locales: ["en"],
  schema: z.object({ tracking: z.string() }),
});
```

```typescript
await fx.send(orderShippedSms, {
  to: "+15551234567",
  data: { tracking: "1Z999" },
});
```

Add a catalog body (`text: "Shipped — track {{tracking}}"`) or accept the JSON fallback.

</Step>

<Step>
### Or send a provider OTP

```typescript
await fx.sendOtp({
  to: "+15551234567",
  requestId: fx.id(),
  lang: "en",
});

await fx.verifyOtp({
  to: "+15551234567",
  requestId, // same id
  code: userEnteredCode,
});
```

Effects record `sends: ["sms-otp"]`. Prefer [`otp({ mode: "provider" })`](/docs/plugins/otp) for
`/auth/otp/*` routes.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Template", "Provider OTP", "App OTP", "Plugin"]}>

<Tab value="Template">

Transactional SMS through the same `fx.send` path as email:

```typescript
const sms = channel.sms({ from: "ACME" });
export const alertSms = sms.template("ops.alert", {
  schema: z.object({ message: z.string() }),
});

await fx.send(alertSms, { to: phone, data: { message: "Disk 90%" } });
```

</Tab>

<Tab value="Provider OTP">

Vendor owns the code (Taqnyat Verify). Options:

| Option      | Type             | Meaning                                   |
| ----------- | ---------------- | ----------------------------------------- |
| `to`        | `string`         | E.164 recipient                           |
| `requestId` | `string`         | Correlation id (required again on verify) |
| `lang`      | `"en"` \| `"ar"` | Message language                          |
| `note`      | `string`         | Optional note appended to SMS             |
| `from`      | `string`         | Sender override                           |

`verifyOtp` adds required `code`. Missing Channel →
`fx.sendOtp needs a bound Channel — declare channel and set drivers.channel.sms (e.g. taqnyat)`.

</Tab>

<Tab value="App OTP">

Your app generates the code; Channel fans out templates:

```typescript
await fx.deliverOtp({
  channels: ["sms", "whatsapp", "email"],
  templates: {
    sms: "auth-otp-sms",
    whatsapp: "auth-otp-whatsapp",
    email: "auth-otp-email",
  },
  phone: "+15551234567",
  email: "alice@example.com",
  data: { otp: "482910" },
  locale: "en",
});
```

Capability: `sends: ["auth-otp"]`. Pass `only: "sms"` for a single-channel resend (no
cross-medium failover).

</Tab>

<Tab value="Plugin">

```typescript
.plug(otp({ mode: "provider" }))
// or
.plug(otp({ mode: "app", channels: ["sms", "email"], exposeDevOtp: true }))
```

Full routes and modes: [OTP plugin](/docs/plugins/otp).

</Tab>

</Tabs>

## Drivers

| Driver id  | OTP Verify | Env                                                         |
| ---------- | ---------- | ----------------------------------------------------------- |
| `taqnyat`  | yes        | `TAQNYAT_BEARER_TOKEN` / `TAQNYAT_TOKEN` + `TAQNYAT_SENDER` |
| `msegat`   | no         | `MSEGAT_USERNAME` / `MSEGAT_API_KEY` / `MSEGAT_SENDER`      |
| `unifonic` | no         | `UNIFONIC_APPSID`                                           |
| `console`  | —          | Config id that opens **no** SMS driver                      |

Boot errors (verbatim):

```text
oke boot: taqnyat channel needs TAQNYAT_BEARER_TOKEN
oke boot: taqnyat channel needs TAQNYAT_SENDER
oke boot: msegat channel needs MSEGAT_USERNAME / MSEGAT_API_KEY / MSEGAT_SENDER
oke boot: unifonic channel needs UNIFONIC_APPSID
```

Provider OTP without a Verify driver:

```text
channel: SMS driver "…" does not support provider-managed OTP; use exposeDevOtp locally or set drivers.channel.sms to "taqnyat"
```

No SMS bound:

```text
channel: no SMS driver bound — bind drivers.channel.sms (e.g. taqnyat) to send provider OTP
```

## Options Reference

### `channel.sms(options?)`

Same medium options as email: `from` / `sender`.

### `.template(name, options?)`

`description` · `locales` · `schema` — bodies in the catalog (`text` is typical for SMS).

## Troubleshooting

<Accordions>

<Accordion title="fx.sendOtp / verifyOtp needs a bound Channel">
  No Channel runtime, or SMS not configured. Declare templates/drivers and set
  `drivers.channel.sms`.
</Accordion>

<Accordion title="channel: no SMS driver bound">
  Config has no SMS id for this env (`CHANNEL_SMS_DEFAULTS` is empty). Pin `taqnyat` / `msegat` /
  `unifonic`.
</Accordion>

<Accordion title="SMS driver does not support provider-managed OTP">
  Only Taqnyat exposes `sendOtp` / `verifyOtp`. Switch driver or use app-mode OTP
  (`fx.deliverOtp` / `otp({ mode: "app" })`).
</Accordion>

<Accordion title="channel: otp delivery has no viable channel">
  `fx.deliverOtp` had no matching address for a declared medium (need `phone` for SMS / WhatsApp,
  `email` for email).
</Accordion>

<Accordion title="channel: otp delivery failed on all channels">
  Every medium in the failover list failed. Check driver credentials, suppression, and template
  catalog entries.
</Accordion>

</Accordions>

## Learn more

- [OTP plugin](/docs/plugins/otp) — `/auth/otp/*`
- [WhatsApp](/docs/elements/channel/whatsapp) — medium for app-mode OTP failover
- [Email](/docs/elements/channel/email) — email leg of `deliverOtp`
- [Channel overview](/docs/elements/channel) — effects and drivers
- [Environment variables](/docs/reference/environment-variables) — SMS boot binder

## Next

<Cards>
  <Card
    title="WhatsApp"
    description="WhatsApp Cloud and Taqnyat WhatsApp drivers."
    href="/docs/elements/channel/whatsapp"
  />
  <Card
    title="OTP Plugin"
    description="Auth routes over provider or app OTP."
    href="/docs/plugins/otp"
  />
  <Card
    title="Channel Overview"
    description="fx.send, catalogs, and consent."
    href="/docs/elements/channel"
  />
</Cards>


# WhatsApp (/docs/elements/channel/whatsapp)

WhatsApp (`channel.whatsapp`) is its own medium — not a Push subtype. Declare templates, pin a
WhatsApp driver (opt-in; no default), and send with `fx.send` or include WhatsApp in
`fx.deliverOtp` failover.

For developers reaching users on Meta Cloud or Taqnyat WhatsApp — same Channel physics as SMS.

<Callout title="The one rule">
  Use `channel.whatsapp(…).template(…)` and set `drivers.channel.whatsapp` to `wa-cloud` or
  `taqnyat-whatsapp`. Boot opens WhatsApp when configured; Push drivers are separate and not
  auto-bound.
</Callout>

## Smallest Example

<Steps>

<Step>
### Pin a WhatsApp driver

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    channel: {
      whatsapp: { prod: "wa-cloud" },
    },
  },
});
```

`wa-cloud` needs `WHATSAPP_TOKEN` (or `WA_CLOUD_TOKEN`) and `WHATSAPP_PHONE_NUMBER_ID`
(or `WA_CLOUD_PHONE_NUMBER_ID`).

</Step>

<Step>
### Declare and send

```typescript title="src/core/channel.ts"
import { channel } from "okengine";
import { z } from "zod";

const wa = channel.whatsapp();

export const bookingReminder = wa.template("booking.reminder", {
  locales: ["en", "ar"],
  schema: z.object({ when: z.string(), place: z.string() }),
});
```

```typescript
await fx.send(bookingReminder, {
  to: "+9665xxxxxxx",
  data: { when: "Thu 15:00", place: "Clinic A" },
  locale: "ar",
});
```

</Step>

<Step>
### Confirm the effect

Manifest / Console list `sends: ["booking.reminder"]`. Receipts follow the same ledger as email
and SMS.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Template", "Taqnyat", "App OTP"]}>

<Tab value="Template">

Catalog typically supplies `text` (and optional `subject` where the transport uses it):

```typescript
oke({
  channel: {
    catalog: {
      "booking.reminder": {
        en: { text: "Reminder: {{when}} at {{place}}" },
        ar: { text: "تذكير: {{when}} — {{place}}" },
      },
    },
  },
});
```

</Tab>

<Tab value="Taqnyat">

```typescript
drivers: {
  channel: {
    whatsapp: { prod: "taqnyat-whatsapp" },
  },
},
```

Needs `TAQNYAT_WHATSAPP_TOKEN` (falls back to `TAQNYAT_BEARER_TOKEN`). Missing token:

```text
oke boot: taqnyat-whatsapp channel needs TAQNYAT_WHATSAPP_TOKEN (or TAQNYAT_BEARER_TOKEN)
```

</Tab>

<Tab value="App OTP">

Include WhatsApp in app-owned OTP delivery (plugin or raw):

```typescript
await fx.deliverOtp({
  channels: ["whatsapp", "sms", "email"],
  templates: {
    whatsapp: "auth-otp-whatsapp",
    sms: "auth-otp-sms",
    email: "auth-otp-email",
  },
  phone: "+15551234567",
  data: { otp: "482910" },
});
```

See [OTP plugin](/docs/plugins/otp) for `/auth/otp/*` and [SMS](/docs/elements/channel/sms) for
provider vs app mode.

</Tab>

</Tabs>

## Drivers

| Driver id          | Env                                                                                           |
| ------------------ | --------------------------------------------------------------------------------------------- |
| `wa-cloud`         | `WHATSAPP_TOKEN` / `WA_CLOUD_TOKEN` + `WHATSAPP_PHONE_NUMBER_ID` / `WA_CLOUD_PHONE_NUMBER_ID` |
| `taqnyat-whatsapp` | `TAQNYAT_WHATSAPP_TOKEN` or `TAQNYAT_BEARER_TOKEN`                                            |
| `console`          | Config id — opens **no** WhatsApp driver                                                      |

```text
oke boot: wa-cloud channel needs WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID
oke boot: unknown whatsapp channel driver "…"
```

## Options Reference

### `channel.whatsapp(options?)`

| Option   | Type     | Meaning           |
| -------- | -------- | ----------------- |
| `from`   | `string` | Default sender id |
| `sender` | `string` | Alias for `from`  |

### `.template(name, options?)`

`description` · `locales` · `schema` — same as other mediums.

## Troubleshooting

<Accordions>

<Accordion title="oke boot: wa-cloud channel needs WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID">
  Both token and phone-number id are required for Meta Cloud. Check alias env names (`WA_CLOUD_*`)
  if you use those.
</Accordion>

<Accordion title="oke boot: taqnyat-whatsapp channel needs TAQNYAT_WHATSAPP_TOKEN">
  Set the WhatsApp-specific token or reuse `TAQNYAT_BEARER_TOKEN`.
</Accordion>

<Accordion title="fx.send succeeds nowhere — no WhatsApp in chain">
  `drivers.channel.whatsapp` unset for this env, or id is `console`. Pin `wa-cloud` or
  `taqnyat-whatsapp`.
</Accordion>

<Accordion title="channel: unknown template">
  Template not auto-registered — import the `channel.whatsapp().template(…)` module before `oke()`,
  or pass `channel.templates`.
</Accordion>

</Accordions>

## Learn more

- [SMS](/docs/elements/channel/sms) — OTP modes and `fx.deliverOtp`
- [Push](/docs/elements/channel/push) — FCM / Web Push (separate medium)
- [Channel overview](/docs/elements/channel) — locale, via, effects
- [OTP plugin](/docs/plugins/otp) — multi-channel auth OTP

## Next

<Cards>
  <Card
    title="Push"
    description="FCM and Web Push driver binding."
    href="/docs/elements/channel/push"
  />
  <Card
    title="SMS"
    description="SMS templates and provider OTP."
    href="/docs/elements/channel/sms"
  />
  <Card
    title="Channel Overview"
    description="Declare, send, and drivers."
    href="/docs/elements/channel"
  />
</Cards>


# Overview (/docs/elements/clock)

Clock is how your backend **knows what time it is and when to run again**. A monthly invoice job, a 30s health ping, and a three-day trial reminder share one vocabulary: declare a named schedule, bind it with `on(clockDecl, flow("name", { do }))`, and read time only through `fx.clock`.

For developers scheduling work on okengine — one handle shape; drivers swap by environment.

<Callout title="The one rule">
  All time access and pauses go through `fx.clock`. `Date.now()`, `new Date()`, and `setTimeout`
  bypass the injectable clock and break durable sleep / time-travel tests.
</Callout>


> Two schedule kinds — clock.every is a fixed interval, clock.daily / cron helpers are wall-clock (timezone from oke({ clock })) — both bind with on(trigger, flow) to the same Flow species.


## Smallest Example

<Steps>

<Step>
### Declare a named clock

```typescript title="src/clocks/digest.ts"
import { clock } from "okengine";

export const digestClock = clock.every("notes.digest", "1d");
```

</Step>

<Step>
### Bind a Flow

```typescript title="src/flows/notes/digest.ts"
import { on, flow } from "okengine";
import { digestClock } from "@/clocks/digest";
import { db, notes } from "@/schema";
import { isNull } from "drizzle-orm";

export const digest = on(
  digestClock,
  flow("notes.digest", {
    plane: "operator",
    do: async (_, fx) => {
      const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
      return { active: rows.length, at: new Date(fx.clock.now()).toISOString() };
    },
  }),
);
```

</Step>

<Step>
### See it tick

With `oke dev`, the scheduler reconciles `notes.digest` into the Store and leader-elects
before each fire. `do` receives no payload — read time with `fx.clock.now()` (epoch-ms).
Map to ISO on the wire with `new Date(fx.clock.now()).toISOString()`.

Under `drivers.clock.test = "frozen"`, advance time in tests instead of waiting a day.

</Step>

</Steps>

<Callout title="Jobs are Flows">
  Write the schedule inline or export it — [Inline or named export](#inline-or-named-export).
  Every consumer still uses `flow("name", { do })`. See
  [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs).
</Callout>

## Inline or named export

| Style                                                     | When                              |
| --------------------------------------------------------- | --------------------------------- |
| `on(clock.every("name", "1h"), flow("ops.ping", { do }))` | One Flow owns this schedule       |
| `export const x = clock.every("name", "1h")`              | Several Flows share this schedule |

<Callout title="Why Clock can be inline">
  `fx.clock` is now / sleep / offsets — not an emit. Signal stays an exported const so producers can
  `fx.emit(handle, payload)`. Export a Clock const only to share one schedule across named Flows.
</Callout>

Both styles pass a real `flow("name", { do })`. Nameless `flow({ do })` fails
**OKE1072** — the trigger's name is never the Flow's name.

<Tabs items={["Inline", "Named"]}>

<Tab value="Inline">

One file — declare and bind together. The scheduler fires it; no other Flow
binds this cadence:

```typescript title="src/flows/health/ping.ts"
import { on, flow, clock } from "okengine";

export const pingExternal = on(
  clock.every("health.pingExternal", "30s"),
  flow("health.pingExternal", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(pingUpstream);
    },
  }),
);
```

</Tab>

<Tab value="Named">

Export the handle when a second `on()` must reuse the same schedule. Each Flow
keeps its own explicit name — **OKE1070** if they collide:

```typescript title="src/clocks/metrics.ts"
import { clock } from "okengine";

export const tickClock = clock.every("metrics.tick", "1h");
```

```typescript title="src/flows/ops/metrics.ts"
import { on, flow } from "okengine";
import { tickClock } from "@/clocks/metrics";

export const sweep = on(
  tickClock,
  flow("ops.sweep", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(sweepMetrics);
    },
  }),
);

export const report = on(
  tickClock,
  flow("ops.report", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(reportMetrics);
    },
  }),
);
```

</Tab>

</Tabs>

## Progressive Patterns

Same `clock` + `fx.clock` from a calendar cron to an interval, a durable pause, and typed offsets:

<Tabs items={["Cron", "Interval", "Sleep", "Time helpers"]}>

<Tab value="Cron">

Five-field cron plus helpers. Prefer an app-wide zone so schedules stay short:

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const dailyReportClock = clock.daily("reports.daily", {
  at: "06:00",
});
```

```typescript title="src/flows/reports/daily.ts"
import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";

export const runDailyReport = on(
  dailyReportClock,
  flow("reports.runDaily", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(buildDailyReport, { at: fx.clock.now() });
    },
  }),
);
```

Set `oke({ clock: { timezone: "Asia/Riyadh" } })` once (or the same in
`defineConfig`). Omit per-clock `timezone` unless one schedule must differ —
see [Schedules](/docs/elements/clock/schedules).

</Tab>

<Tab value="Interval">

Human durations — integer + unit, no weeks: `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"`:

```typescript title="src/clocks/health.ts"
import { clock } from "okengine";

export const pingClock = clock.every("health.pingExternal", "30s");
```

```typescript title="src/flows/health/ping.ts"
import { on, flow } from "okengine";
import { pingClock } from "@/clocks/health";

export const pingExternal = on(
  pingClock,
  flow("health.pingExternal", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(pingUpstream);
    },
  }),
);
```

Per-tenant expansion and duration rules: [Schedules](/docs/elements/clock/schedules).

</Tab>

<Tab value="Sleep">

`fx.clock.sleep(label, duration)` — two arguments. Needs `durable: true` to park across restarts:

```typescript title="src/flows/trials/start.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const start = on(
  http.post({
    in: z.object({ email: z.string().email() }),
  }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("mark-trial", async () => {
        await fx.call(startTrial, { email });
      });
      await fx.clock.sleep("expiry-window", "3d");
      await fx.step("notify", async () => {
        await fx.send(trialExpiringEmail, { to: email });
      });
    },
  }),
);
```

Without a journal, sleep resolves immediately. Deep dive: [Durable Sleep](/docs/elements/clock/sleep).

</Tab>

<Tab value="Time helpers">

Never call `Date.now()` in a Flow — use the injectable surface:

```typescript
const now = fx.clock.now(); // epoch-ms
const cutoff = fx.clock.ago("7d"); // now − 7 days
const due = fx.clock.fromNow("14d"); // now + 14 days
const weekMs = fx.clock.duration("7d"); // span in ms
const expiresAt = createdAt + weekMs;
```

Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400_000 ms, not a
calendar day. Unknown strings parse as `0`.

</Tab>

</Tabs>

## Capability Reference

| Surface       | Signature                                                | Purpose                        | `do` input |
| ------------- | -------------------------------------------------------- | ------------------------------ | ---------- |
| Helpers       | `clock.daily` · `hourly` · `weekly` · `monthly` · `cron` | Calendar presets + field bags  | none (`_`) |
| Interval      | `clock.every(name, duration, opts?)`                     | Fixed duration loop            | none (`_`) |
| Per-tenant    | `clock.perTenant(name, opts)`                            | One Store row per tenant       | none (`_`) |
| Bare callable | `clock(name, { cron? \| every?, … })`                    | Same decl; lower-level form    | none (`_`) |
| Now / offsets | `fx.clock.now` · `ago` · `fromNow` · `duration`          | Injectable time math           | —          |
| Durable sleep | `fx.clock.sleep(label, duration)`                        | Park a durable Flow until wake | —          |

At least one of `cron` or `every` is required on every declaration.

## Per-environment drivers

Standard starters inherit `DRIVER_DEFAULTS`. Pin only when you diverge:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // omit clock to use defaults — pin only overrides
    // clock: { dev: "postgres", test: "frozen", prod: "postgres" },
  },
});
```

| Driver     | Runs as                     | Best for                               |
| ---------- | --------------------------- | -------------------------------------- |
| `postgres` | Shared `oke_crons` + leases | Dev/prod multi-instance (default)      |
| `file`     | `.oke/crons.json`           | Single-host multi-process              |
| `memory`   | In-process map              | Single process; no cross-instance lock |
| `frozen`   | Time-travel harness         | Tests — advance instead of waiting     |

**Consequence:** three pods with `postgres` still run each tick once (30s leader lease).
`memory` does not coordinate across processes.

## The Capabilities of Clock

<Cards>
  <Card
    title="Schedules"
    description="Cron helpers, every-intervals, per-tenant rows, leader locks, and catch-up one."
    href="/docs/elements/clock/schedules"
  />
  <Card
    title="Durable Sleep"
    description="Journaled pauses that resume across deploys — label + duration, durable Flows only."
    href="/docs/elements/clock/sleep"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title='TypeError: clock("name"): require cron or every'>
  `clock(name)` needs `{cron}` and/or `{every}`. Empty options throw at declaration, before `on()`.
</Accordion>

<Accordion title="tz is not a clock option">
  The field is `timezone` (IANA). Prefer `oke({ clock: { timezone } })` /
  `defineConfig({ clock: { timezone } })` so schedules can omit it.
  `{ tz: "Asia/Riyadh" }` is ignored.
</Accordion>

<Accordion title="Cron fired 24 times after overnight downtime">
  Catch-up is `"one"` — one fire per overdue clock, then `nextRunAt` advances. Missed slots show as
  `missedRuns`. A burst usually means several clocks or `clock.perTenant` rows, not a replay of
  hourly slots.
</Accordion>

<Accordion title="Two pods ran the same job">
  Leader election needs a shared Store (`drivers.clock` postgres, or `file` on one host). `memory`
  does not coordinate across processes. Check that instances share `DATABASE_URL`.
</Accordion>

<Accordion title="Sleep returns immediately / wrong wait">
  Missing `durable: true`, or `fx.clock.sleep("8h")` with one argument — `"8h"` is the **label**,
  duration is missing. Use `fx.clock.sleep("label", "8h")`.
</Accordion>

<Accordion title="ScheduleNotOverridableError from Console">
  Cause: `clock "{name}" is not overridable`. Add `overridable: true` and redeploy, or edit the
  declaration in source.
</Accordion>

<Accordion title="OKE1070 — flow name defined twice">
  Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings collide. Give at least one a
  distinct name. Clock allows several consumers on one schedule — each needs its own name.
</Accordion>

<Accordion title="OKE1072 — Clock flow unnamed">
  Cause: `A clock flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(clockDecl, flow("notes.digest", { do }))`.
  Inline `clock.every` still needs `flow("…")` — [Inline or named export](#inline-or-named-export).
</Accordion>

</Accordions>

## Learn more

- [Schedules](/docs/elements/clock/schedules) — cron, every, per-tenant, DST, catch-up, overrides
- [Durable Sleep](/docs/elements/clock/sleep) — `fx.clock.sleep(label, duration)`
- [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs) — bind with `on(clockDecl, flow)`
- [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` around sleeps
- [fx](/docs/reference/fx) — full `fx.clock` table
- [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError` · OKE1070 · OKE1072

## Next

<Cards>
  <Card
    title="Schedules"
    description="Cron helpers, intervals, timezones, leader locks, and catch-up one."
    href="/docs/elements/clock/schedules"
  />
  <Card
    title="Gate"
    description="Policies and rate limits on HTTP triggers."
    href="/docs/elements/gate"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC."
    href="/docs/elements/flow/consumers"
  />
</Cards>


# Schedules (/docs/elements/clock/schedules)

Named schedules trigger Flows on a calendar cron **or** a fixed duration. Declare with
`clock` / helpers, set the zone once on the app, then bind with `on(clockDecl, flow)`.

For developers running daily digests, health pings, and per-tenant billing loops on okengine.

<Callout title="The one rule">
  Calendar work: prefer `oke({ clock: { timezone } })` (or `defineConfig`) and omit
  `timezone` on each schedule. Intervals: durations are `ms|s|m|h|d` only — no weeks.
  Both shapes share leader locks, catch-up `"one"`, and the Store.
</Callout>


> Two schedule kinds — clock.every is a fixed interval, clock.daily / cron helpers are wall-clock (timezone from oke({ clock })) — both bind with on(trigger, flow) to the same Flow species.


## Smallest Example

<Steps>

<Step>
### Declare a schedule and bind a Flow

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const dailyReportClock = clock.daily("reports.daily", { at: "06:00" });
```

```typescript title="src/flows/reports/daily.ts"
import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";

export const runDailyReport = on(
  dailyReportClock,
  flow("reports.runDaily", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(buildDailyReport, { at: fx.clock.now() });
    },
  }),
);
```

Set the zone once so each schedule stays short:

```typescript title="src/app.ts"
import { oke } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});
```

Or `defineConfig({ clock: { timezone: "Asia/Riyadh" } })`. `oke({ clock })` wins over
config; a per-clock `timezone` wins over both.

</Step>

<Step>
### What fires

Every morning at 06:00 in the declared zone, the leader instance runs `reports.runDaily`.
`do` receives no payload — use `fx.clock.now()` when you need the fire instant.

With `oke dev`, the scheduler reconciles `reports.daily` into the Store and leader-elects
before each tick. Under `drivers.clock.test = "frozen"`, advance time in tests instead of
waiting for dawn.

</Step>

</Steps>

<Callout title="Jobs are Flows">
  `on(clockDecl, flow)` is the same species as HTTP handlers and Signal consumers. There is no
  separate job runner — see [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs).
</Callout>

## Progressive Patterns

From helpers and intervals to per-tenant rows and Console-tunable schedules:

<Tabs items={["Helpers", "Interval", "Per-tenant", "Complex fields", "Overridable"]}>

<Tab value="Helpers">

Named helpers compile to the same `ClockDecl` (a five-field `cron` or `every` string):

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const daily = clock.daily("reports.daily", { at: "06:00" });
export const rollup = clock.hourly("metrics.rollup", { minute: 0 });
export const digest = clock.weekly("notes.digest", {
  on: ["mon", "fri"],
  at: "09:00",
});
export const close = clock.monthly("billing.close", { on: [1, 15], at: "00:00" });
export const ping = clock.every("health.ping", "30s");
```

Prefer the named helpers above for fixed schedules. The bare callable remains fully
supported for the same `ClockDecl` shape when you need a raw string or a schedule
chosen programmatically: `clock("x", { cron: "0 6 * * *" })`, Bun nicknames
(`@hourly`), and `clock("x", { every: "30s" })`.

**Consequence:** Manifest / Store still see `cron: "0 6 * * *"` — helpers are declare-time sugar.

</Tab>

<Tab value="Interval">

Fixed duration loops — `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"`:

```typescript title="src/clocks/health.ts"
import { clock } from "okengine";

export const pingClock = clock.every("health.pingExternal", "30s");
```

```typescript title="src/flows/health/ping.ts"
import { on, flow } from "okengine";
import { pingClock } from "@/clocks/health";

export const pingExternal = on(
  pingClock,
  flow("health.pingExternal", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(pingUpstream);
    },
  }),
);
```

Combine with injectable offsets inside `do`:

```typescript
await fx.call(dropExpired, { before: fx.clock.ago("1h") });
```

Unknown duration strings parse as `0` ms and never become due. A `"d"` is exactly
86_400_000 ms — not a calendar day across DST. No week unit; no jitter on `clock()`
(retry jitter lives on Flow `retry`).

</Tab>

<Tab value="Per-tenant">

`clock.perTenant` expands one Store row per tenant (`{name}#{tenantId}`). The bare
template name is never ticked:

```typescript title="src/clocks/invoices.ts"
import { clock } from "okengine";

export const invoicesClock = clock.perTenant("invoices", { every: "1h" });
```

```typescript title="src/flows/billing/invoices.ts"
import { on, flow } from "okengine";
import { invoicesClock } from "@/clocks/invoices";

export const runInvoices = on(
  invoicesClock,
  flow("billing.invoices", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(closeOpenInvoices);
    },
  }),
);
```

Equivalent bare form (same decl; prefer `clock.perTenant` above):
`clock("invoices", { every: "1h", perTenant: true })`.

**Consequence:** ten tenants → ten leader-elected rows. Catch-up still fires **once per
row** after downtime — not a burst of missed hours per tenant.

</Tab>

<Tab value="Complex fields">

`clock.cron` accepts a string **or** a field bag (lists, ranges, steps, weekday names):

```typescript title="src/clocks/ops.ts"
import { clock } from "okengine";

export const sweep = clock.cron("ops.sweep", {
  minute: "*/15",
  hour: [9, 12, 17],
  dayOfWeek: "1-5",
});

export const digest = clock.cron("ops.digest", {
  at: "08:00",
  dayOfWeek: ["mon", "wed", "fri"],
});
```

| Field                                      | Examples                                            |
| ------------------------------------------ | --------------------------------------------------- |
| `minute` / `hour` / `dayOfMonth` / `month` | `0` · `[9, 12, 17]` · `"1-5"` · step strings        |
| `dayOfWeek`                                | `1` · `"mon"` · `["mon", "fri"]` · `"1-5"`          |
| `at`                                       | `"06:00"` — sets minute + hour when those are unset |

Only standard five-field cron (what Bun parses). No “last day of month” / “nth weekday”.

You may set both `cron` and `every` on one declaration; extract records the cron as the
Manifest trigger when both are present. Prefer one primary shape unless you want both.

</Tab>

<Tab value="Overridable">

`overridable: true` lets Console edit the effective cron/every in the Store:

```typescript title="src/clocks/digest.ts"
import { clock } from "okengine";

export const digestClock = clock.daily("notes.digest", {
  at: "08:00",
  overridable: true,
});
```

Without it, a Console edit fails with `ScheduleNotOverridableError`
(`clock "{name}" is not overridable`).

</Tab>

</Tabs>

## Helper Reference

| Helper / form     | Signature                                 | Default / notes                                                    | Compiles to           |
| ----------------- | ----------------------------------------- | ------------------------------------------------------------------ | --------------------- |
| `clock`           | `clock(name, { cron? \| every?, … })`     | Need `cron` and/or `every`                                         | same                  |
| `clock.daily`     | `clock.daily(name, { at?, … })`           | `at` default `"00:00"`; invalid `at` → `cron at: expected "HH:MM"` | five-field cron       |
| `clock.hourly`    | `clock.hourly(name, { minute?, … })`      | `minute` default `0`                                               | five-field cron       |
| `clock.weekly`    | `clock.weekly(name, { on, at?, … })`      | `on` required (`sun`…`sat` / `0–6`); `at` default `"00:00"`        | five-field cron       |
| `clock.monthly`   | `clock.monthly(name, { on, at?, … })`     | `on` required (day of month); no “last day” token                  | five-field cron       |
| `clock.cron`      | `clock.cron(name, expr \| fields, opts?)` | Empty field bag throws; invalid string → `Bun.cron.parse`          | five-field / nickname |
| `clock.every`     | `clock.every(name, duration, opts?)`      | Same duration grammar as `fx.clock.ago` / sleep                    | `every` string        |
| `clock.perTenant` | `clock.perTenant(name, opts)`             | Expands `{name}#{tenantId}` rows                                   | same + `perTenant`    |

At least one of `cron` or `every` is required on the callable form. Empty options throw
`clock("name"): require cron or every`. Invalid cron throws at declare (`invalid cron …`).

An empty `clock.cron` field bag throws
`cron fields: require at least one of at, minute, hour, dayOfMonth, month, dayOfWeek`.

## Timezone Resolution

Cron math needs an IANA zone. Resolve it once; override only when a schedule must differ.

| Source                                  | Wins when           | Example                                                    |
| --------------------------------------- | ------------------- | ---------------------------------------------------------- |
| Per-clock `timezone`                    | Always              | `clock.daily("x", { at: "06:00", timezone: "UTC" })`       |
| `oke({ clock: { timezone } })`          | No per-clock zone   | `oke({ name: "app", clock: { timezone: "Asia/Riyadh" } })` |
| `defineConfig({ clock: { timezone } })` | No `oke({ clock })` | config default                                             |
| Built-in default                        | Nothing else set    | `"UTC"`                                                    |

```typescript title="src/app.ts"
import { oke } from "okengine";
import { clock } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});

// Inherits Asia/Riyadh
export const digest = clock.daily("notes.digest", { at: "08:00" });

// Explicit UTC wins over the app default
export const utcRollup = clock.hourly("metrics.utc", {
  minute: 0,
  timezone: "UTC",
});
```

`{ tz: "…" }` is not an option — it is ignored. Intervals (`every`) are duration-based and
do not consult the zone for tick spacing; the zone still lands on the Store row.

## Binding & Input

Bind with `on(clockDecl, flow("name", { do }))`. One Flow can write `clock.every`
inside `on()` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
`do` has no payload; read time through `fx.clock`:

```typescript title="src/clocks/metrics.ts"
import { clock } from "okengine";

export const cleanupClock = clock.every("metrics.cleanup", "1h");
```

```typescript title="src/flows/metrics/cleanup.ts"
import { on, flow } from "okengine";
import { lt } from "drizzle-orm";
import { cleanupClock } from "@/clocks/metrics";
import { db, metricLogs } from "@/schema";

export const cleanup = on(
  cleanupClock,
  flow("metrics.cleanup", {
    plane: "operator",
    do: async (_, fx) => {
      await fx
        .store(db)
        .delete(metricLogs)
        .where(lt(metricLogs.timestamp, fx.clock.ago("7d")));
      return { at: new Date(fx.clock.now()).toISOString() };
    },
  }),
);
```

| Concern | Rule                                                               |
| ------- | ------------------------------------------------------------------ |
| Trigger | `on(clockDecl, flow)` — same Flow species as HTTP / Signal         |
| Input   | none — use `_`                                                     |
| Time    | `fx.clock.now` / `ago` / `fromNow` / `duration` only               |
| Plane   | Prefer `plane: "operator"` for background work                     |
| Import  | The `clock(…)` module must load at boot so reconcile sees the decl |

## Options Reference

Shared options on `clock(name, options)` and helpers:

| Option / helper                         | Type        | Default               | Meaning                                    |
| --------------------------------------- | ----------- | --------------------- | ------------------------------------------ |
| `cron`                                  | `string`    | —                     | Five-field cron or Bun nickname            |
| `every`                                 | `string`    | —                     | Interval (`"30s"`, `"1h"`, `"7d"`, …)      |
| `timezone`                              | IANA string | app default / `"UTC"` | Zone for cron math                         |
| `overridable`                           | `boolean`   | `false`               | Console may override schedule in the Store |
| `perTenant`                             | `boolean`   | `false`               | Expand `{name}#{tenantId}` rows            |
| `description`                           | `string`    | the name              | Console / docs blurb                       |
| `oke({ clock: { timezone } })`          | app option  | —                     | Default zone when `timezone` omitted       |
| `defineConfig({ clock: { timezone } })` | config      | —                     | Same; overridden by `oke({ clock })`       |

### Duration units (`every`)

| Unit | Example   | Milliseconds |
| ---- | --------- | ------------ |
| `ms` | `"200ms"` | 200          |
| `s`  | `"30s"`   | 30_000       |
| `m`  | `"5m"`    | 300_000      |
| `h`  | `"1h"`    | 3_600_000    |
| `d`  | `"7d"`    | 604_800_000  |

## Per-tenant Schedules

`clock.perTenant` expands one Store row per tenant (`{name}#{tenantId}`). The bare
template name never ticks — `runNow("invoices")` returns `false`.

Tenant ids come from reconcile (`tenantIds`). New tenants get rows; deleted tenants mark
those rows `orphaned`.

| Shape                    | Example         | Fires?               |
| ------------------------ | --------------- | -------------------- |
| Template (declared name) | `invoices`      | No — never ticked    |
| Expanded row             | `invoices#acme` | Yes — leader-elected |

**Consequence:** ten tenants → ten leases. Catch-up is `"one"` **per row**, so downtime
does not replay a burst of missed hours for each tenant.

## Leader Lock

Multi-instance deploys need a shared clock driver — otherwise every pod fires the same
tick. Dev/prod default is **postgres** (test is **frozen**). A short lease (default
**30s**) means only one instance runs each fire.

**Consequence:** three pods calling `runNow` still execute the Flow once. After the lease
expires, another instance may take the next tick.

| Driver     | Cross-instance lock                       | Best for                          |
| ---------- | ----------------------------------------- | --------------------------------- |
| `postgres` | Yes — shared `oke_crons`                  | Dev/prod multi-instance (default) |
| `file`     | Yes — on one host (`.oke/crons.json`)     | Single-host multi-process         |
| `memory`   | No — single process only                  | Local single process              |
| `frozen`   | Test harness — you drive `tick` / advance | Tests                             |

## Catch-up Policy


> Catch-up policy one: an hourly clock down for five hours reports missedRuns five and catchUp one, then a single tick runs the handler once — never a storm of five.


Catch-up is `"one"`: after 5 hours down on an hourly clock, the next tick fires **once**.
Missed slots are visible as `missedRuns` — they are not replayed as a burst.

**Consequence:** a digest that missed the night still runs once at boot, not 24 times.

Console health on each row exposes `driftMs`, `overdue`, `missedRuns`, and `catchUp: "one"`.
Flow `retry` is separate — it retries a failed fire, not missed calendar slots.

## Store Lifecycle & Console

Named clocks reconcile into the Store at boot. The scheduler reads **effective** state from
the Store, never the source declaration directly.

```text
Declared  (Manifest / code)     ← truth for names and defaults
Override  (Store, overridable)  ← operational drift
Effective = declared + override ← what actually runs
```

| Status     | Meaning                       | Fires?                       |
| ---------- | ----------------------------- | ---------------------------- |
| `active`   | Reconciled and enabled        | Yes (when due + lease)       |
| `paused`   | Operator paused via Console   | No — until `active` again    |
| `orphaned` | Decl removed (or tenant gone) | No — row kept, never deleted |

Reconcile upserts still-declared clocks as `active` (preserving lease / `lastRunAt` /
overrides when `overridable`). Removed declarations become `orphaned`.

| Console action  | Requirement         | Error when missing                                     |
| --------------- | ------------------- | ------------------------------------------------------ |
| Run now         | Active row + lease  | `ClockResourceNotFoundError` — `cron "{id}" not found` |
| Pause           | Existing row        | same                                                   |
| Edit cron/every | `overridable: true` | `ScheduleNotOverridableError`                          |

**Consequence:** without `overridable: true`, change the schedule in source and redeploy —
Console edits are refused. Redeploying a still-overridable clock **preserves** overrides.

DST gap / fall-back overlap attaches a Store warning (`gap` / `overlap`); UTC never
warns; the scheduler still ticks. Detection covers simple `M H * * *` / `M H * * DOW`
forms; on overlap days crontab fires **once** (first occurrence).

| Prefer                                          | When                                 |
| ----------------------------------------------- | ------------------------------------ |
| `timezone: "UTC"`                               | No DST warnings                      |
| Non-ambiguous local hour (`08:00`, not `02:00`) | Digests that stay silent on DST days |

## Troubleshooting

<Accordions>

<Accordion title='TypeError: clock("name"): require cron or every'>
  Pass `{cron}` and/or `{every}`, or use a helper (`clock.daily`, `clock.every`, …). Declaration
  throws before `on()`.
</Accordion>

<Accordion title="invalid cron at declare">
  Cause: the expression failed `Bun.cron.parse`. Fix the five-field string / nickname, or fix
  structured fields (`at` must be `"HH:MM"`). Error shape: `clock("…"): invalid cron "…" (…)`.
</Accordion>

<Accordion title='cron at: expected "HH:MM"'>
  `at` on helpers / field bags must be `"HH:MM"` or `"H:MM"` with hour `0–23` and minute `0–59`.
  `"6am"` and `"06:00:00"` fail.
</Accordion>

<Accordion title="Interval never fires">
  Unknown duration strings parse as `0` and never become due. Use `ms|s|m|h|d` only. Confirm the
  Flow is bound with `on(clockDecl, flow)` and the clock module is imported at boot.
</Accordion>

<Accordion title="tz is not a clock option">
  Use `timezone: "Asia/Riyadh"` on the declaration, or set the app default with
  `oke({ clock: { timezone: "Asia/Riyadh" } })` /
  `defineConfig({ clock: { timezone: "Asia/Riyadh" } })`. `{ tz: "…" }` is ignored.
</Accordion>

<Accordion title="Cron / interval fired many times after downtime">
  Catch-up is `"one"` per Store row. A burst usually means several clocks or `clock.perTenant` rows
  (one per tenant), not a replay of missed slots.
</Accordion>

<Accordion title="Two pods ran the same job">
  Need shared `drivers.clock` postgres (or `file` on one machine). `memory` does not elect a leader
  across processes.
</Accordion>

<Accordion title="Bare template name never ticks">
  Expected for `perTenant: true` — only `{name}#{tenantId}` rows fire. Ensure tenant ids are
  available at reconcile.
</Accordion>

<Accordion title="ScheduleNotOverridableError from Console">
  Cause: `clock "{name}" is not overridable`. Add `overridable: true` and redeploy, or change the
  schedule in source.
</Accordion>

<Accordion title="ClockResourceNotFoundError">
  Cause: `cron "{id}" not found` (or `run "{id}" not found`). The Console action targeted a name
  that is not in the reconciled Store — check spelling against your `clock()` declarations.
</Accordion>

<Accordion title="OKE1072 — Clock flow unnamed">
  Cause: `A clock flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(clockDecl, flow("reports.runDaily", { do }))`.
  Inline `clock.every` still needs `flow("…")` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
</Accordion>

<Accordion title="DST gap / overlap warning on the row">
  Informational. Pick a UTC cron, a non-ambiguous local hour, or accept the warning. The job still
  schedules.
</Accordion>

<Accordion title="Paused schedule never fires">
  Console pause sets `status: "paused"`. The scheduler skips it. A later reconcile of a
  still-declared clock restores `active` (and keeps overrides when `overridable`).
</Accordion>

</Accordions>

## Learn more

- [Clock overview](/docs/elements/clock) — `fx.clock` helpers and drivers
- [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export) — one Flow vs shared schedule
- [Durable Sleep](/docs/elements/clock/sleep) — `fx.clock.sleep(label, duration)`
- [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs) — bind with `on(clockDecl, flow)`
- [fx](/docs/reference/fx) — `fx.clock.now` / `ago` / `fromNow` / `duration`
- [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError` · OKE1072

## Next

<Cards>
  <Card
    title="Durable Sleep"
    description="Journaled pauses with fx.clock.sleep(label, duration)."
    href="/docs/elements/clock/sleep"
  />
  <Card
    title="Clock Overview"
    description="Return to the Clock element overview."
    href="/docs/elements/clock"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC."
    href="/docs/elements/flow/consumers"
  />
</Cards>


# Durable Sleep (/docs/elements/clock/sleep)

Durable sleep suspends a Flow until a future instant without holding a worker thread.
Call `await fx.clock.sleep(label, duration)` inside a `durable: true` Flow — the journal
stores `wakeAt`, releases the run lease, and any instance may resume when due.

For developers building trial reminders, delayed digests, and multi-day provisions on okengine.

<Callout title="The one rule">
  Signature is `fx.clock.sleep(label, duration)` — two arguments. A single duration string is
  treated as the **label**, not the wait. Sleep only parks when the Flow is durable.
</Callout>


> Durable sleep: fx.clock.sleep journals the wake time; after a restart the flow resumes at that step instead of losing its place.


## Smallest Example

<Steps>

<Step>
### Sleep inside a durable Flow

```typescript title="src/flows/trials/start.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const start = on(
  http.post({
    in: z.object({ email: z.string().email() }),
  }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("mark-trial", async () => {
        await fx.call(startTrial, { email });
      });
      await fx.clock.sleep("expiry-window", "3d");
      await fx.step("notify", async () => {
        await fx.send(trialExpiringEmail, { to: email });
      });
    },
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/trials/start \
  -H "content-type: application/json" \
  -d '{"email":"ada@example.com"}'
```

Response: **`204 No Content`** (empty body). The HTTP client is done — wake continues on a
worker three days later. Wrap side effects in `fx.step` so replay does not re-send mail.

</Step>

</Steps>

<Callout title="Detailed section">
  If you only need a pause, the example above is enough. Below: park physics, wake-early,
  nested-call limits, non-durable no-ops, and how sleep fits with steps / compensation.
</Callout>

## Progressive Patterns

From a labelled pause to HTTP parking, nested-call limits, and non-durable behavior:

<Tabs items={["Label + duration", "HTTP park", "After a step", "Non-durable"]}>

<Tab value="Label + duration">

Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. Labels show up in the journal
and Console:

```typescript
await fx.clock.sleep("morning-window", "8h");
await fx.clock.sleep("verify-window", "2m");
```

**Wrong:** `fx.clock.sleep("8h")` — `"8h"` is the label; duration is missing → immediate
resolve / unexpected behavior.

</Tab>

<Tab value="HTTP park">

Sleep on an HTTP-triggered durable Flow returns success with an empty body (`204`). Return
a body **before** sleep if the caller must see an id:

```typescript title="src/flows/workspaces/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({ email: z.string().email() }),
  }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("create", async () => {
        await fx.call(createWorkspace, { email });
      });
      await fx.emit(workspaceProvisioned, { email });
      await fx.clock.sleep("welcome-delay", "8h");
      await fx.step("notify", async () => {
        await fx.send(welcomeEmail, { to: email });
      });
    },
  }),
);
```

After sleep the HTTP client is already done (`204`). Need a JSON body? Return from a
Flow that does **not** sleep, and park on a Signal consumer instead. See
[Workflows · Durable Sleep](/docs/elements/flow/workflows#durable-sleep).

</Tab>

<Tab value="After a step">

Checkpoint work **before** the pause so a crash mid-provision does not re-run after wake:

```typescript
await fx.step("provision", async () => {
  await fx.call(createWorkspace, { email });
});
await fx.clock.sleep("morning-window", "8h");
await fx.step("notify", async () => {
  await fx.send(welcomeEmail, { to: email });
});
```

Do not hold `fx.using` resources across sleep — acquire again after wake.

</Tab>

<Tab value="Non-durable">

Without a journal, `fx.clock.sleep` resolves **immediately**. There is no thread sleep and
no `setTimeout`. Use durable Flows for real delays; use frozen clock + advance in tests.

</Tab>

</Tabs>

## Sleep Reference

| Call / option                     | Type        | Default | Meaning                                            |
| --------------------------------- | ----------- | ------- | -------------------------------------------------- |
| `durable: true`                   | Flow option | `false` | Journals steps and sleep; required to park         |
| `fx.clock.sleep(label, duration)` | park        | —       | Writes `wakeAt`, status `sleeping`, releases lease |
| `label`                           | `string`    | —       | Journal / Console step name                        |
| `duration`                        | `string`    | —       | `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`     |

Outcomes of a durable attempt that hits sleep: status `"sleeping"` with `wakeAt` and
`label`. After wake, prior `fx.step` bodies replay from the journal and do not re-execute.

### Duration units

Same grammar as `fx.clock.ago` / `fromNow` / `clock.every`:

| Unit | Example   | Milliseconds |
| ---- | --------- | ------------ |
| `ms` | `"200ms"` | 200          |
| `s`  | `"30s"`   | 30_000       |
| `m`  | `"2m"`    | 120_000      |
| `h`  | `"1h"`    | 3_600_000    |
| `d`  | `"7d"`    | 604_800_000  |

Integer + unit only — no weeks. `"d"` is exactly 86_400_000 ms, not a calendar day across
DST. Unknown strings parse as `0` ms (wake immediately).

## Park Physics

<Callout title="Detailed section">
  If you only need `await fx.clock.sleep("label", "8h")`, jump past this section. The park writes a
  journal entry, sets status `sleeping`, and **releases the run lease** so a parked flow does not
  hold a 30s lock for days.
</Callout>

```text
fx.clock.sleep(label, duration)
  → journal entry { kind: "sleep", label, duration, wakeAt }
  → status = sleeping, lease released
  → HTTP / caller sees success (often 204)
  → later: claim due sleep → resume → replay steps → continue after sleep
```

Any instance may claim the row when `wakeAt` is due (shared journal + lease). Resume replays
completed `fx.step` values from the journal, then continues past the sleep entry.

| Phase          | Status                 | Lease        | What happens                                  |
| -------------- | ---------------------- | ------------ | --------------------------------------------- |
| Before sleep   | `running`              | Held         | Steps append; side effects execute once       |
| At sleep       | `sleeping`             | **Released** | `wakeAt` stored; worker free                  |
| After `wakeAt` | claim → `running`      | Re-acquired  | Replay steps; sleep entry is a no-op past due |
| Done           | `completed` / `failed` | Released     | Terminal — resume refused                     |

**Consequence:** multi-day sleeps are safe across deploys — the journal row is the schedule,
not an in-memory timer.

## With Steps

Each verb of durable work binds the same way — checkpoint, park, then more checkpoints:

<Tabs items={["Checkpoint sandwich", "HTTP body + sleep", "Signal consumer"]}>

<Tab value="Checkpoint sandwich">

Wrap every side effect in a uniquely named `fx.step`. Sleep sits **between** steps:

```typescript title="src/flows/onboarding/welcome.ts"
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";

export const sendWelcome = on(
  userSignedUp,
  flow("onboarding.welcome", {
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("provision", async () => {
        await fx.call(createWorkspace, { email });
      });
      await fx.clock.sleep("morning-window", "8h");
      await fx.step("notify", async () => {
        await fx.send(welcomeEmail, { to: email });
      });
    },
  }),
);
```

**Consequence:** after wake, `provision` returns the journaled value and never re-runs;
`notify` runs for the first time.

</Tab>

<Tab value="HTTP body + sleep">

Parking an HTTP Flow always answers **`204`** (suspend returns `undefined`). When the
caller must see an id, **do not sleep on that route** — return the body, emit, and park
on a consumer:

```typescript title="src/flows/trials/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({ email: z.string().email() }),
    out: z.object({ trialId: z.string() }),
  }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      const trialId = await fx.step("create", async () => {
        return await fx.call(createTrial, { email });
      });
      await fx.emit(trialStarted, { trialId, email });
      return { trialId };
    },
  }),
);
```

</Tab>

<Tab value="Signal consumer">

The consumer owns the multi-day wait (paired with the HTTP Flow above):

```typescript title="src/flows/trials/remind.ts"
import { on, flow } from "okengine";
import { trialStarted } from "@/signals";
import { trialExpiringEmail } from "@/channels/trials";

export const remind = on(
  trialStarted,
  flow("trials.remind", {
    durable: true,
    do: async ({ trialId, email }, fx) => {
      await fx.clock.sleep("expiry-window", "3d");
      await fx.step("notify", async () => {
        await fx.send(trialExpiringEmail, { to: email, trialId });
      });
    },
  }),
);
```

</Tab>

</Tabs>

## Nested Calls

<Callout title="Detailed section">
  If you sleep only on the root durable Flow, skip this section. `fx.call` waits for the callee to
  return — a sleeping callee does not keep the caller parked.
</Callout>

`fx.call` of a durable sleeper: the caller receives `undefined` and continues; the child
wakes later as its own run.

| Pattern                                       | Safe? | Why                                        |
| --------------------------------------------- | ----- | ------------------------------------------ |
| Sleep on the root durable Flow                | Yes   | One journal row, clear park                |
| `fx.emit` → durable consumer that sleeps      | Yes   | Separate run owns the wait                 |
| `fx.call(sleeperFlow, …)` then use the result | No    | Caller gets `undefined`; child parks alone |

**Consequence:** sleep on the **root** durable Flow, or split with `fx.emit` to a consumer —
do not nest sleep behind `fx.call`.

## Wake Early

Operators can advance `wakeAt` to **now** from Console (waiting-on / wake-early). With a
flow resolver, the run resumes immediately; without it, only the wake time moves forward.

| Outcome                      | Meaning                                                          |
| ---------------------------- | ---------------------------------------------------------------- |
| Resumed                      | `wakeAt` set to now and `runDurable` continued past the sleep    |
| Wake time only               | `wakeAt` advanced; scheduler / next claim picks it up            |
| `ClockResourceNotFoundError` | `run "{id}" not found` — missing id, or status is not `sleeping` |

Missing or non-sleeping run id → `ClockResourceNotFoundError` — `run "{id}" not found`.

## Non-durable & Tests

Without a journal session, `fx.clock.sleep` resolves immediately (tests and sync Flows).
There is no thread sleep and no `setTimeout`.

For deterministic waits in tests, use a frozen clock and advance:

```typescript
import { createTimeTravel } from "okengine";

const t = createTimeTravel(0);
// Park a durable run with now: () => t.now()
t.advance("7d");
// Resume — sleep entry is past due and continues
```

Pin journal for the env you need:

| Driver     | Default env    | Best for                                             |
| ---------- | -------------- | ---------------------------------------------------- |
| `postgres` | `dev`, `prod`  | Shared durable runs across replicas (`DATABASE_URL`) |
| `memory`   | `test`         | Process-local; lost on exit                          |
| `file`     | pin explicitly | One machine — `.oke/journal.json`                    |

Unknown ids throw `oke boot: unknown journal driver "…" (expected memory · file · postgres)`.
Postgres without a URL throws `oke boot: journal driver "postgres" needs DATABASE_URL`.

Full lease / orphan / status tables live on [Workflows · Journal](/docs/elements/flow/workflows#journal).

## Troubleshooting

<Accordions>

<Accordion title="Sleep returns immediately / work runs twice after wait">
  Missing `durable: true`, or `fx.clock.sleep("8h")` with one argument. Use `fx.clock.sleep("label",
  "8h")`. Non-durable sleep is a no-op.
</Accordion>

<Accordion title="HTTP 204 with empty body after POST">
  The Flow parked on `fx.clock.sleep` — success, not a missing handler. The original client is done;
  wake continues on a worker. Return a body before sleep if needed, or emit to a Signal consumer for
  the long wait.
</Accordion>

<Accordion title="Mail / charge ran again after resume">
  The side effect was outside `fx.step`. Wrap provider calls in uniquely named steps so replay
  returns the journaled value.
</Accordion>

<Accordion title="Connection held across sleep">
  `fx.using` is same-attempt cleanup — do not hold handles across `fx.clock.sleep`. Acquire again
  after wake.
</Accordion>

<Accordion title="ClockResourceNotFoundError on wake">
  Cause: `run "{id}" not found`. The wake-early target is missing or not `sleeping` — check the run
  id from Console / durable result.
</Accordion>

<Accordion title="fx.call of a durable sleeper returned undefined">
  The callee parked. Sleep on the root Flow, or emit to a durable consumer instead of calling a
  sleeper inline.
</Accordion>

<Accordion title="Unknown duration wakes immediately">
  Strings that do not match `integer + ms|s|m|h|d` parse as `0` ms. Use `"8h"`, not `"8 hours"` or
  `"1w"`.
</Accordion>

<Accordion title='journal: run "…" is leased by another instance'>
  Resume lost the lease race (`JournalLeaseBusy`). The other holder continues; this instance skips.
  Shared `postgres` journal + default **30s** lease — same physics as Signal claims.
</Accordion>

<Accordion title="GET /_/ready is 503 reason orphan_scan">
  Boot is still reclaiming `running` / `sleeping` / `compensating` rows with no live lease. Wait for
  the orphan scan; future sleeps stay scheduled until `wakeAt`.
</Accordion>

</Accordions>

## Learn more

- [Workflows](/docs/elements/flow/workflows) — `durable`, `fx.step`, compensate, full sleep section
- [HTTP](/docs/elements/flow/http) — request envelope; `204` from park / `undefined`
- [Clock overview](/docs/elements/clock) — schedules and `fx.clock` helpers
- [Schedules](/docs/elements/clock/schedules) — cron / every (calendar time, not durable pause)
- [fx](/docs/reference/fx) — `fx.clock.sleep`, `fx.step`, `fx.using`
- [Configuration](/docs/reference/configuration) — `drivers.journal`
- [Errors](/docs/reference/errors) — `ClockResourceNotFoundError`

## Next

<Cards>
  <Card
    title="Durable Workflows"
    description="Step journaling, undo, and multi-day runs."
    href="/docs/elements/flow/workflows"
  />
  <Card
    title="Schedules"
    description="Cron helpers, intervals, and IANA timezones."
    href="/docs/elements/clock/schedules"
  />
  <Card
    title="Clock Overview"
    description="Return to the Clock element overview."
    href="/docs/elements/clock"
  />
</Cards>


# Consumers (/docs/elements/flow/consumers)

Consumers are Flows that run when something else happens — a Signal emit, a named Clock tick, or a SQL row change — instead of waiting for an HTTP request.

For developers wiring background work on okengine — bind the trigger, keep `do` on `fx`.

<Callout title="The one rule">
  Bind with `on(signal)`, `on(clockDecl)`, or `on(db.table(…).changed())`. Delivery physics live on
  the Signal; `cron` / `every` live on the Clock; CDC input is `{ before, after }` plus `table` /
  `action` / `id`. World access still goes through `fx`.
</Callout>

## Smallest Example

<Callout title="Bind and emit are independent">
  The consumer and the producer share one Signal handle. They can live in different files, written
  in any order — emit is not "step 2" after bind.
</Callout>

### Bind a Signal consumer

```typescript title="src/flows/notifications/welcome.ts"
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";

export const sendWelcome = on(
  userSignedUp,
  flow("notifications.welcome", {
    do: async ({ userId, email }, fx) => {
      await fx.send(welcomeEmail, {
        to: email,
        data: { userId },
      });
    },
  }),
);
```

### Emit from any Flow

```typescript
await fx.emit(userSignedUp, { userId: "usr_123", email: "alice@example.com" });
```

The compiler records `emits: ["users.signed-up"]` on the producer. The consumer runs after the
emit commits — the HTTP request does not wait for the welcome mail.

<Callout title="Jobs are consumers">
  A named Clock bound with `on(clockDecl, flow)` is the same species — an asynchronous Flow. There
  is no separate job runner. See [Clock jobs](#clock-jobs).
</Callout>

## Progressive Patterns

Explore consumers from a typed queue worker to a cron job and a table-change handler:

<Tabs items={["Signal", "Clock", "CDC", "Ordered"]}>

<Tab value="Signal">

Declare delivery physics on the Signal, then bind the worker with `on(handle, flow)`:

```typescript title="src/signals/email.ts"
import { signal } from "okengine";
import { z } from "zod";

export const emailTask = signal.once("tasks.email", {
  schema: z.object({ to: z.string().email(), body: z.string() }),
  retries: 3,
  deadLetter: true,
});
```

```typescript title="src/flows/workers/email.ts"
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";

export const processEmail = on(
  emailTask,
  flow("workers.email", {
    do: async ({ to, body }, fx) => {
      await fx.send(rawEmail, { to, body });
    },
  }),
);
```

</Tab>

<Tab value="Clock">

Name the schedule, then bind it. One Flow can write `clock.every` inside `on()` —
[Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).

```typescript title="src/clocks/metrics.ts"
import { clock } from "okengine";

export const cleanupClock = clock.every("metrics.cleanup", "1h");
```

```typescript title="src/flows/metrics/cleanup.ts"
import { on, flow } from "okengine";
import { lt } from "drizzle-orm";
import { cleanupClock } from "@/clocks/metrics";
import { db, metricLogs } from "@/schema";

export const cleanupMetrics = on(
  cleanupClock,
  flow("metrics.cleanup", {
    do: async (_, fx) => {
      await fx
        .store(db)
        .delete(metricLogs)
        .where(lt(metricLogs.timestamp, fx.clock.ago("7d")));
    },
  }),
);
```

</Tab>

<Tab value="CDC">

`db.table(handle).changed()` fires on insert, update, and delete. Input is `{ before, after }`
plus `table`, `action`, and `id`:

```typescript title="src/flows/audit/users.ts"
import { on, flow } from "okengine";
import { db } from "@/core";
import { users, auditLogs } from "@/schema";

export const onUserWrite = on(
  db.table(users).changed(),
  flow("audit.users", {
    do: async ({ table, action, id }, fx) => {
      await fx
        .store(db)
        .insert(auditLogs)
        .values({
          table,
          recordId: String(id),
          action,
        });
    },
  }),
);
```

</Tab>

<Tab value="Ordered">

`signal.once` plus `fx.emit(…, { key })` serializes work per key. Same key never runs
concurrently — the in-flight visibility lease is the lock:

```typescript title="src/flows/orders/ship.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const shipOrder = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(fulfillOrder, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });
```

Omit `key` for competing consumers with no ordering.

</Tab>

</Tabs>

## Trigger Reference

| Trigger    | Signature                                                        | Purpose                                     | `do` input                             |
| ---------- | ---------------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
| Signal     | `on(handle, flow("name", { do }))`                               | Queue (`once`) or fan-out (`broadcast`)     | Payload (`schema`)                     |
| Clock      | `on(clockDecl, flow("name", { do }))` or inline `clock.every(…)` | Interval or cron tick                       | none (`_`)                             |
| CDC any    | `on(db.table(t).changed(), flow)`                                | Every insert / update / delete              | `{ before, after, table, action, id }` |
| CDC column | `on(db.table(t).changed("col"), flow)`                           | Same writes; column stamped on the Manifest | `{ before, after, table, action, id }` |

`signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams),
not as a worker. A Flow with no trigger is [call-only](/docs/elements/flow).

Signal is always an exported const (`fx.emit` needs the handle). Clock may write
`clock.every(…)` inside `on()` — [Clock · Inline or named
export](/docs/elements/clock#inline-or-named-export). **OKE1072** if nameless.

## Signal Consumers

<Callout title="Detailed section">
  If you only need a worker, jump to Once below. Physics live on `signal.once` / `broadcast` /
  `live` — same idea as `http.get` / `http.post`. Defaults: `retries: 3`, `deadLetter: true`,
  `optional: false`.
</Callout>

Each emit is handled according to the Signal helper you declared. The Flow is the subscriber.

<Tabs items={["Once", "Broadcast", "Optional"]}>

<Tab value="Once">

Competing workers — exactly one consumer claims each message. Failed attempts retry, then
dead-letter when `deadLetter` is true (default).

Two different Flows on one `once` signal fail **OKE1071** — see
[Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
For every bound Flow to run, use [`signal.broadcast`](/docs/elements/signal/broadcast).

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderPlaced = signal.once("orders.placed", {
  schema: z.object({
    orderId: z.string(),
    amount: z.number(),
    userId: z.string(),
  }),
  retries: 5,
  deadLetter: true,
});
```

```typescript title="src/flows/orders/fulfill.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const fulfill = on(
  orderPlaced,
  flow("orders.fulfill", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);
```

Visibility lease defaults to **30s**. An inflight worker that dies is reclaimed on the next claim.

</Tab>

<Tab value="Broadcast">

Every subscribed Flow gets a copy. Offline listeners miss the event — there is no replay tape:

```typescript title="src/signals/cache.ts"
import { signal } from "okengine";
import { z } from "zod";

export const catalogChanged = signal.broadcast("catalog.changed", {
  schema: z.object({ sku: z.string() }),
});
```

```typescript title="src/flows/cache/invalidate.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/cache";

export const invalidate = on(
  catalogChanged,
  flow("cache.invalidate", {
    do: async ({ sku }, fx) => {
      await fx.cache.delete(`sku:${sku}`);
    },
  }),
);
```

A second Flow bound to the same Signal also runs. That is the fan-out.

</Tab>

<Tab value="Optional">

Emit with zero subscribers throws **OKE1240** unless `optional: true`. Use that for live
firehoses and hook Signals that may have no worker yet:

```typescript
export const webhook = signal.once("hooks.inbound", {
  optional: true,
  schema: z.object({ id: z.string() }),
});
```

**OKE1240** cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
Fix: add `on(signal, …)` or mark `{ optional: true }`.

</Tab>

</Tabs>

<Accordions>

<Accordion title="Signal Options">
  Optional second argument to `signal.once` / `broadcast` / `live`. Delivery is the helper name.

| Option        | Type                     | Default   | Meaning                                                      |
| ------------- | ------------------------ | --------- | ------------------------------------------------------------ |
| `schema`      | Standard Schema          | omitted   | Enforced at `fx.emit` (**OKE1250** on mismatch)              |
| `retries`     | `number`                 | `3`       | Extra attempts after the first (`retries + 1` total)         |
| `deadLetter`  | `boolean`                | `true`    | Keep exhausted `once` messages; `false` marks them delivered |
| `optional`    | `boolean`                | `false`   | Allow emit with zero subscribers                             |
| `retention`   | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast`  |
| `description` | `string`                 | the name  | Console / docs blurb                                         |

</Accordion>

<Accordion title="Ordering">
  Pass `{ key }` on emit. No two `once` messages sharing `(signal, key)` are claimed at once.

```typescript
await fx.emit(emailTask, payload, { key: user.id });
```

Same key → FIFO. Different keys run in parallel. Omit `key` for a pure competing pool.

</Accordion>

<Accordion title="Retries & dead letters">
  `once` retries then DLQ. After `retries + 1` handler invocations the message is dead when
  `deadLetter: true`. Inspect with `fx.deadLetters(signal)`.

`deadLetter: false` marks the message delivered after the last attempt — nothing lands in the DLQ.

Broadcast does not use the `once` lease / DLQ path. Live uses the retained tape, not this worker.

</Accordion>

<Accordion title="Schema at emit">
  Invalid payloads fail at `fx.emit` with **OKE1250** (`"{resource}": {detail}`) before any consumer
  runs. This is an **emit** contract — workers inherit the payload from the Signal; they do not
  declare `in` on `flow()`.
</Accordion>

</Accordions>

## Clock Jobs

<Callout title="Detailed section">
  Prefer named helpers (`clock.every` / `daily` / `cron`). Bind with
  `on(clockDecl, flow("name", { do }))`, or write `clock.every` inside `on()` —
  [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
</Callout>

Named clocks reconcile into the Store at boot. The scheduler leader-elects so N instances do not
double-fire. `do` receives no payload — read time through `fx.clock`.

<Tabs items={["Interval", "Cron", "Per-tenant"]}>

<Tab value="Interval">

Human durations: `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"` (integer + unit, no weeks):

```typescript title="src/clocks/health.ts"
import { clock } from "okengine";

export const pingClock = clock.every("health.pingExternal", "30s");
```

```typescript title="src/flows/health/ping.ts"
import { on, flow } from "okengine";
import { pingClock } from "@/clocks/health";

export const pingExternal = on(
  pingClock,
  flow("health.pingExternal", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(pingUpstream);
    },
  }),
);
```

</Tab>

<Tab value="Cron">

Five-field cron (`m h dom mon dow`) plus an IANA `timezone` (default `"UTC"`):

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const dailyReportClock = clock.daily("reports.daily", {
  at: "06:00",
  timezone: "Asia/Riyadh",
});
```

```typescript title="src/flows/reports/daily.ts"
import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";

export const runDailyReport = on(
  dailyReportClock,
  flow("reports.runDaily", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(buildDailyReport, { at: fx.clock.now() });
    },
  }),
);
```

You may set `cron` and `every` together. Extract records the cron expression as the Manifest
trigger when both are present.

</Tab>

<Tab value="Per-tenant">

`clock.perTenant` expands one Store row per tenant (`{name}#{tenantId}`). The bare template name
is never ticked:

```typescript title="src/clocks/invoices.ts"
import { clock } from "okengine";

export const invoicesClock = clock.perTenant("invoices", { every: "1h" });
```

```typescript title="src/flows/billing/invoices.ts"
import { on, flow } from "okengine";
import { invoicesClock } from "@/clocks/invoices";

export const runInvoices = on(
  invoicesClock,
  flow("billing.invoices", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(closeOpenInvoices);
    },
  }),
);
```

Equivalent bare form (same decl; prefer `clock.perTenant` above):
`clock("invoices", { every: "1h", perTenant: true })`.

</Tab>

</Tabs>

<Accordions>

<Accordion title="Clock Options">
  Second argument to `clock(name, options)` / `clock.perTenant(name, options)`.

| Option        | Type        | Default  | Meaning                                        |
| ------------- | ----------- | -------- | ---------------------------------------------- |
| `cron`        | `string`    | —        | Five-field cron (`m h dom mon dow`)            |
| `every`       | `string`    | —        | Interval (`"10s"`, `"1h"`, `"7d"`, …)          |
| `timezone`    | IANA string | `"UTC"`  | Zone for cron (intervals are duration-based)   |
| `overridable` | `boolean`   | `false`  | Console may override the schedule in the Store |
| `perTenant`   | `boolean`   | `false`  | Expand `{name}#{tenantId}` rows                |
| `description` | `string`    | the name | Console / docs blurb                           |

At least one of `cron` or `every` is required.

</Accordion>

<Accordion title="Leader lock">
  Dev/prod clock driver is **postgres** (test is **frozen**). A short lease (default **30s**)
  means only one instance runs each tick.

**Consequence:** three pods calling `runNow` still execute the Flow once. After the lease
expires, another instance may take the next tick.

`file` (`.oke/crons.json`) elects across processes on one machine. `memory` is single-process.

</Accordion>

<Accordion title="Catch-up policy one">
  Catch-up is `"one"`: after 5 hours down on an hourly clock, the next tick fires **once**.
  Missed slots are visible as `missedRuns` — they are not replayed as a burst.

**Consequence:** a digest that missed the night still runs once at boot, not 24 times.

</Accordion>

<Accordion title="DST & overrides">
  Cron + a DST zone that lands in a spring-forward gap or fall-back overlap attaches a
  **warning** on the Store row (`gap` / `overlap`). UTC never warns. The scheduler still ticks.

`overridable: true` lets Console edit the effective cron/every. Without it, a Console edit
fails with `ScheduleNotOverridableError`. Removed declarations become `orphaned` rows and
do not fire.

</Accordion>

</Accordions>

## CDC

<Callout title="Detailed section">
  If you only need any-write, jump to Bare or enriched below. The handle is
  `db.table(table).changed(column?)` — `db` is a `store.sql` declaration, `table` is a schema
  handle. `changed("insert")` is **not** an op filter; it stamps a column named `insert`.
</Callout>

SQL writes through `fx.store` notify CDC after commit. The Flow input is always
`{ before, after, table, action, id }` (`CdcPayload`).

### Bare or enriched

| Style                     | When                                                                   |
| ------------------------- | ---------------------------------------------------------------------- |
| `({ before, after })`     | Bound to one table — images are enough (search reindex, listing cache) |
| `({ table, action, id })` | Log, route, or branch — kind of change and which record (audit log)    |

Both styles receive the same object. There is no second dispatch path, no
performance difference, and no correctness difference — the choice is which
fields this handler destructures.

`{ table, action, id }` are always populated; omitting them from `do` does not
drop them from the payload.

<Tabs items={["Bare", "Enriched", "Images"]}>

<Tab value="Bare">

Bound to `notes` — the table is already in the trigger. Images decide upsert vs
drop; the row's `id` is on the surviving image:

```typescript title="src/flows/search/reindex.ts"
import { on, flow } from "okengine";
import { db } from "@/core";
import { notes } from "@/schema";

export const reindexNotes = on(
  db.table(notes).changed(),
  flow("search.reindexNotes", {
    plane: "operator",
    do: async ({ before, after }, fx) => {
      if (!after) {
        await fx.call(dropNoteIndex, { id: String(before?.id ?? "") });
        return;
      }
      await fx.call(upsertNoteIndex, { id: String(after.id) });
    },
  }),
);
```

</Tab>

<Tab value="Enriched">

`table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
`id` is the declared primary-key value — not a hardcoded `"id"` column:

```typescript title="src/flows/audit/users.ts"
import { on, flow } from "okengine";
import { db } from "@/core";
import { users, auditLogs } from "@/schema";

export const onUserWrite = on(
  db.table(users).changed(),
  flow("audit.users", {
    do: async ({ table, action, id }, fx) => {
      await fx
        .store(db)
        .insert(auditLogs)
        .values({
          table,
          recordId: String(id),
          action,
        });
    },
  }),
);
```

</Tab>

<Tab value="Images">

Op is inferred from which image is null — the same derivation as `action`:

| Write  | `before`     | `after` | `action`    |
| ------ | ------------ | ------- | ----------- |
| Insert | `null`       | new row | `"created"` |
| Update | previous row | new row | `"updated"` |
| Delete | previous row | `null`  | `"deleted"` |

```typescript
do: async ({ before, after, action }, fx) => {
  if (action === "created") {
    /* insert */
  } else if (action === "updated") {
    /* update */
  } else {
    /* delete */
  }
};
```

</Tab>

</Tabs>

`changed("status")` stamps `trigger.cdc.column` on the Manifest. Still the same
payload — filter in `do` when you only care about that field:

```typescript title="src/flows/tasks/on-status.ts"
import { on, flow } from "okengine";
import { db } from "@/core";
import { tasks } from "@/schema";

export const onStatus = on(
  db.table(tasks).changed("status"),
  flow("tasks.onStatus", {
    plane: "operator",
    do: async ({ before, after, id }, fx) => {
      if (before?.status === after?.status) return;
      await fx.emit(taskStatusChanged, {
        id,
        from: before?.status ?? null,
        to: after?.status ?? null,
      });
    },
  }),
);
```

<Accordions>

<Accordion title="CDC payload">
  `{ before, after }` are always present. `{ table, action, id }` are always populated — `id` is
  the table's declared primary-key value, not a column assumed to be named `"id"`. There is no
  `record` field and no `{ op }` (that stays on the live-query / outbox path).

Writes must go through `fx.store`. A raw SQL client bypasses the sink, so no consumer runs.

</Accordion>

<Accordion title="Outbox">
  On RLS-capable SQL (`postgres` / `pglite`) the same write is appended to `oke_cdc_outbox`
  for multi-host delivery. Pending backlog is a doctor finding (`cdc_outbox_backlog`).

Live **queries** (`store.resource({ live: true })` / `http.get(path).live(table)`) share this
CDC path but classify per subscriber — see [HTTP · Live Streams](/docs/elements/flow/http#live-streams).

</Accordion>

</Accordions>

## Execution

Consumers share the Flow species with HTTP. The differences are the trigger and how failure
is retried.

| Kind               | Start               | Failure                                | Time             |
| ------------------ | ------------------- | -------------------------------------- | ---------------- |
| Signal `once`      | `fx.emit`           | Signal `retries` then DLQ              | `fx.clock.now()` |
| Signal `broadcast` | `fx.emit`           | Per-subscriber; no `once` DLQ          | `fx.clock.now()` |
| Clock              | scheduler tick      | Flow `retry` if set; no catch-up burst | `fx.clock.*`     |
| CDC                | committed SQL write | Flow `retry` if set                    | `fx.clock.now()` |

Mark long work `durable: true` and wrap side effects in `fx.step` — see
[Workflows](/docs/elements/flow/workflows).

Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal drivers: **redis** in
`dev`/`prod`, **memory** in `test`.

## Troubleshooting

<Accordions>

<Accordion title='TypeError: clock("name"): require cron or every'>
  `clock(name)` needs `{cron}` and/or `{every}`. Empty options throw at declaration, before `on()`.
</Accordion>

<Accordion title="TypeError: on() expected a trigger or signal handle">
  The first argument must be a Signal handle, a Clock handle, `db.table(…).changed()`, an HTTP
  trigger, `internal`, or `mcp.tool(…)`. A bare interval string is not a trigger — wrap it in
  `clock.every("name", "1h")`.
</Accordion>

<Accordion title="OKE1070 — flow name defined twice">
  Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings share a name. Give at least one a
  distinct name.
</Accordion>

<Accordion title="OKE1072 — Signal or Clock flow unnamed">
  Cause: `A {kind} flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`.
</Accordion>

<Accordion title="OKE1071 — once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should independently receive this event, or bind only one Flow. See [Once · Competing
  consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
</Accordion>

<Accordion title="OKE1240 — emit with no subscriber">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.` Add `on(signal, flow)` or
  set `{ optional: true }` on the Signal (live firehoses, unused hooks).
</Accordion>

<Accordion title="OKE1250 — emit failed schema">
  Cause: `"{resource}": {detail}`. The payload failed the Signal's Standard Schema at emit. Fix the
  payload; the consumer never ran.
</Accordion>

<Accordion title='changed("insert") never fires on inserts only'>
  `changed()` takes an optional **column** name, not an op. `changed("insert")` waits for a column
  named `insert`. Use `changed()` and branch on `before` / `after` being null.
</Accordion>

<Accordion title="CDC do never sees record / op">
  Input is `{ before, after, table, action, id }`. There is no `record` field. `action` is
  `"created"` / `"updated"` / `"deleted"` from which image is null. `{ op }` is live-query /
  outbox only.
</Accordion>

<Accordion title="Cron fired 24 times after overnight downtime">
  It should not. Catch-up is `"one"` — one fire per overdue clock, then `nextRunAt` advances. If you
  see a burst, you likely bound several clocks (or `clock.perTenant` expanded many tenants), not a
  replay of missed hourly slots.
</Accordion>

<Accordion title="Two pods ran the same job">
  Clock leader election needs a shared Store (`drivers.clock` postgres, or `file` on one host).
  `memory` does not coordinate across processes. Check that both instances share `DATABASE_URL`.
</Accordion>

<Accordion title="ScheduleNotOverridableError from Console">
  The clock was declared without `overridable: true`. Add it and redeploy, or edit the declaration
  in source instead of Console.
</Accordion>

<Accordion title="tz is not a clock option">
  The field is `timezone` (IANA), default `"UTC"`. `{ tz: "Asia/Riyadh" }` is ignored.
</Accordion>

</Accordions>

## Learn more

- [Signal](/docs/elements/signal) — `once` / `broadcast` / `live` physics
- [Signal · Once](/docs/elements/signal/once) — leases, retries, partition keys
- [Clock](/docs/elements/clock) — schedules, `fx.clock.sleep`
- [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export) — one Flow vs shared schedule
- [Store · SQL](/docs/elements/store/sql) — tables CDC watches
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `signal.live` SSE
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.clock`
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250
- [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` on a consumer

## Next

<Cards>
  <Card
    title="Durable Workflows"
    description="Step journaling and multi-step distributed execution."
    href="/docs/elements/flow/workflows"
  />
  <Card
    title="Signal Element"
    description="Delivery physics — once, broadcast, and live tapes."
    href="/docs/elements/signal"
  />
  <Card
    title="Clock Element"
    description="Named schedules, intervals, and durable sleep."
    href="/docs/elements/clock"
  />
  <Card
    title="HTTP"
    description="Synchronous REST, QUERY, resources, and live SSE."
    href="/docs/elements/flow/http"
  />
</Cards>


# HTTP (/docs/elements/flow/http)

HTTP triggers bind web requests directly to Flows. Every standard REST verb is available alongside RFC 10008 QUERY for safe body reads, multi-verb CRUD mounts, and live Server-Sent Events (SSE).

For developers building APIs on okengine — declare the route, attach gates, return typed data.

<Callout title="The one rule">
  An HTTP trigger parses the request, checks attached gates, and invokes `flow({ do })`.
  All business logic runs inside the Flow via `fx`. Declare `in`, `out`, and `errors` on
  the HTTP bag when the route has a body, path params, or domain failures.
</Callout>

## Smallest Example

<Steps>

<Step>
### Define the route

```typescript title="src/flows/main/ping.ts"
import { on, flow, http } from "okengine";

export const ping = on(
  http.get().public(),
  flow({
    do: () => ({ status: "ok" }),
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X GET http://localhost:6530/ping -H "accept: application/json"
```

Response:

```json
{
  "data": { "status": "ok" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Omit path and name">
  Tree default: `http.get()` and `flow({ do })` — no path or name strings. The
  file stamps both. Pass either only for
  [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).
</Callout>

## Progressive Patterns

Explore HTTP flow patterns from minimal handlers to schema-validated, error-handling, and gate-protected endpoints:

<Tabs items={["Minimal", "Validated", "Failures", "Gates"]}>

<Tab value="Minimal">

Return data directly with automatic JSON response enveloping and zero boilerplate:

```typescript title="src/flows/main/health.ts"
import { on, flow, http } from "okengine";

export const health = on(
  http.get().public(),
  flow({
    do: () => ({ ok: true }),
  }),
);
```

</Tab>

<Tab value="Validated">

Extract URL parameters and JSON body with runtime schema validation on the HTTP bag:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      return { id, title };
    },
  }),
);
```

</Tab>

<Tab value="Failures">

Declare typed domain errors and return clean failure responses using `fx.fail`:

```typescript title="src/flows/orders/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
      if (!order) return fx.fail("NotFound", { id });
      return order;
    },
  }),
);
```

</Tab>

<Tab value="Gates">

Chain policies and rate limits on the trigger with `.gate(...)`.
`fx.json.empty()` answers `204 No Content` with no body:

```typescript title="src/flows/account/delete.ts"
import { on, flow, http, gate } from "okengine";
import { eq } from "drizzle-orm";
import { db, users } from "@/schema";

const member = gate.policy("member", ({ auth }) => !!auth.verified);
const admin = gate.scope("admin");
const deleteRate = gate.rate({ max: 5, per: "1m", keyBy: "user" });

export const deleteAccount = on(
  http.delete().gate(member, admin, deleteRate),
  flow({
    do: async (_, fx) => {
      await fx.store(db).delete(users).where(eq(users.id, fx.auth.userId!));
      return fx.json.empty();
    },
  }),
);
```

</Tab>

</Tabs>

## Method Reference

| Method          | Signature                  | Purpose                                 | Body Allowed    | Idempotent     |
| --------------- | -------------------------- | --------------------------------------- | --------------- | -------------- |
| `http.get`      | `http.get(path?)`          | Fetch a resource or list                | No              | Yes            |
| `http.post`     | `http.post(path?)`         | Create resource / command               | Yes             | No             |
| `http.put`      | `http.put(path?)`          | Replace entire resource                 | Yes             | Yes            |
| `http.patch`    | `http.patch(path?)`        | Partial resource update                 | Yes             | No             |
| `http.delete`   | `http.delete(path?)`       | Remove a resource                       | Optional        | Yes            |
| `http.query`    | `http.query(path?)`        | Safe read with JSON body                | Yes (RFC 10008) | Yes            |
| `http.head`     | `http.head(path?)`         | Retrieve response headers               | No              | Yes            |
| `http.options`  | `http.options(path?)`      | Discover allowed methods                | No              | Yes            |
| `http.resource` | `http.resource(path, ops)` | Five CRUD verbs; live when on           | Verb-dependent  | Verb-dependent |
| `http.live`     | `http.live(signal)`        | Firehose SSE on `GET /_oke/live/{name}` | No              | Yes            |

## Path Conventions

**Default — omit the path.** Tree files stamp the URL from disk (`http.get()`).
Pass a path only for [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).

**Control — explicit path** — pass the URL template when the folder should not own the route:

```typescript
http.get("/organizations/:orgId/members/:memberId");
```

**Pathless** — omit so the compiler stamps from disk location:

```typescript title="src/flows/users/[id]/get.ts"
import { on, flow, http } from "okengine";

// Stamped automatically to GET /users/:id · flow users.get
export const get = on(http.get(), flow({ do: async ({ id }) => ({ id }) }));
```

Full file-tree rules: [Routing](/docs/elements/flow/routing).

## Request Parsing

Before `flow({ do })`, the HTTP engine merges request parts into one object checked against
the trigger's `in`:

1. **Path parameters** — `:param` segments (e.g. `{ id: "123" }`).
2. **Query string** — `?sort=desc` keys at the root.
3. **JSON body** — object fields merged into the same root.
4. **Headers & cookies** — bags under `headers` / `cookie` when `do` reads them (declare the same keys in `in`).

```typescript title="src/flows/items/[id]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, items } from "@/schema";

export const update = on(
  http.patch({
    // :id from the path merges with JSON body { title }
    in: z.object({
      id: z.string(),
      title: z.string().min(1),
    }),
  }),
  flow({
    do: async ({ id, title }, fx) => {
      await fx.store(db).update(items).set({ title }).where(eq(items.id, id));
      return { id, title };
    },
  }),
);
```

Read request metadata by naming `headers` / `cookie` in `in` and destructuring them in `do`
(header names are lower-cased):

```typescript title="src/flows/uploads/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({
      name: z.string().min(1),
      headers: z.object({
        "content-type": z.string().optional(),
        "x-request-id": z.string().optional(),
      }),
      cookie: z.object({
        sid: z.string().optional(),
      }),
    }),
  }),
  flow({
    do: async ({ name, headers, cookie }, fx) => {
      return {
        id: fx.id(),
        name,
        contentType: headers["content-type"] ?? "application/octet-stream",
        session: cookie.sid ?? null,
      };
    },
  }),
);
```

## HTTP Methods

Each verb binds with `on(http.<method>(), flow({…}))`. Omit the path on tree
files; pass one only for [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).

<Tabs items={["GET", "POST", "PUT", "PATCH", "DELETE", "QUERY", "HEAD", "OPTIONS"]}>

<Tab value="GET">

Fetch a resource or collection:

```typescript title="src/flows/notes/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), title: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

</Tab>

<Tab value="POST">

Create a resource or run a command. `fx.json.create(value)` returns `201 Created` with
`{ data: value, error: null }`:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      await fx.store(db).insert(notes).values({ id, title });
      return fx.json.create({ id, title });
    },
  }),
);
```

</Tab>

<Tab value="PUT">

Replace an entire resource (idempotent full write):

```typescript title="src/flows/notes/[id]/replace.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const replace = on(
  http.put({
    in: z.object({
      id: z.string(),
      title: z.string().min(1),
      body: z.string(),
    }),
    out: z.object({ id: z.string(), title: z.string(), body: z.string() }),
  }),
  flow({
    do: async ({ id, title, body }, fx) => {
      await fx.store(db).update(notes).set({ title, body }).where(eq(notes.id, id));
      return { id, title, body };
    },
  }),
);
```

</Tab>

<Tab value="PATCH">

Apply a partial update (only declared fields change):

```typescript title="src/flows/notes/[id]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const update = on(
  http.patch({
    in: z.object({
      id: z.string(),
      title: z.string().min(1).optional(),
    }),
    out: z.object({ id: z.string(), title: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id, title }, fx) => {
      if (title !== undefined) {
        await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
      }
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

</Tab>

<Tab value="DELETE">

Remove a resource. `fx.json.empty()` returns `204 No Content` with no body:

```typescript title="src/flows/notes/[id]/remove.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const remove = on(
  http.delete({ in: z.object({ id: z.string() }) }),
  flow({
    do: async ({ id }, fx) => {
      await fx.store(db).delete(notes).where(eq(notes.id, id));
      return fx.json.empty();
    },
  }),
);
```

</Tab>

<Tab value="QUERY">

Safe, idempotent read with a JSON body (RFC 10008) — filters that would overflow a URL:

```typescript title="src/flows/orders/search.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db } from "@/schema";

export const search = on(
  http.query({
    in: z.object({
      filters: z.array(z.string()),
      dateRange: z.object({ from: z.string(), to: z.string() }),
    }),
    out: z.array(z.object({ id: z.string(), total: z.number() })),
  }),
  flow({
    do: async ({ filters, dateRange }, fx) => {
      return await fx.store(db).queryOrders(filters, dateRange);
    },
  }),
);
```

Clients must send `Content-Type: application/json`.

Some browsers, HTTP libraries, and reverse proxies still reject or strip bodies on methods other
than `POST`/`PUT`/`PATCH`. Prefer modern clients, or fall back to `POST` for the same search
contract when you must support older stacks.

</Tab>

<Tab value="HEAD">

Probe existence / headers without returning a body. `head` is not a reserved
leaf, so pass the path when the URL must match GET (`/notes/:id`):

```typescript title="src/flows/notes/[id]/head.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const head = on(
  http.head("/notes/:id", {
    in: z.object({ id: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx
        .store(db)
        .select({ id: notes.id })
        .from(notes)
        .where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return;
    },
  }),
);
```

</Tab>

<Tab value="OPTIONS">

Advertise allowed verbs. Same idea — pass the collection path explicitly when
the leaf name would otherwise add a segment:

```typescript title="src/flows/notes/options.ts"
import { on, flow, http } from "okengine";

export const options = on(
  http.options("/notes"),
  flow({
    do: () => ({
      allow: ["GET", "POST", "PUT", "PATCH", "DELETE", "QUERY", "HEAD", "OPTIONS"],
    }),
  }),
);
```

</Tab>

</Tabs>

## Resources

<Callout title="Detailed section">
  If you only need the basic mount, jump to the example below. `on(http.resource(path, ops))` takes
  **no Flow as a second argument** — `.all()` is the bag; options live on `store.resource`. A second
  argument throws `on(http.resource(...)) takes no second argument`.
</Callout>

`http.resource(path, ops)` mounts five CRUD Flows in one `on()` call. Pass
`store.resource(…).all()` or any bag with `list` · `create` · `get` · `update` · `remove`.

Chain `.gate(...)` / `.public()` once — every verb (and live, when present) gets the same gates.

<Tabs items={["Define", "Mount"]}>

<Tab value="Define">

`store.resource` builds the five Flows. The factory registers no routes.

```typescript title="src/flows/notes/resource.ts"
import { store } from "okengine";
import { z } from "zod";
import { db, notesTable } from "@/schema";

export const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
});
```

</Tab>

<Tab value="Mount">

`.gate(member)` stamps every verb. The client sees `api.notes.list` / `.create` / `.get` /
`.update` / `.remove` after `oke({ name: "app" }).adopt({ notes })`.

```typescript title="src/flows/notes/index.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { notesResource } from "./resource";

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

</Tab>

</Tabs>

The URL id segment is always `:id`. Update is **PATCH**, not PUT. There is no
pathless `http.resource()` — pass an explicit base path.

| Op       | Method   | Path          | Typical status                     |
| -------- | -------- | ------------- | ---------------------------------- |
| `list`   | `GET`    | `/notes`      | `200` + `{ data, error, meta }`    |
| `create` | `POST`   | `/notes`      | `201 Created` (`fx.json.create`)   |
| `get`    | `GET`    | `/notes/:id`  | `200`, or `NotFound`               |
| `update` | `PATCH`  | `/notes/:id`  | `200`, or `NotFound`               |
| `remove` | `DELETE` | `/notes/:id`  | `204 No Content` (`fx.json.empty`) |
| `live`   | `GET`    | `/notes/live` | SSE — only when live is on         |

<Accordions>

<Accordion title="Resource Options">
  Third argument to `store.resource(db, table, options)`:

| Option     | Type      | Default                          | Meaning                                                                  |
| ---------- | --------- | -------------------------------- | ------------------------------------------------------------------------ |
| `in`       | Schema    | _(required)_                     | Create body (`POST`)                                                     |
| `out`      | Schema    | _(required)_                     | Item shape (get / list / update return)                                  |
| `update`   | Schema    | `in`                             | Patch fields. Wire body is `{ id, ...patch }`                            |
| `idSchema` | Schema    | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key)              |
| `errors`   | error map | `{ NotFound }`                   | Typed failures on get / update / remove                                  |
| `id`       | column    | table PK                         | Column bound to `:id`                                                    |
| `list`     | object    | see List Options                 | List query grammar (`GET /notes`)                                        |
| `breaking` | `boolean` | `false`                          | Marks the five Flows `breaking: true` (handwritten → resource migration) |
| `live`     | `boolean` | omitted                          | Live query surface; see Resource Live                                    |

</Accordion>

<Accordion title="List Options">
  Nested on `store.resource(…, { list: { … } })`. Search / filter / order / select
  use a column scope: `"all"` · column array · `"none"`.

| Option      | Type                     | Default                                          | Meaning                        |
| ----------- | ------------------------ | ------------------------------------------------ | ------------------------------ |
| `mode`      | `"cursor"` \| `"offset"` | `"cursor"` when `cursor` is set, else `"offset"` | Pagination                     |
| `cursor`    | columns                  | `[]`                                             | Keyset columns                 |
| `direction` | `"asc"` \| `"desc"`      | `"desc"`                                         | Default sort when no `?order=` |
| `limit`     | `number`                 | `20`                                             | Default page size              |
| `maxLimit`  | `number`                 | `100`                                            | Cap on `?limit=`               |
| `count`     | `"exact"` \| `"none"`    | `"exact"`                                        | Offset-only `COUNT(*)`         |
| `search`    | column scope             | `"none"`                                         | `?search=` / `?q=`             |
| `filter`    | column scope             | `"none"`                                         | `?col=eq.x` grammar            |
| `order`     | column scope             | cursor columns, else `"all"`                     | `?order=`                      |
| `select`    | column scope             | `"all"`                                          | `?select=` projection          |

</Accordion>

<Accordion title="Resource Members">

| Member                                          | Kind                | Meaning                                                                                   |
| ----------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `all()`                                         | method, no args     | Bag for `http.resource(path, notesResource.all())` — five Flows, plus `live` when enabled |
| `list` · `create` · `get` · `update` · `remove` | Flow                | One verb. Bind with `http.get` / `http.post` / `http.patch` / `http.delete`               |
| `page(input)`                                   | method              | Compile list-query input for a handwritten `fx.store(db).page`                            |
| `live`                                          | `{ signal, flow }?` | Live surface when `live: true` (or the project default drained on)                        |

</Accordion>

<Accordion title="Resource Live">
  A sixth route appears only when the resource is live. It is **not** a signal
  firehose — each subscriber gets classified row events (RLS + list filters).

| `live` on the resource           | Result                                                                                   |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| `{ live: true }`                 | Mount `GET <path>/live` now                                                              |
| omitted                          | Mount only if `oke({ store: { live: true } })`                                           |
| `{ live: false }`                | Never mount live for this resource                                                       |
| table `store.schema.live(false)` | Opts that table out of the project default. `{ live: true }` on the resource still wins. |

```typescript title="src/flows/notes/resource.ts"
const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
  live: true,
});

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

**Consequence:** `GET /notes/live` rides the same `.gate(...)` chain as list/get.

Query-string filters use the resource list grammar; pagination cursors do **not**
gate membership — a row enters or leaves the window when filters / RLS change.

Wire events (consumed with `useLiveQuery` on the [typed client](/docs/client/react)):

| `kind`    | Meaning                                                     |
| --------- | ----------------------------------------------------------- |
| `upsert`  | Row visible under stamp + query — merge by primary key      |
| `revoked` | Row left visibility (`reason: "rls"` or `"query"`) — remove |
| `delete`  | Row deleted — remove                                        |

Live queries need an RLS-capable SQL driver (`postgres` / `pglite`) and a gated
identity on the request. Extract fails without a primary key:

```text
extract: live: true on table "notes" requires a primary key column (upsert/revoked/delete address rows by PK)
```

Missing `updatedAt` / `updated_at`, or no RLS policies, warn at extract — they
do not fail the build.

</Accordion>

<Accordion title="Subset & Override">
  `http.resource` always mounts all five CRUD keys. To expose only some verbs,
  bind those Flows on individual triggers. To replace one verb, spread `.all()`
  and override that key — the other four stay:

```typescript title="src/flows/notes/list.ts"
import { on, http, store } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { db, notesTable } from "@/schema";

const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
});

export const list = on(http.get().gate(member), notesResource.list);
```

```typescript title="src/flows/notes/[id]/get.ts"
export const get = on(http.get().gate(member), notesResource.get);
```

Override one verb on a resource mount — path is required on `http.resource`:

```typescript title="src/flows/notes/index.ts"
import { on, flow, http } from "okengine";
import { eq } from "drizzle-orm";

export const notes = on(
  http
    .resource("/notes", {
      ...notesResource.all(),
      remove: flow({
        do: async ({ id }, fx) => {
          await fx
            .store(db)
            .update(notesTable)
            .set({ archived: true })
            .where(eq(notesTable.id, id));
          return fx.json.empty();
        },
      }),
    })
    .gate(member),
);
```

A handwritten bag works the same way — each value must be a `flow(…)`:

```typescript
on(
  http.resource("/notes", {
    list: flow("notes.list", { do: () => [] }),
    create: flow("notes.create", { do: () => ({ id: "n1" }) }),
    get: flow("notes.get", { do: () => ({ id: "n1" }) }),
    update: flow("notes.update", { do: () => ({ id: "n1" }) }),
    remove: flow("notes.remove", { do: (_, fx) => fx.json.empty() }),
  }),
);
```

Missing or non-Flow keys throw `on(http.resource(...)) expects the five CRUD FlowDefs`.
A `GET /notes` you also declared by hand collides at boot (**OKE1041**).

</Accordion>

</Accordions>

## Live Streams

<Callout title="Detailed section">
  If you only need the basic firehose, jump to the example below. `.live(…)` is GET-only —
  `on(http.post("/x").live(signal))` throws `on(http.*.live(signal)): live exposure must be GET`.
</Callout>

`http.live(signal)` is one-arg `on()` — the engine synthesizes the stream Flow
(`fx.live` + `effects.reads: ["signal:<name>"]`). Chain `.gate(...)` like any GET.

```typescript title="src/flows/orders/firehose.ts"
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});

export const firehose = on(http.live(orderStatus).gate(member));
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

Response `Content-Type` is `text/event-stream`. Frames are JSON `data:` lines
(optional `id:` for resume), then `data: [DONE]`.

<Accordions>

<Accordion title="Exposure Shapes">
  Three GET shapes expose a live SSE body. Pick the physics first, then the path.

| Declaration                                        | Path                    | Physics                                        |
| -------------------------------------------------- | ----------------------- | ---------------------------------------------- |
| `on(http.live(signal))`                            | `GET /_oke/live/{name}` | Signal tape — every event                      |
| `on(http.get(path).live(signal))`                  | Your path               | Signal tape — auto-match on `:params`          |
| `on(http.get(path).live(table), flow)`             | Your path               | Live **query** — `liveQuery(fx, table, input)` |
| `store.resource({ live: true })` + `http.resource` | `GET <path>/live`       | Same live-query physics as `.live(table)`      |

Signal names in the default firehose path are `encodeURIComponent`'d
(`chat.message` stays readable; slashes in internal names are escaped).

</Accordion>

<Accordion title="Filtered Paths">
  Path params become a filter: an event is forwarded when each `:param` that
  **exists on the payload** equals the request value. Params missing from the
  payload are skipped (the event still flows). No params = firehose.

```typescript title="src/flows/orders/events.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
```

`GET /orders/ord_1/events` receives `{ orderId: "ord_1", status: "shipped" }`
and drops events for other orders.

</Accordion>

<Accordion title="Custom Match">
  Pass your own Flow as the second argument to `on()` when auto-match is not
  enough. Return `fx.live(signal, { match })` from `do` — do not wrap it with
  `fx.json.stream`.

```typescript title="src/flows/orders/vip-feed.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const vipFeed = on(
  http.get("/orders/vip/events").gate(member).live(orderStatus),
  flow("orders.vipFeed", {
    do: (_input, fx) =>
      fx.live(orderStatus, {
        match: (payload) => payload.status === "shipped",
      }),
  }),
);
```

**Consequence:** a custom Flow stamps a distinct match key, so it can coexist
with the auto-match route for the same signal (different path). Two synthesized
firehoses that share signal **and** gates fail uniqueness — see Uniqueness.

</Accordion>

<Accordion title="Live Queries">
  For a handwritten list that should stream the same classified CDC as
  `store.resource({ live: true })`, bind the table on GET and open the window
  with `liveQuery`:

```typescript title="src/flows/tasks/live.ts"
import { on, flow, http, liveQuery } from "okengine";
import { member } from "@/core/gate";
import { tasks } from "@/schema";

export const tasksLive = on(
  http.get("/tasks/live").gate(member).live(tasks),
  flow("tasks.live", {
    do: async (input, fx) =>
      liveQuery(fx, tasks, input, {
        filter: [tasks.status],
        search: [tasks.title],
        order: "all",
      }),
  }),
);
```

Same driver, identity, and extract guardrails as Resource Live. Prefer
`http.resource` + `{ live: true }` when you already mount the five CRUD ops.

</Accordion>

<Accordion title="Uniqueness">
  Boot keys each live HTTP route as `(signal, gates, match)`. Match is the
  sorted path-param names, or `custom:<flow>` when you passed a Flow, or
  `(firehose)` when there are no params.

| Pair                                    | Boots?                                    |
| --------------------------------------- | ----------------------------------------- |
| Member `:orderId` + admin firehose      | Yes — gates and match differ              |
| Same params, different gates            | Yes — the client disambiguates with `via` |
| Two member firehoses on different paths | No — **OKE1050**                          |
| Same method + path twice                | No — **OKE1041** first                    |

**OKE1050** cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).`
Fix: a different gate, a path-param filter, or drop the extra route.

</Accordion>

<Accordion title="Client Subscription">
  `signal.live` is HTTP SSE. `for await` stays on the server; the browser
  uses a callback.

The client picks the unique exposure whose `matchKey` fields are a subset of
the input, preferring the largest match (`{ orderId }` beats firehose). A tie
needs `via: "unit.flow"`.

```typescript
const stop = api.live(
  orderStatus,
  { orderId: "ord_1" },
  {
    onEvent: (event) => {
      /* { orderId, status } */
    },
    onError: (err) => {
      /* 4xx, envelope, or drop */
    },
    autoResubscribe: false,
  },
);
stop();
```

`api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing
Flow. Reconnects send `Last-Event-ID` from the last `id:` received.

A **410** `LiveResumeGap` (**OKE1210**) means that cursor is gone — drop it
and replay the remaining tape (`autoResubscribe: true`).

Resource live queries use `useLiveQuery` (snapshot + classified events), not
`api.live`. See [Client · Live](/docs/client/live).

</Accordion>

</Accordions>

## Trigger Modifiers

Every HTTP trigger supports fluent modifier chaining before binding to `on()`.
Resource mounts accept `.gate(...)` and `.public()` only — live on a resource
comes from `store.resource({ live: true })`, not `.live()`.

**Gates** — attach policy and rate handles. They evaluate in declaration order; first denial wins:

```typescript
import { gate } from "okengine";
import { member } from "@/core/gate";

http.post().gate(member, gate.scope("editor"), gate.rate({ max: 100, per: "1m", keyBy: "user" }));
```

**Public** — explicitly marks the endpoint as open without authentication:

```typescript
http.get().public();
```

## Response Envelopes

Every HTTP flow returns the same envelope shape. You choose status and optional `meta` — not a
custom wrapper.

<Callout title="Envelope is fixed">
  Success and failure always use `{ data, error }` (optional top-level `meta`). There is no API to
  replace that shape. Use `fx.json.*` for status codes and `meta`; use `fx.fail` for typed errors.
</Callout>

**Success** — returning a value from `do` produces `200 OK`:

```json
{ "data": { "id": "123" }, "error": null }
```

Returning `undefined` produces a `204 No Content` response with an empty body.

**Custom status** — `fx.json.create` for `201 Created`, or `fx.json.ok` with optional `meta`:

```typescript
return fx.json.create({ id: "ord_1" });
// or
return fx.json.ok({ id: "ord_1" }, { meta: { traceId: fx.runId } });
```

**Typed failures** — `fx.fail(code, data)` formats the error envelope and maps status:

```typescript
return fx.fail("NotFound", { id: "123" });
```

```json
{
  "data": null,
  "error": {
    "code": "NotFound",
    "message": "Resource not found",
    "data": { "id": "123" }
  }
}
```

Standard status code mappings:

- `ValidationError` → `422 Unprocessable Entity`
- `Unauthorized` → `401 Unauthorized`
- `Forbidden` → `403 Forbidden`
- `RateLimited` → `429 Too Many Requests`
- Custom error codes → `400 Bad Request`

## Troubleshooting

<Accordions>

<Accordion title="404 Not Found — route missing">
  No Flow is bound to that method + path. Check the explicit path, or for pathless routes the
  file-tree stamp (`src/flows/users/[id]/get.ts` → `GET /users/:id`). A bare `404` with body `Not
  Found` means the router found no match.
</Accordion>

<Accordion title="405 Method Not Allowed on valid route">
  The path exists but has not been bound to the requested HTTP verb. The response contains an
  `Allow` header listing valid methods for that path.
</Accordion>

<Accordion title="415 Unsupported Media Type on QUERY">
  RFC 10008 requires `Content-Type: application/json` for `http.query` requests. Ensure your client
  sends this header with a valid JSON payload.
</Accordion>

<Accordion title="422 ValidationError on request">
  The merged input payload failed validation against the trigger's `in` schema. Check the
  `error.data.issues` array for the specific field validation failure.
</Accordion>

<Accordion title="Browser blocked by CORS / missing Access-Control-*">
  Cross-origin access is closed until you plug the [`cors`](/docs/plugins/cors) plugin with an
  explicit `origin`. Same-origin calls need no CORS headers. Preflight `OPTIONS` is answered by the
  plugin even when the path is bound to other methods.
</Accordion>

<Accordion title="TypeError: on(http.resource(...)) takes no second argument">
  The ops bag already holds the five Flows. Call `on(http.resource("/notes",
  notesResource.all()).gate(member))` — do not pass a `flow(...)` as the second argument.
</Accordion>

<Accordion title="TypeError: on(http.resource(...)) expects the five CRUD FlowDefs">
  The bag must include `list`, `create`, `get`, `update`, and `remove`, each a `flow(...)`. To
  expose fewer verbs, bind those Flows on `http.get` / `http.post` yourself instead of
  `http.resource`.
</Accordion>

<Accordion title="OKE1041 — method + path bound twice">
  Cause: `{method} {path} is bound twice (flow "{flow}").` A resource mount plus a handwritten
  `http.get("/notes")` (or two mounts on the same base path) collide. Drop one binding.
</Accordion>

<Accordion title="TypeError: live exposure must be GET">
  Live SSE feeds declared via `.live(signal)` can only be attached to `GET` triggers
  (`http.get(...)` or `http.live(...)`). Other verbs reject live stream synthesis.
</Accordion>

<Accordion title="OKE1050 — live signal exposed twice">
  Cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}
  ).` Two firehoses (`http.live` or param-less `.live`) that share the signal and gates cannot boot.
  Change the gate, add a path-param filter, or remove a route.
</Accordion>

<Accordion title="OKE1210 — 410 LiveResumeGap">
  Cause: `Cursor "{afterId}" missing on "{signal}".` That `Last-Event-ID` is gone from the tape.
  Reconnect without it; remaining events replay. `autoResubscribe: true` does this after backoff.
</Accordion>

<Accordion title="live query requires a primary key / RLS driver">
  Extract: `live: true on table "…" requires a primary key column`. Runtime: `live query for "…"
  requires an RLS-capable SQL driver (postgres / pglite)` or `requires a gated identity`. Attach
  `.gate(...)` and declare a PK.
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — `store.resource`, list query grammar, SQL facet
- [Signal · Live](/docs/elements/signal/live) — `signal.live` tapes
- [Client](/docs/client/live) — `api.live`, `useLive`, `useLiveQuery`
- [fx](/docs/reference/fx) — `fx.live`, `fx.json.stream`, `fx.json.create`
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on triggers
- [Errors](/docs/reference/errors) — OKE1041 · OKE1050 · OKE1210

## Next

<Cards>
  <Card
    title="Gate Element"
    description="Configure authentication, authorization, and rate limiting."
    href="/docs/elements/gate"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="Durable Workflows"
    description="Step journaling and multi-step distributed execution."
    href="/docs/elements/flow/workflows"
  />
</Cards>


# Overview (/docs/elements/flow)

Flow is the element for **behavior**. An HTTP endpoint, a Signal worker, a named Clock tick, a SQL change handler, and a multi-step checkout are the same shape: `on(trigger, flow)`. Only the trigger changes.

For developers writing backend work on okengine — put the invoke contract on the exposure
(`http.*` / `call` / `mcp.tool`), keep `do` on `fx`.

<Callout title="The one rule">
  All world access goes through `fx`. A direct `fetch`, `Date.now()`, or `node:` import inside `do`
  is a defect. Effects are inferred from what the Flow touches through `fx` — that inference powers
  the Manifest, Console, cache, and durability.
</Callout>

<FlowShape />

## Smallest Example

<Steps>

<Step>
### Define a Flow

```typescript title="src/flows/main/health.ts"
import { on, flow, http } from "okengine";

export const health = on(
  http.get().public(),
  flow({
    do: () => ({ ok: true }),
  }),
);
```

</Step>

<Step>
### Call it

```bash
curl -X GET http://localhost:6530/health -H "accept: application/json"
```

Response:

```json
{
  "data": { "ok": true },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Omit path and name">
  Tree default: `http.get()` + `flow({ do })` — no path or name strings. Pass
  either only for
  [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).
</Callout>

<Callout title="Call-only Flows">
  Use `call("payments.charge", { in, out, do, … })` for internal callees — same species as
  `flow`, with the invoke contract on the bag. Nothing outside your code can start it unless you
  also bind a trigger. Other Flows invoke it with `fx.call(flowRef, input)`.
</Callout>
## Progressive Patterns

Same `on` + `flow` + `do` from a ping to a typed failure to a private callee:

<Tabs items={["Minimal", "Validated", "Failures", "Call-only"]}>

<Tab value="Minimal">

Return a value. HTTP wraps it as `{ data, error: null }`:

```typescript title="src/flows/main/ping.ts"
import { on, flow, http } from "okengine";

export const ping = on(
  http.get().public(),
  flow({
    do: () => ({ status: "ok" }),
  }),
);
```

</Tab>

<Tab value="Validated">

`in` / `out` live on the HTTP bag. Invalid input never enters `do`:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      return { id, title };
    },
  }),
);
```

</Tab>

<Tab value="Failures">

Declare domain errors on the exposure bag and return `fx.fail` — do not throw for expected failures:

```typescript title="src/flows/orders/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, orders } from "@/schema";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
      if (!order) return fx.fail("NotFound", { id });
      return order;
    },
  }),
);
```

</Tab>

<Tab value="Call-only">

Use `call(name, { … })` — contract and `do` on one bag. The parent records `calls: ["payments.charge"]`.
`fx.call` waits for the callee:

```typescript title="src/flows/payments/charge.ts"
import { call } from "okengine";
import { z } from "zod";
import { db, charges } from "@/schema";

export const chargeCard = call("payments.charge", {
  in: z.object({ amount: z.number() }),
  out: z.object({ chargeId: z.string() }),
  do: async ({ amount }, fx) => {
    const chargeId = fx.id();
    await fx.store(db).insert(charges).values({ id: chargeId, amount });
    return { chargeId };
  },
});
```

```typescript
const { chargeId } = await fx.call(chargeCard, { amount: 50 });
```

</Tab>

</Tabs>

## Trigger Reference

`flow`, `do`, and `fx` never change. Bind a different trigger:


> Six triggers — HTTP, signal, interval, row change, fx.call, and an MCP tool — all binding to the same Flow species.


| Trigger   | Bind                                           | Starts when         | `do` input                             |
| --------- | ---------------------------------------------- | ------------------- | -------------------------------------- |
| HTTP      | `on(http.get(), flow)`                         | A request           | Merged path / query / body             |
| Signal    | `on(signalHandle, flow)`                       | `fx.emit`           | Payload (`schema`)                     |
| Clock     | `on(clockDecl, flow)`                          | Scheduler tick      | none (`_`)                             |
| CDC       | `on(db.table(t).changed(), flow)`              | Committed SQL write | `{ before, after, table, action, id }` |
| Call-only | `call("name", { in, out, do, … })`             | `fx.call`           | Callee `in`                            |
| MCP       | `on(mcp.tool("x", { in, out }).gate(…), flow)` | MCP `tools/call`    | Tool args                              |

`signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams), not as a worker.

## The Capabilities of Flow

<Cards>
  <Card
    title="HTTP"
    description="REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE."
    href="/docs/elements/flow/http"
  />
  <Card
    title="Routing"
    description="File-tree stamps for HTTP paths, Flow names, and client units."
    href="/docs/elements/flow/routing"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="Durable Workflows"
    description="fx.step replay, LIFO undo, durable sleep, and crash resume."
    href="/docs/elements/flow/workflows"
  />
</Cards>

## Options Reference

Second argument to `flow(name, options)` — or the only argument to a nameless `flow({ do })`.
Invoke contracts (`in` / `out` / `errors` / `breaking`) belong on the exposure — see
[Contracts](#contracts) below.

| Option         | Type                                   | Default                   | Meaning                                                                         |
| -------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- |
| `do`           | `(input, fx) => output \| FlowFailure` | _(required)_              | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
| `durable`      | `boolean`                              | `false`                   | Journal `fx.step` / sleep / gated `fx` calls                                    |
| `retry`        | `FxRetryOptions`                       | omitted                   | Whole-`do` retry on throw (same journal when durable)                           |
| `cache`        | `boolean \| string`                    | omitted (auto)            | Read-only Flows cache automatically; `false` opts out; `"30s"` adds TTL         |
| `compensate`   | `(ctx, fx) => unknown`                 | omitted                   | After LIFO `{ undo }`, before the run commits `failed`                          |
| `plane`        | `"user" \| "operator"`                 | `"user"`                  | Operator bypasses RLS; user must not `fx.call` operator                         |
| `effects`      | `Effects`                              | inferred                  | Capability token — write this only when inference cannot see the body           |
| `slo`          | `{ availability?, latency? }`          | omitted                   | Manifest metadata (Console / docs)                                              |
| `tenantScoped` | `boolean`                              | `true` when tenancy is on | `false` skips tenant-role scope union                                           |

**Consequence:** `durable: true` disables automatic read-cache for that Flow.

## Contracts

<Callout title="Detailed section">
  Invoke contracts live on the **exposure** — `http.post({ in, out, errors })`, `call("name", {
  in, out, do })`, or `mcp.tool("x", { in, out })`. The Manifest still shows flat
  `flows.*.{in,out,errors,breaking}` as a projection from that exposure. `in` runs before `do`;
  `out` runs after a successful return. `fx.fail` skips `out`. Signal / Channel `schema` is a
  separate **emit** contract (validated at `fx.emit` / `fx.send`).
</Callout>

<Tabs items={["Standard Schema", "Failures", "Envelope"]}>

<Tab value="Standard Schema">

Any library with `~standard` (Standard Schema V1) works. Zod is the usual choice:

```typescript
import { on, flow, http } from "okengine";
import { z } from "zod";

on(
  http.post({
    in: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
    out: z.object({ id: z.string() }),
  }),
  flow({
    do: async (input, fx) => ({ id: fx.id() }),
  }),
);
```

Valibot (`v.object`) and ArkType (`type({…})`) bind the same way. Shared DTOs belong in
`shapes.ts` next to the unit — that filename is never a route.

</Tab>

<Tab value="Failures">

Errors at the Flow boundary are **values**. Throw only for bugs. Declare `errors` on the
exposure and return from `do`:

```typescript
call("orders.create", {
  in: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
  out: z.object({ id: z.string() }),
  errors: {
    OutOfStock: z.object({ available: z.number() }),
  },
  do: async (input, fx) => {
    const [row] = await fx.store(db).select().from(stock).where(eq(stock.sku, input.sku));
    if (!row || row.available < input.qty) {
      return fx.fail("OutOfStock", { available: row?.available ?? 0 });
    }
    return { id: fx.id() };
  },
});
```

`fx.fail(code, data, { message? })` builds `{ data: null, error: { code, data, message? } }`.
The typed client narrows on `res.error.code`.

</Tab>

<Tab value="Envelope">

HTTP success from a returned value is `200` + `{ data, error: null }`. `undefined` is
`204` with an empty body. Typed failures use `{ data: null, error }`:

```json
{
  "data": null,
  "error": {
    "code": "OutOfStock",
    "data": { "available": 0 }
  }
}
```

Status for `error.code`:

| Code                                                  | Status |
| ----------------------------------------------------- | ------ |
| `ValidationError`                                     | `422`  |
| `Unauthorized`                                        | `401`  |
| `Forbidden`                                           | `403`  |
| `RateLimited`                                         | `429`  |
| Any other declared code (`NotFound`, `OutOfStock`, …) | `400`  |

A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("NotFound")`.

</Tab>

</Tabs>

<Accordions>

<Accordion title="store.resource schemas">
  `store.resource(db, table, { in, out })` requires `in` (create body) and `out` (item
  shape). List / get / update / remove Flows are built for you — contracts are stamped
  from the resource factory. Handwritten invoke contracts go on `http.*` / `call` /
  `mcp.tool` — see [HTTP · Resources](/docs/elements/flow/http#resources).
</Accordion>

<Accordion title="ValidationError payload">
  Failed `in` (or `out`) is `ValidationError` with `error.data.issues` — each issue has `message`
  and `path`. HTTP status is **422**. The handler never ran.
</Accordion>

<Accordion title="Name stamping">
  Prefer nameless `flow({ do })` on HTTP tree files — `src/flows/notes/[id]/get.ts` +
  `export const get` stamps `notes.get`. Signal / Clock workers pass `flow("name", { do })`
  (**OKE1072**; Clock inline is [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export)).
</Accordion>

</Accordions>

## The fx door

<Callout title="Detailed section">
  If you only need store / emit, jump to the table. `fx` is the only I/O surface inside `do`. The
  compiler records what you touch as `effects` on the Manifest.
</Callout>

| Call                       | Records                    | Use                                           |
| -------------------------- | -------------------------- | --------------------------------------------- |
| `fx.store(db)`             | `reads` / `writes` `sql:…` | SQL (and other Store facets)                  |
| `fx.emit(signal, payload)` | `emits`                    | Signal outbox                                 |
| `fx.send(template, opts)`  | `sends`                    | Channel template                              |
| `fx.ask(prompt, opts)`     | `asks`                     | AI prompt                                     |
| `fx.vault.get(secret)`     | `secrets`                  | Declared secret (never a raw value in source) |
| `fx.call(flow, input?)`    | `calls`                    | Another Flow — waits for return               |
| `fx.id()`                  | —                          | OKID — 21-char native id from `okengine/okid` |
| `fx.clock.now()`           | —                          | Deterministic time                            |
| `fx.fail(code, data)`      | —                          | Typed failure value                           |
| `fx.step(name, fn)`        | journal                    | Durable checkpoint                            |

<Accordions>

<Accordion title="Side channels">
  `Date.now()`, `new Date()`, `setTimeout`, global `fetch`, and `node:fs` skip the ledger.
  Tests cannot time-travel; durable replay cannot skip the work; cache cannot see the read.

**Fix:** `fx.clock.now()`, `fx.store`, `fx.send`, or wrap a provider in `fx.step`.

</Accordion>

<Accordion title="fx.call identity">
  `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed). `fx.tenant.id` propagates.
  For audit only, read `fx.principal` — gates never consult it. See [fx](/docs/reference/fx).
</Accordion>

<Accordion title="Undeclared effects (OKE1001–1007)">
  Explicit `effects` that drift from the body throw at runtime (`Flow "{flow}" writes "{resource}"
  without declaring it.`). Most apps never write `effects` — inference covers them. **OKE1020** is
  deploy-shaped boot with neither inference nor a block.
</Accordion>

</Accordions>

## Call-only

<Callout title="Detailed section">
  Prefer `call("name", { in, out, do, … })`. `internal` exists so call-only is a trigger
  *value* — `on(internal, flow)` — when you need all kinds addressable the same way.
</Callout>

```typescript title="src/flows/orders/checkout.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { chargeCard } from "@/flows/payments/charge";

export const checkout = on(
  http.post({ in: z.object({ sku: z.string() }) }),
  flow({
    do: async ({ sku }, fx) => {
      const { chargeId } = await fx.call(chargeCard, { amount: 50 });
      return { sku, chargeId };
    },
  }),
);
```

Do **not** `fx.call` a Flow that parks on `fx.clock.sleep` — the caller receives
`undefined` and continues; the child wakes later as its own run. Sleep on the root
durable Flow, or split with `fx.emit`. See [Workflows](/docs/elements/flow/workflows).

## Cache, retry, and plane

<Tabs items={["Cache", "Retry", "Plane"]}>

<Tab value="Cache">

Read-only Flows (Store `reads`, no `writes`, no `asks`, not durable) cache automatically.
No `cache:` option required. Mutations and `durable: true` stay uncached:

```typescript
flow("catalog.get", {
  cache: "30s",
  do: async ({ id }, fx) => {
    const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
    return row;
  },
});
```

`cache: false` opts out. A duration string adds TTL on top of write invalidation.

</Tab>

<Tab value="Retry">

`flow({ retry })` re-enters the whole `do`. `retries` is extra attempts after the first.
Prefer `fx.retry` **inside** `fx.step` so a completed charge never re-runs:

```typescript
flow({
  retry: { retries: 3, delay: "200ms", backoff: 2 },
  do: async (input, fx) => {
    return await fx.ask(flakyModel, { prompt: input.text });
  },
});
```

| `retry` option | Default       | Meaning                            |
| -------------- | ------------- | ---------------------------------- |
| `retries`      | `0`           | Extra attempts after the first     |
| `delay`        | `50` (ms)     | Initial wait (`"200ms"` allowed)   |
| `backoff`      | `2`           | Multiplier after each retry        |
| `jitter`       | `true`        | Full jitter                        |
| `when`         | thrown errors | Skips abort and durable-sleep park |

</Tab>

<Tab value="Plane">

`"user"` is the application default. `"operator"` is Console — RLS is bypassed,
`fx.operator` is the principal, `fx.auth` must not appear in that body:

```typescript
flow("ops.allOrders", {
  plane: "operator",
  do: async (_, fx) => {
    return await fx.store(db).select().from(orders);
  },
});
```

A user-plane Flow that `fx.call`s an operator Flow fails compile:
`cross-plane call: user flow "…" calls operator flow "…"`.

</Tab>

</Tabs>

<Accordions>

<Accordion title="compensate context">
  Durable-only. Runs after reverse `{ undo }`, never on success, retry attempts, or sleep
  park. Context: `{ input, error, completedSteps }` — forward step names only. Full
  physics: [Workflows · Compensation](/docs/elements/flow/workflows#compensation).
</Accordion>

<Accordion title="breaking and tenantScoped">
  `breaking: true` on the exposure bag lets `oke doctor --diff` accept that Flow's contract
  break. It does not cover a different Flow.

`tenantScoped: false` skips tenant-role scope union even when `fx.tenant.id` is set.
Default is `true` once `gate.auth.tenant` is on.

</Accordion>

<Accordion title="Hooks and plugins">
  `flowDef.hook(stage, fn)` registers a per-Flow hook (`onRequest` · `onParse` · `onAuth` ·
  `beforeHandle` · `afterHandle` · `onError` · `onResponse`). `flowDef.plug(plugin)` scopes a plugin
  to that Flow — see [Plugins](/docs/reference/plugins).
</Accordion>

</Accordions>

## Troubleshooting

<Accordions>

<Accordion title='TypeError: flow() expected an options bag with a do handler'>
  `flow()` requires `{ do }`. `flow("name")` with no options, or a bag without `do`,
  throws at declaration — before `on()`.
</Accordion>

<Accordion title="TypeError: on() expected a trigger or signal handle">
  First argument must be an HTTP trigger, Signal handle, Clock handle, `db.table(…).changed()`,
  `internal`, or `mcp.tool(…)`. A bare interval string is not a trigger — wrap it in
  `clock.every("name", "1h")`.
</Accordion>

<Accordion title="TypeError: on() expected a flow() definition as the second argument">
  Second argument must be the object `flow()` returned. Passing a plain function or
  forgetting `flow({ do })` fails here. Resource mounts take **no** second argument —
  see [HTTP · Resources](/docs/elements/flow/http#resources).
</Accordion>

<Accordion title="Direct Date.now, fetch, or node: import inside do">
  Side channels skip effect tracking and durable replay.

**Fix:** `fx.clock.now()`, `fx.store`, `fx.send`, or `fx.step` around the provider.

</Accordion>

<Accordion title="Thrown Error becomes a mystery 500 instead of a typed envelope">
  Uncaught exceptions are defects. Declare the code in `errors` on the exposure and `return
  fx.fail("OutOfStock", payload)` from `do`.
</Accordion>

<Accordion title="422 ValidationError — path param missing from in">
  `http.get("/users/:id")` merges `{id}`. A schema that expects `userId` fails before `do`. Align
  path keys with `in` object keys. See [HTTP · Request
  Parsing](/docs/elements/flow/http#request-parsing).
</Accordion>

<Accordion title="fx.fail('NotFound') is 400, not 404">
  Custom domain codes map to **400**. A bare `404` `Not Found` means the router found no method +
  path. Use `fx.fail` for domain misses; fix the route for missing bindings.
</Accordion>

<Accordion title="Read-only Flow never cache-hits">
  Auto-cache needs Store `reads`, no `writes`, no `asks`, and `durable` off. Empty effect sets stay
  uncached. Opt in with a duration (`cache: "30s"`) only after a real read is inferred — or pass
  `cache: false` to disable.
</Accordion>

<Accordion title='cross-plane call: user flow "…" calls operator flow "…"'>
  User-plane Flows cannot `fx.call` operator Flows. Keep operator work on `plane: "operator"` and
  invoke it from Console, or split a user-safe callee.
</Accordion>

<Accordion title="OKE1020 — no declared effects">
  Cause: `Flow "{flow}" has no declared effects and no Manifest to derive them from.` Extract
  failures append `Manifest extract failed — …`. Boot with `oke dev` / `oke build`; install
  `oxc-parser` if extract cannot load. On Windows use `bun run dev` / `bunx oke dev`.
</Accordion>

</Accordions>

## Learn more

- [The Architecture](/docs/understand/the-architecture) — `on`, trigger, `flow`, `do`, `fx`
- [HTTP](/docs/elements/flow/http) — verbs, envelopes, resources, live SSE
- [Routing](/docs/elements/flow/routing) — file-tree stamps, barrels, OKE1030 · OKE1040–1045
- [Consumers](/docs/elements/flow/consumers) — Signal / Clock / CDC
- [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step`
- [fx](/docs/reference/fx) — every method inside `do`
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the trigger
- [Errors](/docs/reference/errors) — OKE1001–1009 · OKE1020 · ValidationError · denials
- [MCP](/docs/elements/ai/mcp) — `mcp.tool` exposure

## Next

<Cards>
  <Card
    title="HTTP"
    description="Synchronous REST, QUERY, resources, and live SSE."
    href="/docs/elements/flow/http"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="Durable Workflows"
    description="Step journaling and multi-step distributed execution."
    href="/docs/elements/flow/workflows"
  />
  <Card
    title="The Architecture"
    description="Five pieces behind on(trigger, flow)."
    href="/docs/understand/the-architecture"
  />
</Cards>


# Routing (/docs/elements/flow/routing)

HTTP routes are inferred from where a Flow file lives. Put `http.get()` in
`src/flows/notes/[id]/get.ts` and the compiler stamps `GET /notes/:id`, names the
Flow `notes.get`, and the client calls `api.notes.get({ id })`.

<Callout title="The one rule">
  On a tree file, omit path and name: `on(http.get(), flow({ do }))`. The file
  tree stamps both. Pass either only for control — barrels, a URL that must not
  follow the folder, or a stable name for `fx.call`. Explicit always wins.
</Callout>

## When to omit · when to pass

|               | Omit (default)                                                       | Pass explicitly                                                                                                  |
| ------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **HTTP path** | Tree file under `src/flows/<unit>/` — `http.get()`, `http.post()`, … | Barrel `index.ts`; a public URL that must not match the folder; `http.resource(path, ops)`; custom live SSE path |
| **Flow name** | Same tree file — `flow({ do })` stamps `unit.export`                 | Barrel (optional — export still stamps); stable name across moves; call-only Flow you `fx.call` by name          |

**Consequence:** most app code looks like the create-oke template —
`http.get().public()` + `flow({ … })` — with no string path and no string name.

## Smallest Example

<Steps>

<Step>
### Place the file in the tree

`oke dev` / `oke build` regenerate `src/flows/generated.ts`. Import it before
`oke()` so pathless triggers receive their stamp:

```typescript title="src/app.ts"
import "@/flows/generated";
import { oke } from "okengine";

export const app = oke({ name: "notes" });
```

</Step>

<Step>
### Use pathless `http.get()`

The tree stamps `GET /notes/:id`. `:id` merges into `in`. The export name is the
client method (`api.notes.get`):

```typescript title="src/flows/notes/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }) => ({ id }),
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X GET http://localhost:6530/notes/n_1 -H "accept: application/json"
```

Response:

```json
{
  "data": { "id": "n_1" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Method is not the filename">
  `list.ts` does not become `GET` by itself. Reserved leaves only omit a URL segment. Bind
  `http.get()`, `http.post()`, `http.patch()`, or `http.delete()` yourself — see [Reserved
  Leaves](#reserved-leaves).
</Callout>

## Progressive Patterns

From a pathless tree file to an explicit barrel, an action leaf, and a catch-all:

<Tabs items={["Tree", "Barrel", "Action", "Catch-all"]}>

<Tab value="Tree">

One file per route. Skip the path argument. The generated barrel calls
`stampHttpPath` / `stampFlowName` after import:

```typescript title="src/flows/notes/list.ts"
import { on, flow, http } from "okengine";

export const list = on(
  http.get(),
  flow({
    do: () => [],
  }),
);
```

Stamped to `GET /notes`, Flow `notes.list`, client `api.notes.list()`.

</Tab>

<Tab value="Barrel">

A unit that is **only** `index.ts` (plus skip-list files) is a barrel. The
generated file re-exports it **without** stamping — pass explicit paths:

```typescript title="src/flows/notes/index.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const list = on(
  http.get("/notes").public(),
  flow("notes.list", {
    do: () => [],
  }),
);

export const create = on(
  http.post("/notes", { in: z.object({ title: z.string().min(1) }) }),
  flow("notes.create", {
    do: async ({ title }, fx) => ({ id: fx.id(), title }),
  }),
);
```

Pathless `http.get()` inside a barrel stays unresolved and fails boot
(**OKE1040**).

</Tab>

<Tab value="Action">

A leaf that is not reserved **adds** a segment. `archive.ts` is
`POST /notes/:id/archive`, not `POST /notes/:id`:

```typescript title="src/flows/notes/[id]/archive.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const archive = on(
  http.post({ in: z.object({ id: z.string() }) }),
  flow({
    do: async ({ id }) => ({ id, archived: true }),
  }),
);
```

</Tab>

<Tab value="Catch-all">

`[...slug]` becomes `*` on the URL. The request param is always `"*"`, never
`slug`:

```typescript title="src/flows/docs/[...slug]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({ in: z.object({ "*": z.string() }) }).public(),
  flow({
    do: async (input) => ({ path: input["*"] }),
  }),
);
```

```bash
curl -X GET http://localhost:6530/docs/getting-started/install \
  -H "accept: application/json"
```

`do` receives `{ "*": "getting-started/install" }`. Call
`api.docs.get({ "*": "a/b/c" })` — not `{ slug }`.

</Tab>

</Tabs>

## Convention Reference

| Convention    | File                                    | Stamped route             | Client                         |
| ------------- | --------------------------------------- | ------------------------- | ------------------------------ |
| Dynamic param | `notes/[id]/get.ts` + `http.get()`      | `GET /notes/:id`          | `api.notes.get({ id })`        |
| Reserved leaf | `notes/list.ts` + `http.get()`          | `GET /notes`              | `api.notes.list()`             |
| Action leaf   | `notes/[id]/archive.ts` + `http.post()` | `POST /notes/:id/archive` | `api.notes.archive({ id })`    |
| Catch-all     | `docs/[...slug]/get.ts` + `http.get()`  | `GET /docs/*`             | `api.docs.get({ "*": "a/b" })` |
| Route group   | `notes/(ops)/archive.ts`                | `/notes/archive`          | `api.notes.archive`            |
| Root unit     | `main/health.ts` + `http.get()`         | `GET /health`             | `api.main.health()`            |
| Folder root   | `main/route.ts` + `http.get()`          | `GET /`                   | `api.main.root()`              |

## Units

The first folder under `src/flows/` is the **client unit**. Nested folders and
the leaf file build the URL. The `export const` name is the method on that unit.

```text
src/flows/
├── notes/                     # Unit: notes → api.notes.*
│   ├── list.ts                # GET /notes          (reserved leaf)
│   ├── create.ts              # POST /notes         (reserved leaf)
│   ├── shapes.ts              # skipped (not a route)
│   └── [id]/
│       ├── get.ts             # GET /notes/:id
│       └── archive.ts         # POST /notes/:id/archive
├── billing/
│   └── (checkout)/            # omitted from the URL
│       └── charge.ts          # POST /billing/charge  (if http.post())
└── main/                      # Unit: main — prefix omitted from the URL
    ├── health.ts              # GET /health
    └── route.ts               # GET /
```

A file sitting directly in `src/flows/` (no unit folder) is not a route.

Unit folder names must be valid JS identifiers (`notes`, `main`, `_` allowed
inside; `my-notes` is skipped). Folders starting with `_`, `[`, or `(` are not
units.

**Consequence:** `export const getNote` from `get.ts` is `api.notes.getNote`, not
`api.notes.get`. Match the export to the name you want on the client.

## Path Conventions

**Default — pathless.** Omit the path so `generated.ts` stamps the URL from disk:

```typescript
http.get(); // pending until stampHttpPath runs
```

**Control — explicit path.** Pass the URL template when the folder should not
own the route (barrel, public API shape, resource mount). The tree never
overwrites an explicit path:

```typescript
http.get("/organizations/:orgId/members/:memberId");
```

Path params, query string, and JSON body still merge into one object checked by
`in` — same order as [HTTP · Request Parsing](/docs/elements/flow/http#request-parsing).

### Dynamic parameters

`[id]` → `:id`. The folder name is the param key. Declare the same key on `in`:

```typescript title="src/flows/orgs/[orgId]/members/[memberId]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({ in: z.object({ orgId: z.string(), memberId: z.string() }) }),
  flow({
    do: async ({ orgId, memberId }) => ({ orgId, memberId }),
  }),
);
```

Stamped to `GET /orgs/:orgId/members/:memberId`.

### Catch-alls

`[...slug]` → `*` (greedy rest). Optional `[[...slug]]` is **not** supported —
generate fails with `Optional catch-all "[[...slug]]" is not supported — use [...slug] (param is "*").`

A route that includes `*` selects the Trie matcher automatically (the compiled
RegExp matcher cannot express wildcards).

### Route groups

A folder `(ops)` is omitted from the URL. Use it to group files without adding a
segment:

```text
src/flows/notes/(ops)/archive.ts  →  /notes/archive
```

`(ops)` never enters the Flow name either (`notes.archive`).

### The `main` unit

`main` is omitted from the URL prefix. Flow names still use `main.`:

| File             | URL           | Flow name                              |
| ---------------- | ------------- | -------------------------------------- |
| `main/health.ts` | `/health`     | `main.health`                          |
| `main/route.ts`  | `/`           | `main.root` (from `export const root`) |
| `main/index.ts`  | `/` (extract) | barrel — pass `http.get("/")`          |

### Skip list

These files are never routes (walk skips them; path inference returns nothing):

| Pattern                     | Why                                              |
| --------------------------- | ------------------------------------------------ |
| `generated.ts`              | Adopt barrel (`oke dev` / `oke build` writes it) |
| `shapes.ts`                 | Shared Zod / contracts                           |
| `signals.ts`                | Signal declarations                              |
| `*.test.ts` / `*.test.tsx`  | Tests                                            |
| `_` prefix (file or folder) | Private helpers (`notes/_lib/util.ts`)           |

Skip-list files may sit next to tree routes. They do **not** turn a tree into a
barrel.

## Reserved Leaves

To avoid `/notes/get` and `/orders/list`, these filenames add **no** URL
segment — the same five CRUD names as `http.resource`, plus folder roots:

| Leaf     | Typical trigger | Example file                        | Stamped path    |
| -------- | --------------- | ----------------------------------- | --------------- |
| `list`   | `http.get()`    | `notes/list.ts`                     | `/notes`        |
| `create` | `http.post()`   | `notes/create.ts`                   | `/notes`        |
| `get`    | `http.get()`    | `notes/[id]/get.ts`                 | `/notes/:id`    |
| `update` | `http.patch()`  | `notes/[id]/update.ts`              | `/notes/:id`    |
| `remove` | `http.delete()` | `notes/[id]/remove.ts`              | `/notes/:id`    |
| `index`  | _(barrel only)_ | `notes/index.ts`                    | `/notes`        |
| `route`  | any             | `notes/route.ts` or `main/route.ts` | `/notes` or `/` |

Any other leaf **is** a segment: `query.ts` → `/notes/query`.

In a tree unit, do **not** add `index.ts` beside other route files — that is a
generate error. Use `route.ts` (or `list.ts` / `create.ts`) for the collection
root.

## Barrel vs Tree

`oke dev` / `oke build` scans each `src/flows/<unit>/` folder and writes
`generated.ts`. Two shapes, never mixed:

<Tabs items={["Tree", "Barrel", "App entry"]}>

<Tab value="Tree">

`[param]` folders, `(group)` folders, or extra route files. The barrel imports
each file and stamps path + name:

```typescript
const notes = {
  get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
  list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
};
export { notes };
registerFlowUnits({ notes });
```

`oke()` drains `registerFlowUnits` into `$routes`. `.adopt({ notes })` is
optional and additive.

</Tab>

<Tab value="Barrel">

Only `index.ts` (+ skip-list). Re-export, no stamp:

```typescript
import * as notes from "./notes/index.ts";
export { notes };
registerFlowUnits({ notes });
```

Declare `http.get("/notes")` and `flow("notes.list", {…})` (or rely on adopt to
stamp the name from the export). Pathless HTTP fails **OKE1040**.

</Tab>

<Tab value="App entry">

```typescript title="src/app.ts"
import "@/flows/generated";
import { oke } from "okengine";

export const app = oke({ name: "notes" });
export type App = typeof app;
```

Do not edit `generated.ts` by hand. Adding a unit folder without regenerating
leaves a stale barrel — **OKE1030** in prod / `oke dev` with Compose.

</Tab>

</Tabs>

<Accordions>

<Accordion title="Mixed barrel + tree">
  `index.ts` plus `[id]/get.ts` (or any other route file) throws at generate:

```text
Unit "notes" mixes a barrel index.ts with tree route files. Use only index.ts (barrel), or move the collection path to route.ts and keep [id]/ beside it.
```

Fix: delete `index.ts` and use `list.ts` / `route.ts`, or fold every route into
`index.ts` with explicit paths.

</Accordion>

<Accordion title="Export collisions">
  Two files in the same unit cannot share an `export const` name:

```text
Unit "notes" exports "get" from both list.ts and route.ts.
```

Rename one export. The client method is the export name, not the filename.

</Accordion>

<Accordion title="Unit-prefix drift">
  `flow("tasks.get", {…})` living under `src/flows/notes/` throws:

```text
flow("tasks.…") in notes/get.ts does not match the folder "notes".
```

Use `flow("notes.get", {…})`, a nameless `flow({ do })` (stamped `notes.get`
from the export), or move the file.

</Accordion>

</Accordions>

## Names

Three names, one file:

| Surface   | Source                                                              | Example                 |
| --------- | ------------------------------------------------------------------- | ----------------------- |
| HTTP path | File tree (default) or explicit `http.get("/x")`                    | `/notes/:id`            |
| Flow name | `unit.export` from `flow({ do })` (default), or `flow("notes.get")` | `notes.get`             |
| Client    | Unit folder + `export const`                                        | `api.notes.get({ id })` |

Nameless `flow({ do })` is the tree default — same rule as pathless HTTP. Pass
`flow("notes.get")` only for control (stable name, barrel, or matching unit
prefix). Wrong-unit prefixes fail generate.

Non-HTTP files still join the unit. A signal consumer in `notes/on-created.ts`
is `api.notes.onCreated` over RPC (`POST /_oke/notes/onCreated`), not HTTP.

Signal / Clock consumers pass an explicit `flow("…")` name (**OKE1072** if nameless
outside `src/flows/<unit>/`; **OKE1070** on collision). Clock may write `clock.every(…)`
inside `on()` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).

## Runtime Matching

All adopted HTTP bindings go into one matcher. Wrong method on a known path is
**405** with `Allow`. No match is a bare **404** `Not Found`.

| `oke({ router })` | Default | What it does                                                                                                |
| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `"default"`       | yes     | Compiled RegExp (O(1) static map + per-bucket regex for `:id`). Falls back to Trie when a path includes `*` |
| `"edge"`          |         | Linear scan, then Trie. No RegExp compile — cold-start / isolates                                           |

p99 match stays under **1 ms** on the compiled matcher. You do not pick buckets
by hand — a catch-all in the table selects Trie for the whole app.

Duplicate `METHOD + path` fails boot (**OKE1041**), including a resource mount
plus a handwritten `http.get("/notes")`.

## Troubleshooting

<Accordions>

<Accordion title="404 Not Found — route missing">
  No Flow is bound to that method + path. Check the explicit path, or for pathless routes the
  file-tree stamp (`notes/[id]/get.ts` → `GET /notes/:id`). A bare `404` with body `Not Found` means
  the router found no match.
</Accordion>

<Accordion title="405 Method Not Allowed on valid route">
  The path exists but has not been bound to the requested verb. `Allow` lists the methods that are.
  `list.ts` with `http.get()` is GET-only — POST that URL is 405, not a missing file.
</Accordion>

<Accordion title="OKE1040 — pathless trigger never stamped">
  Cause: `Flow "{flow}" bound {method} with no path — the file-tree stamp never ran.` Import
  `@/flows/generated`, run `oke dev` / `oke build`, or pass `http.get("/…")`. Barrels do not stamp —
  they need the explicit path.
</Accordion>

<Accordion title="OKE1030 — adopt barrel stale">
  Cause: `src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale.` Run
  `oke dev` or `oke build` after adding a unit folder. Prod and Compose `oke dev` refuse to boot
  this way.
</Accordion>

<Accordion title="OKE1041 — method + path bound twice">
  Cause: `{method} {path} is bound twice (flow "{flow}").` Two tree files stamped the same verb +
  path (`list.ts` and `route.ts` both `http.get()`), or a resource mount plus a handwritten route.
  Drop one binding.
</Accordion>

<Accordion title="OKE1045 — HTTP flow unnamed">
  Cause: `An HTTP flow on {method} {path} has no name.`
  Use `flow("unit.export", {…})` or `export const` from a `src/flows/<unit>/`
  file so the tree can stamp `unit.export`. `export default` is not picked up.
</Accordion>

<Accordion title="OKE1070 — flow name defined twice">
  Cause: `Flow "{flow}" is defined twice.` Two explicit `flow("…")` calls collide, or two tree
  exports stamp the same `unit.export`. Give at least one a distinct name or tree export.
</Accordion>

<Accordion title="OKE1072 — Signal or Clock flow unnamed">
  Cause: `A {kind} flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`.
</Accordion>

<Accordion title="422 — path param missing from in">
  `[id]` stamps `:id`. `in` must declare `id` (same key). A schema that expects `userId` while the
  path is `:id` fails validation before `do`.
</Accordion>

<Accordion title="Catch-all input is empty / wrong key">
  `[...slug]` does not bind `slug`. The router param is `"*"`. Declare
  `in: z.object({ "*": z.string() })` and read `input["*"]`. Optional
  `[[...slug]]` is rejected at generate.
</Accordion>

<Accordion title="Unit mixes index.ts with tree files">
  Generate: `Unit "…" mixes a barrel index.ts with tree route files.` Use only `index.ts` (explicit
  paths), or move the collection path to `route.ts` and keep `[id]/` beside it.
</Accordion>

</Accordions>

## Learn more

- [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
- [Client](/docs/client/calling) — `api.notes.get`, REST vs RPC, `$routes`
- [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
- [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger

## Next

<Cards>
  <Card
    title="HTTP"
    description="REST verbs, RFC 10008 QUERY, CRUD mounts, and live SSE on Flow."
    href="/docs/elements/flow/http"
  />
  <Card
    title="Client"
    description="Typed caller — createClient, envelopes, REST from $routes."
    href="/docs/client/calling"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
</Cards>


# Workflows (/docs/elements/flow/workflows)

A durable Flow is an ordinary Flow with `durable: true`. Named `fx.step` calls journal
their results so a crash resumes without re-running completed work — checkout, onboarding,
anything that must not double-charge.

For developers writing multi-step work on okengine — set `durable: true`, wrap side
effects in `fx.step`, keep time on `fx.clock`.

<Callout title="The one rule">
  Wrap every side effect in a uniquely named `fx.step`. Replay returns the journaled value and never
  re-runs the body. Register `{undo}` on steps that must reverse; sleep only with
  `fx.clock.sleep(label, duration)` on a durable Flow.
</Callout>


> Durable journal physics: with durable true, killing the process after create-intent resumes at confirm and create-intent never re-runs; without a journal, a restart re-runs create-intent.


## Smallest Example

<Steps>

<Step>
### Define a durable Flow

```typescript title="src/flows/orders/checkout.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, charges, orders } from "@/schema";

export const checkout = on(
  http.post({
    in: z.object({ userId: z.string(), sku: z.string() }),
    out: z.object({ orderId: z.string() }),
  }),
  flow({
    durable: true,
    do: async ({ userId, sku }, fx) => {
      const charge = await fx.step("charge", async () => {
        const id = fx.id();
        await fx.store(db).insert(charges).values({ id, userId, amount: 50 });
        return { id };
      });

      const orderId = await fx.step("create-order", async () => {
        const id = fx.id();
        await fx.store(db).insert(orders).values({
          id,
          userId,
          sku,
          chargeId: charge.id,
        });
        return id;
      });

      return { orderId };
    },
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/orders/checkout \
  -H "content-type: application/json" \
  -d '{"userId":"usr_1","sku":"sku_42"}'
```

Response:

```json
{
  "data": { "orderId": "ord_1" },
  "error": null
}
```

Kill the process after `charge` persists and before `create-order` finishes.
Boot again — `charge` replays from the journal; the card is not charged twice.

</Step>

</Steps>

<Callout title="Not a separate species">
  There is no workflow engine API. `durable: true` is a Flow option — HTTP, Signal, Clock, CDC, and
  call-only Flows all journal the same way. See [Consumers](/docs/elements/flow/consumers).
</Callout>

## Progressive Patterns

Explore durable Flows from a two-step journal to undo, sleep, and flow-level compensation:

<Tabs items={["Minimal", "Undo", "Sleep", "Compensate"]}>

<Tab value="Minimal">

Two named steps. `fx.id()` lives **inside** the step so resume reuses the journaled id:

```typescript title="src/flows/billing/charge.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, payments } from "@/schema";

export const charge = on(
  http.post({
    in: z.object({ userId: z.string(), amount: z.number() }),
    out: z.object({ paymentId: z.string() }),
  }),
  flow({
    durable: true,
    do: async ({ userId, amount }, fx) => {
      const paymentId = await fx.step("create-intent", async () => {
        const id = fx.id();
        await fx.store(db).insert(payments).values({ id, userId, amount });
        return id;
      });
      await fx.step("confirm", async () => {
        await fx.call(capturePayment, { paymentId });
      });
      return { paymentId };
    },
  }),
);
```

</Tab>

<Tab value="Undo">

`{ undo }` receives the journaled return value. On terminal failure, completed
undos run last-in first-out; the failed step does **not** undo:

```typescript title="src/flows/orders/checkout.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, charges } from "@/schema";

export const checkout = on(
  http.post({ in: z.object({ userId: z.string() }) }),
  flow({
    durable: true,
    do: async ({ userId }, fx) => {
      const charge = await fx.step(
        "charge",
        async () => {
          const id = fx.id();
          await fx.store(db).insert(charges).values({ id, userId, amount: 50 });
          return { id };
        },
        {
          undo: async (res) => {
            await fx.store(db).delete(charges).where(eq(charges.id, res.id));
          },
        },
      );
      await fx.step("fulfill", async () => {
        await fx.call(fulfillOrder, { chargeId: charge.id });
      });
      return { chargeId: charge.id };
    },
  }),
);
```

</Tab>

<Tab value="Sleep">

`fx.clock.sleep(label, duration)` parks the run and releases the worker. HTTP
returns **`204 No Content`** immediately — the caller is not waiting at wake:

```typescript title="src/flows/trials/reminder.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { trialExpiringEmail } from "@/channels/trial";

export const reminder = on(
  http.post({ in: z.object({ email: z.string().email() }) }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("mark-trial", async () => {
        await fx.call(startTrial, { email });
      });
      await fx.clock.sleep("expiry-window", "3d");
      await fx.step("notify", async () => {
        await fx.send(trialExpiringEmail, { to: email });
      });
    },
  }),
);
```

Without `durable: true`, `sleep` resolves immediately and does not park.

</Tab>

<Tab value="Compensate">

Per-step `{ undo }` runs first (LIFO), then optional `compensate` for
cross-cutting cleanup. Manual undo work uses distinct `undo:…` step names:

```typescript title="src/flows/orders/checkout.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const checkout = on(
  http.post({ in: z.object({ userId: z.string(), sku: z.string() }) }),
  flow({
    durable: true,
    do: async ({ userId, sku }, fx) => {
      await fx.step("reserve", () => fx.call(reserveStock, { sku }), {
        undo: () => fx.call(releaseStock, { sku }),
      });
      await fx.step("charge", () => fx.call(chargeCard, { userId }));
      return { ok: true as const };
    },
    compensate: async (ctx, fx) => {
      await fx.step("undo:alert", async () => {
        await fx.send(opsAlert, {
          to: "oncall@example.com",
          data: {
            steps: ctx.completedSteps.join(", "),
            error: String(ctx.error),
          },
        });
      });
    },
  }),
);
```

`compensate` does **not** run on success, between `retry` attempts, or on sleep park.

</Tab>

</Tabs>

## Options Reference

| Option / call                     | Type                   | Default   | Meaning                                                   |
| --------------------------------- | ---------------------- | --------- | --------------------------------------------------------- |
| `durable`                         | `boolean`              | `false`   | Journal `fx.step`, `fx.clock.sleep`, and gated `fx` calls |
| `compensate`                      | `(ctx, fx) => unknown` | omitted   | After LIFO undos, before the run commits `failed`         |
| `retry`                           | `FxRetryOptions`       | omitted   | Whole-`do` retry on the **same** journal session          |
| `fx.step(name, fn, opts?)`        | step                   | —         | Named checkpoint; `{ undo }` is optional                  |
| `fx.clock.sleep(label, duration)` | park                   | —         | Durable pause; two arguments (label then duration)        |
| `fx.retry(fn, opts?)`             | inner retry            | see Retry | Put **inside** a step so a completed charge never re-runs |

`compensate` context: `{ input, error, completedSteps }` — `completedSteps` are
forward names only (`undo:…` entries are excluded).

**Consequence:** `durable: true` disables automatic read-cache for that Flow.

## Steps

<Callout title="Detailed section">
  If you only need a named checkpoint, jump to Replay below. Step names must be unique per run. The
  prefix `undo:` is reserved for compensation — a forward step with that prefix throws `journal:
  step name "…" uses reserved prefix "undo:"`.
</Callout>

Every `fx.step` persists `{ name, value }` before the next line runs. On resume the
engine matches by name, returns the stored value, and skips the function.

<Tabs items={["Replay", "At-least-once", "Inner retry"]}>

<Tab value="Replay">

Completed steps are skipped. Generate ids and call providers **inside** the step:

```typescript
const intent = await fx.step("create-intent", async () => {
  const id = fx.id();
  await fx.call(createPaymentIntent, { id, amount: input.total });
  return { id };
});
```

`fx.id()` outside a step mints a new id on every resume. Gated `fx` calls
(`store`, `emit`, `send`, `ask`, `call`, `vault`) are also journaled in call
order — named steps are the stable checkpoint when the sequence might branch.

</Tab>

<Tab value="At-least-once">

A crash **during** a step (before persist) re-runs that function. Completed
neighbors never re-run. Make the in-flight body safe to repeat, or persist at
the provider first and journal only the id (the create-intent pattern).

**Consequence:** two replicas will not double-run a **journaled** step; they
may double-run the step that was in flight when the holder died.

</Tab>

<Tab value="Inner retry">

`fx.retry` inside `fx.step` retries the provider without committing a step
until success. Flow-level `retry` re-enters `do` on the same journal — completed
steps still replay:

```typescript
const charge = await fx.step("charge", () =>
  fx.retry(() => fx.call(stripeCharge, { amount: input.total }), {
    retries: 3,
    delay: "100ms",
    backoff: 2,
    jitter: true,
  }),
);
```

Do **not** put `fx.retry` around the whole `do` by hand — use `flow({ retry })`.

</Tab>

</Tabs>

<Accordions>

<Accordion title="Step Options">
  Third argument to `fx.step(name, fn, options)`.

| Option | Type                 | Default | Meaning                                                  |
| ------ | -------------------- | ------- | -------------------------------------------------------- |
| `undo` | `(value) => unknown` | omitted | Runs on terminal failure with the journaled value (LIFO) |

`undo` closures are re-bound on resume by re-entering `do` without new forward
work. Nested `{ undo }` on an `undo:…` step throws
`journal: undo steps cannot register nested undo`.

</Accordion>

<Accordion title="Duplicate names">
  A second forward step with the same name throws
  `journal: duplicate step name "charge"`. Pick a new name (`charge-tax`) or
  fold the work into the first step.

Sleep matches by **label** from the cursor — use a distinct label per pause.

</Accordion>

<Accordion title="Manifest steps">
  The compiler records `fx.step("…")` string names on the Flow as `steps`.
  Removing a name is a Manifest contract change (`oke doctor --diff`). Adding
  a name is recorded the same way. Names only — bodies are not in the Manifest.

</Accordion>

<Accordion title="fx.using is not journaled">
  `fx.using(acquire, release, use)` is same-attempt cleanup. Do not hold a
  connection or file handle across `fx.clock.sleep` — acquire again after wake.

</Accordion>

</Accordions>

## Compensation

<Callout title="Detailed section">
  If you only need per-step refunds, jump to the table below. Compensation runs on throw **and**
  `fx.fail` — after retries are exhausted, never on sleep park.
</Callout>

When a durable run fails terminally, status becomes `compensating`, then `failed`.

| Step                        | State                  | Action when `fulfill` throws    |
| --------------------------- | ---------------------- | ------------------------------- |
| 1. `charge` with `{ undo }` | Succeeded              | `undo(journaledValue)` — refund |
| 2. `fulfill`                | Failed (not persisted) | No undo for this step           |
| 3. later work               | Not started            | Never executed                  |

Order: reverse `{ undo }` frames, then `flow.compensate`, then commit `failed`.
If an undo or `compensate` throws, the journal error is `compensate:{code}`.

<Accordions>

<Accordion title="compensate context">

| Field            | Meaning                                 |
| ---------------- | --------------------------------------- |
| `input`          | Original validated Flow input           |
| `error`          | Thrown value or `fx.fail` result        |
| `completedSteps` | Forward step names (no `undo:` entries) |

Use `compensate` for alerts and cross-cutting cleanup. Prefer `{ undo }` for
the reverse of one step. Manual bodies must call `fx.step("undo:…", …)` — never
reuse a forward name.

</Accordion>

<Accordion title="Orphan mid-undo">
  A crash during compensation resumes in `compensating`. Already-journaled
  `undo:charge` is skipped; remaining undos continue — forward `do` does not
  re-enter. Failed/completed runs refuse resume (`journal: run is already failed`).

</Accordion>

<Accordion title="Retry vs undo">
  `flow({ retry })` does **not** undo between attempts. Undos run once, after
  the last extra attempt still throws or `fx.fail`s.

</Accordion>

</Accordions>

## Durable Sleep

<Callout title="Detailed section">
  If you only need a pause, jump to the example below. Signature is `fx.clock.sleep(label,
  duration)` — a single duration string is the **label**, not the wait. Durations: `"200ms"` ·
  `"30s"` · `"2m"` · `"1h"` · `"7d"`.
</Callout>

Sleep writes a wake time, sets status `sleeping`, and **releases the run lease**
so a parked flow does not hold a 30s lock for days. Any instance may claim the
row when `wakeAt` is due.

```typescript title="src/flows/onboarding/welcome.ts"
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";

export const sendWelcome = on(
  userSignedUp,
  flow("onboarding.welcome", {
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("provision", async () => {
        await fx.call(createWorkspace, { email });
      });
      await fx.clock.sleep("morning-window", "8h");
      await fx.step("notify", async () => {
        await fx.send(welcomeEmail, { to: email });
      });
    },
  }),
);
```

HTTP + sleep: the request returns `204` with an empty body. Resume is a
scheduler job, not a second response to that client.

<Accordions>

<Accordion title="Duration strings">
  Integer + unit only — no weeks. `"d"` is 86_400_000 ms, not a calendar day.
  Unknown strings parse as `0` (wake immediately). Same grammar as
  `fx.clock.ago` / `fromNow`.

</Accordion>

<Accordion title="Do not fx.call a sleeper">
  `fx.call` waits for the callee to return. If the callee parks, the caller
  receives `undefined` and continues; the child wakes later as its own run.
  Sleep on the **root** durable Flow, or split with `fx.emit` to a consumer.

</Accordion>

<Accordion title="Non-durable sleep">
  Without a journal, `fx.clock.sleep` resolves immediately (tests and sync
  Flows). There is no thread sleep and no `setTimeout`.

</Accordion>

</Accordions>

## Retry

Two layers — do not mix them up.

| Layer                       | Where             | Journal                          | Undo                         |
| --------------------------- | ----------------- | -------------------------------- | ---------------------------- |
| `fx.retry` inside `fx.step` | One provider call | Step commits once, after success | No                           |
| `flow({ retry })`           | Whole `do`        | Same session; rewind + replay    | After the last attempt fails |

| `retry` option | Default                | Meaning                                       |
| -------------- | ---------------------- | --------------------------------------------- |
| `retries`      | `0`                    | Extra attempts after the first                |
| `delay`        | `50` (ms) or `"100ms"` | Initial backoff                               |
| `backoff`      | `2`                    | Multiplier after each retry                   |
| `jitter`       | `true`                 | Full jitter (thundering-herd)                 |
| `when`         | thrown errors          | Skips abort and sleep park (`JournalSuspend`) |

**Consequence:** put provider retries inside the step; use Flow `retry` for
transient failures **after** a step (network to your own `fx.call`).

## Journal

<Callout title="Detailed section">
  If you only need defaults, jump to the table. `drivers.journal` is `postgres` in `dev`/`prod` and
  `memory` in `test`. Pin `file` for a single host without Postgres.
</Callout>

The journal is a driver, not an element. Runs are rows: `running` · `sleeping` ·
`compensating` · `completed` · `failed`.

| Driver     | Default env    | Best for                                             |
| ---------- | -------------- | ---------------------------------------------------- |
| `postgres` | `dev`, `prod`  | Shared durable runs across replicas (`DATABASE_URL`) |
| `memory`   | `test`         | Process-local; lost on exit                          |
| `file`     | pin explicitly | One machine — `.oke/journal.json`                    |

Unknown ids throw `oke boot: unknown journal driver "…" (expected memory · file · postgres)`.
Postgres without a URL throws `oke boot: journal driver "postgres" needs DATABASE_URL`.

<Accordions>

<Accordion title="Leases">
  Default lease is **30s** (same as Signal claims). A live holder renews on
  every journal write. Sleep and terminal commit **release** the lease.

Resume that loses the race throws `journal: run "{id}" is leased by another instance`
(`JournalLeaseBusy`). The other holder continues; this instance skips.

</Accordion>

<Accordion title="Orphans & ready">
  Boot resumes `running` / `sleeping` / `compensating` rows with no live lease;
  future sleeps stay scheduled until `wakeAt`. `GET /_/ready` stays
  `503 { ready: false, reason: "orphan_scan" }` until that scan finishes.

</Accordion>

<Accordion title="Statuses">

| Status         | Meaning                               |
| -------------- | ------------------------------------- |
| `running`      | In-flight attempt; lease held         |
| `sleeping`     | Parked; lease released; `wakeAt` set  |
| `compensating` | LIFO undos / `compensate` in progress |
| `completed`    | Terminal success — resume refused     |
| `failed`       | Terminal failure — resume refused     |

</Accordion>

</Accordions>

## Troubleshooting

<Accordions>

<Accordion title='journal: duplicate step name "charge"'>
  Two forward `fx.step("charge", …)` calls in one run. Rename one, or combine the work. Compensation
  uses `undo:charge` automatically — do not declare a second forward step with that name.
</Accordion>

<Accordion title='journal: step name "undo:x" uses reserved prefix "undo:"'>
  `undo:` is for the compensation phase. Forward work needs a plain name (`refund`). Inside
  `compensate`, `fx.step("undo:alert", …)` is the intended form.
</Accordion>

<Accordion title="HTTP 204 with empty body after POST">
  The Flow parked on `fx.clock.sleep` — success, not a missing handler. The original client is done;
  wake continues on a worker. Return a body **before** sleep if the caller must see an id, or emit
  to a Signal consumer for the rest.
</Accordion>

<Accordion title="Sleep returns immediately / work runs twice after wait">
  Missing `durable: true`, or `fx.clock.sleep("8h")` with one argument — `"8h"` is the label,
  duration is missing. Use `fx.clock.sleep("label", "8h")`. Non-durable sleep is a no-op.
</Accordion>

<Accordion title="Card charged twice after a crash">
  The provider call was outside `fx.step`, or the crash was mid-step (at-least-once). Move
  create-intent into a step and make confirm idempotent. `Date.now()` / `fetch` bypass the journal —
  use `fx.clock.now()` and `fx.step`.
</Accordion>

<Accordion title="journal: run is leased by another instance">
  Two instances claimed the same run. This is skip-not-fail: the holder continues. Shared `postgres`
  (or `file` on one host) is required — `memory` does not coordinate across processes.
</Accordion>

<Accordion title="oke boot: journal driver postgres needs DATABASE_URL">
  Dev/prod default is `postgres`. Set `DATABASE_URL`, or pin `drivers.journal.test` / a non-Postgres
  map in `oke.config.ts` for local experiments without SQL.
</Accordion>

<Accordion title="compensate:{code} on the failed run">
  An `{undo}` or `compensate` body threw. Fix the reverse path; the forward error is already
  recorded. Forward `do` will not re-run on that run id.
</Accordion>

<Accordion title="fx.call of a durable sleeper returned undefined">
  The callee parked. Sleep on the root Flow, or emit to a durable consumer instead of calling a
  sleeper inline.
</Accordion>

<Accordion title="GET /_/ready is 503 reason orphan_scan">
  Boot is resuming durable orphans. Wait — do not point a liveness probe at `/_/ready`. Use a
  separate liveness check; readiness may stay 503 until the orphan scan finishes.
</Accordion>

</Accordions>

## Learn more

- [Flow](/docs/elements/flow) — `durable`, `retry`, `compensate` on the Flow options table
- [Consumers](/docs/elements/flow/consumers) — Signal / Clock / CDC as the same species
- [HTTP](/docs/elements/flow/http) — request envelope; `204` from `undefined`
- [Clock · Durable Sleep](/docs/elements/clock/sleep) — pause physics
- [fx](/docs/reference/fx) — `fx.step`, `fx.retry`, `fx.clock.sleep`, `fx.using`
- [Configuration](/docs/reference/configuration) — `drivers.journal`

## Next

<Cards>
  <Card
    title="Durable Sleep"
    description="Process-safe pauses that resume across reboots."
    href="/docs/elements/clock/sleep"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="HTTP"
    description="Synchronous REST, QUERY, resources, and live SSE."
    href="/docs/elements/flow/http"
  />
  <Card
    title="Flow Overview"
    description="One shape for every kind of backend behavior."
    href="/docs/elements/flow"
  />
</Cards>


# Authentication (/docs/elements/gate/auth)

Authentication is configured as `oke({ gate: { auth } })`. That bag issues `/auth/*` Flows
(unless `http: false`), fills `fx.auth`, and leaves permission to policy gates — there is no
`gate.auth` handle for `.gate(...)`.

For developers shipping signed-in APIs on okengine — turn on auth, plug a method, attach policies.

<Callout title="The one rule">
  Turn on `gate.auth` for identity. Attach `gate.policy` / `gate.scope` (or `.public()`) for
  permission. Boot fails if an HTTP trigger has neither.
</Callout>

## Smallest Example

<Steps>

<Step>
### Enable auth on the app

```typescript title="src/app.ts"
import { oke } from "okengine";
import { username } from "okengine/plugins";

export const app = oke({
  name: "notes",
  env: "dev",
  gate: {
    auth: {
      // secret required in prod; minted in dev when omitted
      // basePath defaults to "/auth"
    },
  },
}).plug(username());
```

</Step>

<Step>
### Declare a signed-in policy and attach it

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const member = gate.policy("member", {
  description: "Signed-in user",
  check: ({ auth }) => !!auth.verified,
});
```

```typescript title="src/flows/profile/get.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";

export const get = on(
  http.get().gate(member),
  flow({
    do: async (_, fx) => ({ userId: fx.auth.userId }),
  }),
);
```

</Step>

<Step>
### Sign in and call

```bash
# Method routes live under basePath (default /auth) — see your plugged method docs
curl -X GET http://localhost:6530/profile \
  -H "accept: application/json" \
  -H "authorization: Bearer …"
```

Authenticated callers reach `do` with `fx.auth.userId` and `fx.auth.scopes` set. Anonymous
callers fail the policy → typed `Unauthorized`.

</Step>

</Steps>

## Progressive Patterns

From Bearer-only identity to cookies, API keys, and method plugins:

<Tabs items={["Bearer", "Cookies", "API keys", "Plugins"]}>

<Tab value="Bearer">

Default transport is `Authorization: Bearer <access>`. The pipeline verifies the token into
`fx.auth` before gate evaluation:

```typescript
export const app = oke({
  name: "notes",
  env: "dev",
  gate: { auth: {} },
});
```

In production, set `gate.auth.secret` (or `OKE_AUTH_SECRET`). Omitting it in `prod` throws:
`gate.auth: secret is required in production (set gate.auth.secret or OKE_AUTH_SECRET)`.

Forged, expired, or revoked access tokens map to typed `Unauthorized` — they never become a
principal.

</Tab>

<Tab value="Cookies">

Opt-in HttpOnly cookie mirror (Bearer remains default). Enable under `gate.auth.cookies`:

```typescript
gate: {
  auth: {
    cookies: {
      enabled: true,
      prefix: "oke", // default
      sameSite: "lax", // default
      // secure defaults true; path defaults "/"
    },
  },
}
```

**Consequence:** cookie sessions need the same CSRF / CORS posture as any cookie app — plug
[`csrf`](/docs/plugins/csrf) and [`cors`](/docs/plugins/cors) when browsers call cross-origin.

</Tab>

<Tab value="API keys">

Machine principals authenticate with a key secret. Inside `do`, `fx.auth.apiKeyId` is set;
session-only methods (`fx.auth.createApiKey`, tenant admin, …) refuse keys.

```typescript title="src/flows/keys/create.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";

export const create = on(
  http.post().gate(member),
  flow({
    do: async (_, fx) => {
      const { key, secret } = await fx.auth.createApiKey({
        name: "ci",
        scopes: ["notes:read"],
        expiresIn: "90d",
      });
      return { id: key.id, secret }; // secret shown once
    },
  }),
);
```

Key methods: `createApiKey` · `listApiKeys` · `revokeApiKey` · `rotateApiKey` · `updateApiKey`.

</Tab>

<Tab value="Plugins">

`gate.auth` alone does not ship a login UI — plug a method from `okengine/plugins`:

| Plugin              | Docs                                   |
| ------------------- | -------------------------------------- |
| `username`          | [Username](/docs/plugins/username)     |
| `magicLink`         | [Magic link](/docs/plugins/magic-link) |
| `passkey`           | [Passkey](/docs/plugins/passkey)       |
| `oauth` / providers | [OAuth](/docs/plugins/oauth)           |
| `anonymous`         | [Anonymous](/docs/plugins/anonymous)   |
| `twoFactor`         | [Two-factor](/docs/plugins/two-factor) |
| `otp`               | [OTP](/docs/plugins/otp)               |

</Tab>

</Tabs>

## Options

| Option                                      | Type          | Default            | Meaning                                                    |
| ------------------------------------------- | ------------- | ------------------ | ---------------------------------------------------------- |
| `secret`                                    | `string`      | minted in non-prod | HMAC for access tokens; required in prod                   |
| `basePath`                                  | `string`      | `"/auth"`          | HTTP prefix for auth Flows                                 |
| `http`                                      | `boolean`     | `true`             | `false` skips `/auth/*` bindings (secret + tables only)    |
| `audience`                                  | `string`      | `"oke-app"`        | Access-token audience claim                                |
| `emailAndPassword.enabled`                  | `boolean`     | `false`            | Credential method knobs                                    |
| `emailAndPassword.requireEmailVerification` | `boolean`     | `false`            | Block sign-in until verified                               |
| `session.accessTtlMs`                       | `number`      | `14m`              | Access token lifetime                                      |
| `session.refreshTtlMs`                      | `number`      | `30d`              | Refresh token lifetime                                     |
| `session.freshAgeMs`                        | `number`      | `24h`              | Max age for "fresh" step-up policies                       |
| `session.idleTtlMs`                         | `number`      | off                | Idle timeout from last activity                            |
| `session.absoluteTtlMs`                     | `number`      | off                | Absolute lifetime from creation                            |
| `session.singleSessionPerUser`              | `boolean`     | `false`            | One live family per user                                   |
| `cookies`                                   | bag           | off                | HttpOnly cookie mirror                                     |
| `secondaryStorage`                          | bag           | off                | Hot-path KV cache (`prefix` default `"auth:"`)             |
| `tenant`                                    | `true` \| bag | off                | Multi-tenancy — see [Tenancy](/docs/elements/gate/tenancy) |

## What `fx.auth` carries

| Field           | Meaning                                                                    |
| --------------- | -------------------------------------------------------------------------- |
| `userId`        | Principal id, or `null` when anonymous                                     |
| `scopes`        | `ReadonlySet<string>` used by `gate.scope` (may include tenant-role union) |
| `sessionScopes` | Session / JWT scopes before tenant-role union                              |
| `verified`      | Session / credential passed verification                                   |
| `apiKeyId`      | Present when the principal is an API key                                   |

Inside `do`, read identity from `fx.auth` — not `fx.user`. World access stays on `fx`.

## Sessions & Cookies

<Callout title="Detailed section">
  Defaults match a short-lived access token plus a long-lived refresh family. Override only what
  your product needs.
</Callout>

```typescript title="src/app.ts"
export const app = oke({
  name: "notes",
  env: "prod",
  gate: {
    auth: {
      secret: process.env.OKE_AUTH_SECRET!,
      session: {
        accessTtlMs: 14 * 60 * 1000,
        refreshTtlMs: 30 * 24 * 60 * 60 * 1000,
        freshAgeMs: 24 * 60 * 60 * 1000,
        // idleTtlMs / absoluteTtlMs / singleSessionPerUser when needed
      },
      cookies: {
        enabled: true,
        prefix: "oke",
        sameSite: "lax",
        secure: true,
        path: "/",
      },
    },
  },
});
```

| Cookie option    | Default | Meaning                           |
| ---------------- | ------- | --------------------------------- |
| `enabled`        | `false` | Opt-in HttpOnly mirror            |
| `prefix`         | `"oke"` | Cookie name prefix                |
| `secure`         | `true`  | HTTPS-only                        |
| `sameSite`       | `"lax"` | `"strict"` \| `"lax"` \| `"none"` |
| `path`           | `"/"`   | Cookie path                       |
| `crossSubdomain` | `false` | Share across subdomains           |
| `domain`         | —       | Explicit cookie domain            |

**Freshness:** policies that require a recent sign-in should compare session age against
`session.freshAgeMs` (default 24h). Step-up plugins (e.g. [two-factor](/docs/plugins/two-factor))
build on the same window.

## API Keys

<Accordions>

<Accordion title="createApiKey options">

| Field         | Type                   | Meaning                                    |
| ------------- | ---------------------- | ------------------------------------------ |
| `name`        | `string`               | Label for Console / list                   |
| `scopes`      | `string[]`             | Cannot exceed the creator’s session scopes |
| `expiresIn`   | duration string        | Optional (`"90d"`, `"1h"`, …)              |
| `ipAllowlist` | `string[]`             | Optional source IP allowlist               |
| `rateLimit`   | `{ max, per } \| null` | Optional per-key throttle                  |

Return shape: `{ key, secret }` — the secret is shown once at create / rotate.

</Accordion>

<Accordion title="Session-only refusals">
  Key management and tenant admin refuse API-key principals:

```json
{
  "data": null,
  "error": {
    "code": "Forbidden",
    "message": "You are not allowed to perform this action.",
    "data": { "gate": "auth:api-keys", "reason": "session_only" }
  }
}
```

Call those methods from a user session. Machine keys authenticate _into_ Flows; they do not
mint more keys.

</Accordion>

</Accordions>

## Public routes

Health checks and login endpoints must declare open posture explicitly:

```typescript
http.get().public();
// equivalent: http.get().gate(gate.public)
```

Auth method Flows under `basePath` register their own posture; your app routes still need
`.gate(...)` or `.public()`.

Set `gate.auth.http: false` when you want tables + Bearer verify without materializing
`/auth/*` HTTP bindings (embedding / Console-style hosts).

## Troubleshooting

<Accordions>

<Accordion title="gate.auth: secret is required in production">
  Cause: `gate.auth: secret is required in production (set gate.auth.secret or OKE_AUTH_SECRET)`.
  Set an explicit secret before shipping — never rely on the minted dev secret in prod.
</Accordion>

<Accordion title="401 on every gated route after sign-in">
  Token missing, expired, wrong audience, or cookies enabled without sending credentials. Check
  `Authorization: Bearer`, `audience`, and cookie `SameSite` / CORS.
</Accordion>

<Accordion title="Forbidden · session_only on createApiKey / listTenants">
  Those methods refuse API-key principals (`error.data.reason: "session_only"`). Call them from a
  user session, not a machine key.
</Accordion>

<Accordion title="Forbidden · not_owner on revokeApiKey">
  Keys are owned by the creator. A different session cannot revoke or rotate another user’s key
  (`reason: "not_owner"`).
</Accordion>

<Accordion title="GateBootError after enabling auth">
  Enabling `gate.auth` does not auto-gate your routes. Attach `member` (or `.public()`) on every
  HTTP trigger — see [Boot Posture](/docs/elements/gate#boot-posture).
</Accordion>

</Accordions>

## Learn more

- [Username plugin](/docs/plugins/username) — email-free sign-up on `gate.auth`
- [Authorization](/docs/elements/gate/authorization) — scopes and ABAC policies
- [RLS](/docs/elements/gate/rls) — row policies from stamped identity
- [Tenancy](/docs/elements/gate/tenancy) — `fx.tenant.id`
- [HTTP](/docs/elements/flow/http) — `.gate` / `.public` on triggers

## Next

<Cards>
  <Card
    title="Authorization"
    description="gate.policy and gate.scope for permission checks."
    href="/docs/elements/gate/authorization"
  />
  <Card
    title="RLS"
    description="Stamp Gate identity into SQL row policies."
    href="/docs/elements/gate/rls"
  />
  <Card
    title="Tenancy"
    description="Resolve fx.tenant.id from claims, headers, or subdomains."
    href="/docs/elements/gate/tenancy"
  />
</Cards>


# Authorization (/docs/elements/gate/authorization)

Authorization answers “may this principal do this?” after identity is known. Declare reusable
`gate.policy` / `gate.scope` handles, compose them with `gate.all`, and attach the chain on the
trigger.

For developers enforcing RBAC / ABAC on okengine — name the check once, reuse it on every route.

<Callout title="The one rule">
  Policies receive `GatePolicyContext` (`auth`, `operator`, optional `meta`) — never invent
  `ctx.user` or `ctx.store`. World access stays inside `do` via `fx`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare policies and scopes

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

/** Signed-in member. */
export const member = gate.policy("member", {
  check: ({ auth }) => !!auth.verified,
});

/** Holds the notes:write scope (name is the scope string). */
export const notesWrite = gate.scope("notes:write");

/** Admin-only ABAC example. */
export const adminOnly = gate.policy("adminOnly", ({ auth }) => auth.scopes.has("admin"));
```

`gate.scope(name)` is shorthand for
`gate.policy(name, ({ auth }) => auth.scopes.has(name))` with `scopes: [name]` recorded.

</Step>

<Step>
### Compose and attach

```typescript title="src/flows/notes/create.ts"
import { on, flow, http, gate } from "okengine";
import { member, notesWrite } from "@/core/gate";

export const notesMutate = gate.all(member, notesWrite);

export const create = on(
  http.post().gate(notesMutate),
  // or: .gate(member, notesWrite)
  flow({
    do: async ({ title }, fx) => fx.json.create({ id: fx.id(), title }),
  }),
);
```

</Step>

<Step>
### See the denial

Missing `notes:write` on an authenticated caller:

```json
{
  "data": null,
  "error": {
    "code": "Forbidden",
    "message": "You are not allowed to perform this action.",
    "data": { "gate": "notes:write", "reason": "policy denied" }
  }
}
```

Anonymous callers denied earlier in the chain get `Unauthorized` instead.

</Step>

</Steps>

## Progressive Patterns

From a single scope to Module:Action names, composition, and operator plane:

<Tabs items={["Scope", "ABAC", "Compose", "Operator"]}>

<Tab value="Scope">

Prefer `gate.scope` when the check is exactly “has this scope string”:

```typescript
export const bookingCreate = gate.scope("booking:create");
export const notesRead = gate.scope("notes:read");
```

Names containing `:` are Module:Action pairs — extracted into Manifest permissions for Console.

</Tab>

<Tab value="ABAC">

Use `gate.policy` when the predicate needs more than a single scope membership:

```typescript
export const freshAdmin = gate.policy("freshAdmin", ({ auth, meta }) => {
  if (!auth.scopes.has("admin")) return false;
  // Example: combine scopes with request meta (ip allowlists live in plugins)
  return !!auth.verified && meta?.ip !== undefined;
});
```

Async predicates are allowed (`Promise<boolean>`).

</Tab>

<Tab value="Compose">

`gate.all` is every-member-must-pass, left to right. Nesting flattens:

```typescript
const write = gate.all(member, notesWrite, gate.rate({ max: 60, per: "1m", keyBy: "user" }));
const strictWrite = gate.all(write, gate.scope("notes:admin"));
```

Attach either the composed handle or list members on `.gate(...)` — order is declaration order;
first denial wins.

</Tab>

<Tab value="Operator">

Console / operator-plane Flows read `operator`, not `auth.userId`:

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const consoleOp = gate.policy("consoleOp", ({ operator }) => operator.id !== null);
```

```typescript title="src/flows/ops/cleanup.ts"
import { on, flow, http } from "okengine";
import { consoleOp } from "@/core/gate";

export const cleanup = on(
  http.post().gate(consoleOp),
  flow({
    plane: "operator",
    do: async (_, fx) => ({ operatorId: fx.operator.id }),
  }),
);
```

Rate `keyBy: "operator"` keys on `operator.id`.

</Tab>

</Tabs>

## Policy Context

| Field                | Type                          | Meaning                                        |
| -------------------- | ----------------------------- | ---------------------------------------------- |
| `auth.userId`        | `string \| null`              | User-plane principal                           |
| `auth.scopes`        | `ReadonlySet<string>`         | Granted scopes (may include tenant-role union) |
| `auth.verified`      | `boolean \| undefined`        | Credential verified                            |
| `auth.apiKeyId`      | `string \| null \| undefined` | API-key principal when present                 |
| `operator.id`        | `string \| null`              | Operator-plane principal                       |
| `meta.ip` / `userId` | optional                      | Subject dims for rate `keyBy`                  |

Policies must not touch the store, vault, or network — those belong in `do` via `fx`.

## Declaration Forms

| Form            | Example                                                | Notes                             |
| --------------- | ------------------------------------------------------ | --------------------------------- |
| Predicate       | `gate.policy("member", ({ auth }) => !!auth.verified)` | Shortest                          |
| Options         | `gate.policy("member", { check, description? })`       | Console / docs label              |
| Scope shorthand | `gate.scope("notes:write")`                            | Records `scopes: ["notes:write"]` |
| Public sentinel | `gate.public`                                          | Always allows; reserved name      |
| Chain           | `gate.all(a, b, c)`                                    | Flattened at attach time          |

Reserved: `gate.policy("public", …)` and `gate.scope("public")` throw — use `gate.public`.

## Module:Action Permissions

<Callout title="Detailed section">
  Scopes with a colon (`notes:write`) are Module:Action pairs. Manifest + Console derive the
  permission catalog from Flows, effects, and gate scopes — you do not hand-maintain a second list.
</Callout>

| Source                         | Example pair                     |
| ------------------------------ | -------------------------------- |
| Flow id `notes.create`         | `notes:create`                   |
| `gate.scope("booking:create")` | `booking:create`                 |
| Effect `reads: ["sql:notes"]`  | `store.sql:read`                 |
| Operator-plane Flow            | also `console:…` when applicable |

**Consequence:** prefer `gate.scope("notes:write")` over a one-off policy with the same string —
the scope is recorded on the declaration for Manifest / Console.

Tenant roles may grant **application** scopes only (`notes:write`), not `console:*`. See
[Tenancy](/docs/elements/gate/tenancy).

## Attaching on Resources & Live

Chain once on the mount — every verb (and live, when present) inherits the same gates:

```typescript title="src/flows/notes/index.ts"
import { on, http } from "okengine";
import { member, notesWrite } from "@/core/gate";
import { notesResource } from "./resource";

export const notes = on(http.resource("/notes", notesResource.all()).gate(member, notesWrite));
```

Live firehoses use the same `.gate(...)` fluent:

```typescript
on(http.live(orderStatus).gate(member));
```

See [HTTP · Resources](/docs/elements/flow/http#resources) and
[HTTP · Live Streams](/docs/elements/flow/http#live-streams).

## Denial Mapping

| Situation                        | Code           | Status | `error.data`                |
| -------------------------------- | -------------- | ------ | --------------------------- |
| Policy denied, no `auth.userId`  | `Unauthorized` | 401    | `{}`                        |
| Policy denied, principal present | `Forbidden`    | 403    | `{ gate, reason }`          |
| Unknown gate name at runtime     | deny           | —      | `reason: "unknown gate: …"` |

`reason` is `"policy denied"` for failed predicates (unless a rate gate burned earlier).

## Troubleshooting

<Accordions>

<Accordion title="403 Forbidden but the user looks signed in">
  A later gate in the chain failed — read `error.data.gate`. Confirm the session / key actually
  carries that scope (`fx.auth.scopes`), including tenant-role unions when tenancy is on.
</Accordion>

<Accordion title="Policy always fails with verified users">
  Check for `!!auth.verified` vs `auth.userId !== null`. Some principals have a `userId` before
  verification completes — pick the predicate that matches your product rule.
</Accordion>

<Accordion title='TypeError: name "public" is reserved'>
  Cause: `gate.policy: name "public" is reserved — use gate.public for intentionally unauthenticated
  surfaces` (or the `gate.scope` variant). Rename the policy or use `.public()`.
</Accordion>

<Accordion title="TypeError: gate.all: at least one member is required">
  Pass one or more policy / rate / nested `all` handles — empty `gate.all()` is invalid.
</Accordion>

<Accordion title="Scope present in JWT but route still Forbidden">
  Tenant-role scopes union into `fx.auth.scopes` only when the Flow is tenant-scoped (default when
  tenancy is on). A Flow with `tenantScoped: false` keeps session scopes only.
</Accordion>

</Accordions>

## Learn more

- [Authentication](/docs/elements/gate/auth) — how `fx.auth` is filled
- [RLS](/docs/elements/gate/rls) — row policies from the same Gate identity
- [Rate Limits](/docs/elements/gate/rate-limits) — throttle on the same chain
- [Tenancy](/docs/elements/gate/tenancy) — tenant-role scope union
- [HTTP](/docs/elements/flow/http) — `.gate(...)` on triggers

## Next

<Cards>
  <Card
    title="RLS"
    description="Stamp Gate identity into SQL row policies."
    href="/docs/elements/gate/rls"
  />
  <Card
    title="Rate Limits"
    description="Throttle with gate.rate on the same chain."
    href="/docs/elements/gate/rate-limits"
  />
  <Card
    title="Tenancy"
    description="fx.tenant.id and membership-scoped data."
    href="/docs/elements/gate/tenancy"
  />
</Cards>


# Overview (/docs/elements/gate)

Gate is how your backend **decides whether a caller may run a Flow**. Policies and rate limits
attach to the trigger with `.gate(...)`. Identity comes from `oke({ gate: { auth } })` into
`fx.auth`; Gate policies read that principal — they do not invent a separate middleware layer.

For developers protecting APIs on okengine — declare the check, chain it on the trigger, keep
`do` behind the first denial.

<Callout title="The one rule">
  **First denial wins.** Gates on a trigger evaluate left to right. The first reject stops the chain
  — later gates are skipped, and `do` never runs.
</Callout>


> Gate chain: member, canBook, and fair evaluate left to right. First denial wins — Unauthorized when anonymous, Forbidden when authenticated but a policy says no, RateLimited when the quota is burned. Later gates are skipped. do runs only when every gate passed.


## Smallest Example

<Steps>

<Step>
### Declare a policy and attach it

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const member = gate.policy("member", {
  description: "Signed-in workspace member",
  check: ({ auth }) => !!auth.verified,
});
```

```typescript title="src/flows/profile/get.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";

export const get = on(
  http.get().gate(member),
  flow({
    do: async (_, fx) => ({ userId: fx.auth.userId }),
  }),
);
```

</Step>

<Step>
### Call with and without a session

```bash
# Anonymous → 401 Unauthorized (policy denied, no userId)
curl -X GET http://localhost:6530/profile -H "accept: application/json"

# Signed-in → 200 with principal
curl -X GET http://localhost:6530/profile \
  -H "accept: application/json" \
  -H "authorization: Bearer …"
```

Anonymous denial envelope:

```json
{
  "data": null,
  "error": { "code": "Unauthorized", "message": "Authentication required.", "data": {} }
}
```

</Step>

</Steps>

<Callout title="Auth posture is required">
  Every HTTP (and MCP tool) trigger must declare a gate chain **or** `.public()`. Omitting both
  fails boot with `GateBootError`. See [Boot Posture](#boot-posture).
</Callout>

## Progressive Patterns

From a single policy to scopes, rates, and reusable chains:

<Tabs items={["Policy", "Scope", "Rate", "Compose"]}>

<Tab value="Policy">

Named ABAC check over `auth` / `operator` / optional `meta`:

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const member = gate.policy("member", ({ auth }) => !!auth.verified);

export const adminOnly = gate.policy("adminOnly", {
  description: "Admin scope required",
  check: ({ auth }) => auth.scopes.has("admin"),
});
```

Both forms are valid: a bare predicate, or `{ check, description? }`.

</Tab>

<Tab value="Scope">

`gate.scope(name)` is shorthand for
`gate.policy(name, ({ auth }) => auth.scopes.has(name))` with `scopes: [name]` recorded:

```typescript
export const notesWrite = gate.scope("notes:write");
```

**Consequence:** the scope string is the policy id — one source of truth for Manifest and Console.

</Tab>

<Tab value="Rate">

KV-backed throttle. Pass one options object — there is no `gate.rate(name, { limit })` form:

```typescript
export const notesWriteRate = gate.rate({
  max: 60,
  per: "1m",
  keyBy: "user",
  description: "Note write throttle",
});
```

Default strategy is `sliding-window-counter`. See [Rate Limits](/docs/elements/gate/rate-limits).

</Tab>

<Tab value="Compose">

`gate.all` builds a reusable chain. Nested `all` handles flatten at `.gate(...)`:

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const member = gate.policy("member", ({ auth }) => !!auth.verified);
export const notesWrite = gate.scope("notes:write");
export const notesWriteRate = gate.rate({ max: 60, per: "1m", keyBy: "user" });

export const notesMutate = gate.all(member, notesWrite, notesWriteRate);
```

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { notesMutate } from "@/core/gate";

export const create = on(
  http.post().gate(notesMutate),
  // or: .gate(member, notesWrite, notesWriteRate)
  flow({
    do: async (input, fx) => fx.json.create({ id: fx.id(), ...input }),
  }),
);
```

</Tab>

</Tabs>

## Declaration Reference

| Declaration   | Signature                                             | Purpose                                |
| ------------- | ----------------------------------------------------- | -------------------------------------- |
| `gate.policy` | `gate.policy(name, check \| { check, description? })` | Named ABAC / auth predicate            |
| `gate.scope`  | `gate.scope(name)`                                    | Require `auth.scopes.has(name)`        |
| `gate.public` | `gate.public` (handle)                                | Intentionally unauthenticated sentinel |
| `gate.rate`   | `gate.rate({ max, per, keyBy?, … })`                  | KV-backed throttle                     |
| `gate.all`    | `gate.all(...members)`                                | Reusable left-to-right chain           |

| `oke({ gate })` option | Type                  | Default             | Meaning                                                                           |
| ---------------------- | --------------------- | ------------------- | --------------------------------------------------------------------------------- |
| `auth`                 | bag / omitted         | off                 | Sessions, keys, optional tenancy — see [Authentication](/docs/elements/gate/auth) |
| `policies`             | decls / `all` handles | auto from registry  | Explicit bag; usually unnecessary                                                 |
| `rateLimit.enabled`    | `boolean`             | `true` when auth on | Stricter presets on auth Flows                                                    |
| `unguardedHttp`        | `"deny"` \| `"allow"` | `"deny"`            | `"allow"` only when `env === "test"`                                              |

## Attaching Gates

Gates chain on the **trigger**, not on a `gates: [...]` field inside `flow()`:

```typescript
http.post().gate(member, gate.scope("editor"), gate.rate({ max: 100, per: "1m", keyBy: "user" }));
```

**Public** — open the route without authentication:

```typescript
http.get().public();
// equivalent: http.get().gate(gate.public)
```

Resource mounts accept `.gate(...)` / `.public()` once — every CRUD verb (and live, when
present) gets the same chain. See [HTTP · Resources](/docs/elements/flow/http#resources).

## Evaluation Order

<Callout title="Detailed section">
  If you only need “attach and go”, the Smallest Example is enough. This section is the walk the
  runtime takes before `do`.
</Callout>

On each request the pipeline:

1. Resolves identity into `fx.auth` (Bearer / cookie / API key when `gate.auth` is on).
2. Resolves `fx.tenant.id` when `gate.auth.tenant` is enabled.
3. Evaluates the trigger’s gate names **in declaration order**.
4. Stops on the first denial — maps to a typed envelope; later gates never run.
5. Invokes `do` only when every gate allowed.

```typescript title="src/flows/notes/create.ts"
import { on, flow, http, gate } from "okengine";

const member = gate.policy("member", ({ auth }) => !!auth.verified);
const write = gate.scope("notes:write");
const throttle = gate.rate({ max: 60, per: "1m", keyBy: "user" });

// Order matters: identity → capability → quota
export const create = on(
  http.post().gate(member, write, throttle),
  flow({
    do: async (input, fx) => fx.json.create({ id: fx.id(), ...input }),
  }),
);
```

| Caller                          | First denial               | Later gates                  |
| ------------------------------- | -------------------------- | ---------------------------- |
| Anonymous                       | `member` → `Unauthorized`  | `write` / `throttle` skipped |
| Signed-in, no `notes:write`     | `write` → `Forbidden`      | `throttle` skipped           |
| Signed-in + scope, quota burned | `throttle` → `RateLimited` | —                            |
| All pass                        | —                          | `do` runs                    |

**Consequence:** put identity policies **before** rates so anonymous traffic 401s instead of
burning a shared `"anon"` quota under `keyBy: "user"`.

<Accordions>

<Accordion title="What policies may read">
  Predicates receive `GatePolicyContext` only — `auth`, `operator`, optional `meta` (`ip`, `userId`,
  …). They must not touch Store, Vault, or the network. World access stays in `do` via `fx`. See
  [Authorization](/docs/elements/gate/authorization).
</Accordion>

<Accordion title="Rate evaluation fields">
  A rate take records `remaining` and `retryAfterMs` on the evaluation. A burn maps to
  `RateLimited` with `{ retryAfterMs }` in `error.data`.

Missing KV still uses kind `rate` → typed `RateLimited` (often `retryAfterMs: 0`);
telemetry reason is `"rate gate requires kv"`.

</Accordion>

<Accordion title="Unknown gate names">
  A name that is not registered denies with reason `unknown gate: …`. Prefer attaching the declared
  handle (`member`) rather than a raw string so the registry always knows the predicate.
</Accordion>

</Accordions>

## Denial Mapping

Denials are typed error values — never thrown stacks mid-pipeline:

| Situation                        | Code           | Status | Typical `error.data`               |
| -------------------------------- | -------------- | ------ | ---------------------------------- |
| Policy denied, no `auth.userId`  | `Unauthorized` | 401    | `{}`                               |
| Policy denied, principal present | `Forbidden`    | 403    | `{ gate, reason }`                 |
| Rate gate burned / no KV         | `RateLimited`  | 429    | `{ retryAfterMs }`                 |
| Tenant required / not a member   | `Forbidden`    | 403    | `{ gate: "auth:tenants", reason }` |

Authenticated policy denial:

```json
{
  "data": null,
  "error": {
    "code": "Forbidden",
    "message": "You are not allowed to perform this action.",
    "data": { "gate": "notes:write", "reason": "policy denied" }
  }
}
```

Rate burn:

```json
{
  "data": null,
  "error": {
    "code": "RateLimited",
    "message": "Too many requests. Try again later.",
    "data": { "retryAfterMs": 42000 }
  }
}
```

## Boot Posture

Every HTTP and MCP-tool trigger must carry at least one gate (including `gate.public`) or
`.public()`. Missing posture throws `GateBootError` listing every gap:

```text
gate boot failed — 2 trigger(s) missing auth posture (attach a gate or .public()):
  - notes.list GET /notes
  - notes.create POST /notes
```

`unguardedHttp: "allow"` skips the audit **only** when `env === "test"`. Outside test it has no
effect — migrate real apps with per-trigger `.public()`.

MCP tools follow the same rule: a tool trigger with an empty gate list fails the same boot
audit (listed as `mcp` + tool name).

## The Capabilities of Gate

<Cards>
  <Card
    title="Authentication"
    description="Enable gate.auth, populate fx.auth, then protect Flows with policies."
    href="/docs/elements/gate/auth"
  />
  <Card
    title="Authorization Policies"
    description="gate.policy and gate.scope for RBAC / ABAC; compose with gate.all."
    href="/docs/elements/gate/authorization"
  />
  <Card
    title="RLS"
    description="Stamp Gate identity into SQL — store.schema.policy helpers filter rows."
    href="/docs/elements/gate/rls"
  />
  <Card
    title="Rate Limits"
    description="gate.rate with five KV strategies — sliding-window-counter by default."
    href="/docs/elements/gate/rate-limits"
  />
  <Card
    title="Tenancy"
    description="Opt into gate.auth.tenant and read fx.tenant.id for isolation."
    href="/docs/elements/gate/tenancy"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title="GateBootError — missing auth posture">
  Cause: `gate boot failed — N trigger(s) missing auth posture (attach a gate or .public()):`. Every
  listed HTTP/MCP trigger needs `.gate(...)` or `.public()`. `unguardedHttp: "allow"` only works
  when `env === "test"`.
</Accordion>

<Accordion title="401 Unauthorized on a gated route">
  A policy denied and `auth.userId` is null — or Bearer forge / expiry mapped to `Unauthorized`
  before gates. Send a valid Bearer (or cookie when enabled), or mark the route `.public()` if it
  should be open.
</Accordion>

<Accordion title="403 Forbidden with gate + reason">
  The principal is authenticated but failed a later policy (`error.data.gate` names it). Check
  scopes on the session / API key, or the predicate in that policy.
</Accordion>

<Accordion title="429 RateLimited with retryAfterMs">
  A `gate.rate` burned its quota (or ran without KV). Wait `retryAfterMs`, raise `max` / widen
  `per`, or configure a KV driver when reason telemetry shows `"rate gate requires kv"`.
</Accordion>

<Accordion title='TypeError: gate.policy name "public" is reserved'>
  Use `gate.public` or `.public()` for open surfaces. Do not declare `gate.policy("public", …)` or
  `gate.scope("public")`.
</Accordion>

<Accordion title="TypeError: gate.all: at least one member is required">
  `gate.all()` with zero members is invalid. Pass one or more policy / rate / nested `all` handles.
</Accordion>

</Accordions>

## Learn more

- [Authentication](/docs/elements/gate/auth) — `gate.auth`, `fx.auth`, sessions and keys
- [Authorization](/docs/elements/gate/authorization) — policies, scopes, `gate.all`
- [RLS](/docs/elements/gate/rls) — Gate identity stamped into SQL row policies
- [Rate Limits](/docs/elements/gate/rate-limits) — strategies, `keyBy`, KV
- [Tenancy](/docs/elements/gate/tenancy) — `fx.tenant.id` and isolation
- [HTTP](/docs/elements/flow/http) — `.gate(...)` / `.public()` on triggers
- [Errors](/docs/reference/errors) — `Unauthorized` · `Forbidden` · `RateLimited`

## Next

<Cards>
  <Card
    title="Authentication"
    description="Configure gate.auth and session identity."
    href="/docs/elements/gate/auth"
  />
  <Card
    title="HTTP triggers"
    description="Attach .gate(...) and .public() on REST routes."
    href="/docs/elements/flow/http"
  />
  <Card
    title="Vault Element"
    description="Declare secrets and protected configuration."
    href="/docs/elements/vault"
  />
</Cards>


# Rate Limits (/docs/elements/gate/rate-limits)

`gate.rate` protects Flows from abuse and runaway clients. Limits run as atomic takes on the KV
driver before `do`. Compose rates with policies via `gate.all` or list them on `.gate(...)`.

For developers throttling login, writes, and expensive reads on okengine — declare the window,
key the subject, attach on the trigger.

<Callout title="The one rule">
  Pass options as one object: `{ max, per, keyBy? }`. There is no `gate.rate(name, { limit, window })`
  form — the runtime names the gate from strategy and window.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a rate gate

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const loginThrottle = gate.rate({
  strategy: "sliding-window-counter", // default when omitted
  max: 5,
  per: "1m",
  keyBy: "ip",
  description: "Login attempts per IP",
});
```

The runtime name is `rate:sliding-window-counter:5/1m` (strategy · max · per).

</Step>

<Step>
### Attach on the trigger

```typescript title="src/flows/auth/login.ts"
import { on, flow, http, gate } from "okengine";
import { loginThrottle } from "@/core/gate";

export const login = on(
  http.post("/auth/sign-in/email").gate(gate.public, loginThrottle),
  flow({
    do: async (input, fx) => {
      // At most 5 takes per IP per minute
      return input;
    },
  }),
);
```

</Step>

<Step>
### See a burned quota

```json
{
  "data": null,
  "error": {
    "code": "RateLimited",
    "message": "Too many requests. Try again later.",
    "data": { "retryAfterMs": 42000 }
  }
}
```

Status is `429 Too Many Requests`. Clients should wait `retryAfterMs` before retrying.

</Step>

</Steps>

## Progressive Patterns

From the default counter to bursty buckets and subject keys:

<Tabs items={["Default", "Token bucket", "keyBy", "Compose"]}>

<Tab value="Default">

Omit `strategy` for `sliding-window-counter` — best accuracy-to-cost ratio (two KV keys, no
boundary bursts):

```typescript
export const fair = gate.rate({ max: 60, per: "1m", keyBy: "user" });
```

</Tab>

<Tab value="Token bucket">

Allow short bursts that refill over `per`:

```typescript
export const bursty = gate.rate({
  strategy: "token-bucket",
  max: 20,
  per: "1m",
  keyBy: "user",
});
```

Use `leaky-bucket` when you need a smooth outbound rate instead of burst capacity.

</Tab>

<Tab value="keyBy">

Subject dimension for the take key:

| `keyBy`              | Subject                                           |
| -------------------- | ------------------------------------------------- |
| `"user"`             | `auth.userId` (else `meta.userId`, else `"anon"`) |
| `"ip"`               | `meta.ip` (else `"0.0.0.0"`)                      |
| `"operator"`         | `operator.id` (else `"anon"`)                     |
| `"global"` / omitted | Shared `"global"` bucket                          |
| other string         | `meta[keyBy]` when present, else the literal      |

```typescript
gate.rate({ max: 100, per: "1h", keyBy: "ip" });
gate.rate({ max: 10, per: "1s" }); // global
```

</Tab>

<Tab value="Compose">

Rates sit on the same chain as policies — first denial wins:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http, gate } from "okengine";

export const notesMutate = gate.all(
  gate.policy("member", ({ auth }) => !!auth.verified),
  gate.scope("notes:write"),
  gate.rate({ max: 60, per: "1m", keyBy: "user" }),
);

export const create = on(
  http.post().gate(notesMutate),
  flow({
    do: async (input, fx) => fx.json.create({ id: fx.id(), ...input }),
  }),
);
```

Put identity policies **before** rates when anonymous traffic should 401 instead of burning
quota under `"anon"`.

</Tab>

</Tabs>

## Strategies

| Strategy                 | Best for                   | Cost shape                  |
| ------------------------ | -------------------------- | --------------------------- |
| `sliding-window-counter` | Default — accuracy vs cost | Two keys, weighted estimate |
| `token-bucket`           | Bursty traffic with refill | One hash (`tokens`, `ts`)   |
| `leaky-bucket`           | Smooth outbound rate       | One hash (`level`, `ts`)    |
| `fixed-window`           | Simple reset intervals     | One counter per bucket      |
| `sliding-log`            | Strict precision           | ZSET of event timestamps    |

All five run as atomic Lua on the KV driver (memory / redis EVAL).

<Callout title="Detailed section">
  Pick the strategy from product physics, not fashion. The default covers most HTTP write paths;
  switch only when you need bursts, smooth leak, or exact event logs.
</Callout>

<Tabs items={["sliding-window-counter", "token-bucket", "leaky-bucket", "fixed-window", "sliding-log"]}>

<Tab value="sliding-window-counter">

Near-exact rolling window without boundary spikes of a naive fixed window:

```typescript
gate.rate({
  strategy: "sliding-window-counter", // or omit — this is the default
  max: 100,
  per: "1m",
  keyBy: "user",
});
```

Uses current + previous window counters with a time-weighted estimate.

</Tab>

<Tab value="token-bucket">

Burst up to `max`, then refill smoothly across `per`:

```typescript
gate.rate({
  strategy: "token-bucket",
  max: 20,
  per: "1m",
  keyBy: "ip",
});
```

**Consequence:** a quiet client can spend its full burst immediately after idle time.

</Tab>

<Tab value="leaky-bucket">

Smooth outbound rate — rejects when the “bucket level” would exceed `max`:

```typescript
gate.rate({
  strategy: "leaky-bucket",
  max: 50,
  per: "1m",
  keyBy: "user",
});
```

Prefer this for upstream APIs that punish spikes more than averages.

</Tab>

<Tab value="fixed-window">

Simple counter that resets each `per` bucket:

```typescript
gate.rate({
  strategy: "fixed-window",
  max: 1000,
  per: "1h",
  keyBy: "global",
});
```

Cheapest layout; allows a double-burst at the bucket boundary.

</Tab>

<Tab value="sliding-log">

Exact event log (ZSET of timestamps) — strict precision at higher cost:

```typescript
gate.rate({
  strategy: "sliding-log",
  max: 10,
  per: "1m",
  keyBy: "ip",
});
```

Use for sensitive endpoints where approximate counters are not enough.

</Tab>

</Tabs>

## Options

| Option        | Type           | Default                  | Meaning                                      |
| ------------- | -------------- | ------------------------ | -------------------------------------------- |
| `max`         | `number`       | required                 | Takes allowed within `per` (`> 0`)           |
| `per`         | `string`       | required                 | Window / refill (`"1m"`, `"60s"`, `"1h"`, …) |
| `strategy`    | `RateStrategy` | `sliding-window-counter` | Algorithm id                                 |
| `keyBy`       | `string`       | global                   | Subject dim (`"ip"`, `"user"`, …)            |
| `overridable` | `boolean`      | `false`                  | Console may override `max` / `per` in Store  |
| `description` | `string`       | —                        | Human label for Console / docs               |

**Consequence:** rate gates need a KV driver in the app. Without KV, evaluation denies with
telemetry reason `"rate gate requires kv"` and the typed code is still `RateLimited`.

Declare-time guards:

- `gate.rate: max must be a positive number`
- `gate.rate: per is required`

Invalid `per` at evaluation → deny with `reason: "invalid per: …"`.

## Naming & Overrides

The runtime name is always `rate:{strategy}:{max}/{per}`:

```text
rate:sliding-window-counter:60/1m
rate:token-bucket:20/1m
```

When `overridable: true`, Console may tune `max` / `per` in Store without a code deploy. Leave
it `false` (default) for hard product limits.

`oke({ gate: { rateLimit: { enabled } } })` controls whether **auth path** Flows attach
stricter presets. Default is `true` when `gate.auth` is on, `false` otherwise — it does not
replace your own `gate.rate` declarations.

## Troubleshooting

<Accordions>

<Accordion title="429 RateLimited immediately">
  Quota burned for that subject. Inspect `retryAfterMs`, widen `per`, raise `max`, or change `keyBy`
  so clients do not share one global bucket unintentionally.
</Accordion>

<Accordion title='Denial reason "rate gate requires kv"'>
  Configure a KV driver (`memory` for local, `redis` for multi-instance). Policy-only apps can omit
  KV; any `gate.rate` on a hot path cannot. The HTTP envelope is still `RateLimited`.
</Accordion>

<Accordion title="Anonymous traffic burns user quotas">
  With `keyBy: "user"`, missing `userId` maps to `"anon"` — shared. Put a member policy before the
  rate, or key by `"ip"` for public endpoints.
</Accordion>

<Accordion title="TypeError: gate.rate: max must be a positive number">
  `max` must be `> 0`. Zero and negatives fail at declare.
</Accordion>

<Accordion title="TypeError: gate.rate: per is required">
  Pass a duration string (`"1m"`, `"30s"`, …). Empty / missing `per` fails at declare.
</Accordion>

<Accordion title='Denial reason "invalid per: …"'>
  The duration string did not parse to a positive window. Use `ms|s|m|h|d` style values the Clock
  duration parser accepts (`"1m"`, `"60s"`, `"1h"`).
</Accordion>

</Accordions>

## Learn more

- [Gate Overview](/docs/elements/gate) — chain order and denial mapping
- [Authorization](/docs/elements/gate/authorization) — policies on the same `.gate(...)`
- [Store · KV](/docs/elements/store/kv) — KV facet drivers
- [HTTP](/docs/elements/flow/http) — attach rates on REST routes

## Next

<Cards>
  <Card
    title="Tenancy"
    description="Resolve fx.tenant.id for B2B isolation."
    href="/docs/elements/gate/tenancy"
  />
  <Card
    title="RLS"
    description="Stamp Gate identity into SQL row policies."
    href="/docs/elements/gate/rls"
  />
  <Card
    title="Vault Element"
    description="Secrets and protected configuration."
    href="/docs/elements/vault"
  />
</Cards>


# RLS (/docs/elements/gate/rls)

Gate decides whether a caller may run a Flow. **RLS** decides which SQL rows that
caller may see or change.

After `.gate(...)` passes, `fx.store` stamps the principal into Postgres session GUCs
(`oke.gate` · `oke.user` · `oke.scopes` · `oke.tenant`) so table policies enforce the
same identity.

For developers shipping multi-user SQL on okengine — declare Gate policies, attach them
on the trigger, and mirror them on the table with `store.schema.policy.*`.

<Callout title="The one rule">
  Put row predicates on the **table** (`store.schema.policy.gate` / `owner` / `scope` / `tenant`).
  Put permission to **act** on the **trigger** (`.gate(...)`). Never invent a separate middleware
  layer — the stamp rides every `fx.store` call.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a Gate policy and an RLS table

```typescript title="src/core/gate.ts"
import { gate } from "okengine";

export const member = gate.policy("member", ({ auth }) => !!auth.verified);
```

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";

export const tasks = store.schema.table(
  "tasks",
  {
    id: field.id().primaryKey(),
    owner: field.text().notNull(),
    title: field.text().notNull(),
  },
  [
    store.schema.policy.gate("member", { for: "select" }),
    store.schema.policy.owner("owner", { for: "all" }),
  ],
);
```

</Step>

<Step>
### Gate the route and query through `fx.store`

```typescript title="src/flows/tasks/list.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { db, tasks } from "@/schema";

export const list = on(
  http.get().gate(member),
  flow({
    do: async (_, fx) => fx.store(db).select().from(tasks),
  }),
);
```

</Step>

<Step>
### What the caller sees

Alice’s session stamps `oke.gate = 'member'` and `oke.user = '<alice>'`. SELECT
policies that require `oke.gate() = 'member'` and `owner = oke.user()` return only
Alice’s rows — even if the Flow’s `where` clause is empty.

Use an RLS-capable SQL driver (`postgres` / `pglite`). The `memory` SQL driver does
not enforce row policies.

</Step>

</Steps>

## Progressive Patterns

From a gate-name check to owner columns, scopes, and tenants:

<Tabs items={["Gate", "Owner", "Scope", "Tenant"]}>

<Tab value="Gate">

`store.schema.policy.gate(name)` stamps `oke.gate() = '…'` (default `for: "select"`).
The name must match a Gate policy on the trigger (rate gates are skipped when picking
the stamp):

```typescript
store.schema.policy.gate("member", { for: "select" });
```

**Consequence:** the first non-rate gate on `.gate(...)` becomes `oke.gate` for that
request. Put the policy you want stamped **first** when the chain mixes policies and
rates.

</Tab>

<Tab value="Owner">

`store.schema.policy.owner(column)` stamps `column = oke.user()` (default `for: "all"`):

```typescript
store.schema.policy.owner("owner", { for: "all" });
```

`oke.user()` is the stamped `fx.auth.userId` (empty string when anonymous / public).

</Tab>

<Tab value="Scope">

`store.schema.policy.scope(scope)` stamps `oke.has_scope('…')` (default `for: "insert"`).
Pass a string or a `gate.scope(...)` handle so the scope stays single-sourced:

```typescript
import { gate } from "okengine";

export const bookingCreate = gate.scope("booking:create");

// On the table:
store.schema.policy.scope(bookingCreate, { for: "insert" });
// or: store.schema.policy.scope("booking:create", { for: "insert" });
```

</Tab>

<Tab value="Tenant">

When `gate.auth.tenant` is on, every table needs a tenant policy or an explicit opt-out:

```typescript
store.schema.policy.tenant("tenantId"); // default for: "all" → tenantId = oke.tenant()
// or shared catalog tables:
store.schema.unscoped();
```

See [Tenancy](/docs/elements/gate/tenancy). Extract fails without one:

```text
extract: table "{store}.{table}" needs store.schema.policy.tenant(...) or store.schema.unscoped() when gate.auth.tenant is on
```

</Tab>

</Tabs>

## Identity Stamp

After Gate allows, the SQL session prelude sets:

| GUC / helper       | Source                             | Used by         |
| ------------------ | ---------------------------------- | --------------- |
| `oke.gate()`       | First non-rate gate on the trigger | `policy.gate`   |
| `oke.user()`       | `fx.auth.userId`                   | `policy.owner`  |
| `oke.has_scope(…)` | `fx.auth.scopes` (JSON)            | `policy.scope`  |
| `oke.tenant()`     | `fx.tenant.id` when tenancy is on  | `policy.tenant` |

The stamp `SET LOCAL ROLE oke_app` and turns `row_security` on so table owners cannot
bypass policies on the hot path.

| Plane / mode                | Stamp?                     |
| --------------------------- | -------------------------- |
| User-plane Flow, gated      | Yes                        |
| `plane: "operator"`         | No — operator bypasses RLS |
| Console / Call API `bypass` | No                         |

## Policy Helpers

Extras are the **third argument** of `store.schema.table(name, cols, extras)`:

| Helper                             | Default `for` | Predicate                               |
| ---------------------------------- | ------------- | --------------------------------------- |
| `store.schema.policy.gate(name)`   | `select`      | `oke.gate() = '…'`                      |
| `store.schema.policy.owner(col)`   | `all`         | `col = oke.user()`                      |
| `store.schema.policy.scope(scope)` | `insert`      | `oke.has_scope('…')`                    |
| `store.schema.policy.tenant(col)`  | `all`         | `col = oke.tenant()`                    |
| `store.schema.rls()`               | —             | Enable RLS with no policies yet         |
| `store.schema.unscoped()`          | —             | Opt out of tenant requirement           |
| `store.schema.policy(name, opts)`  | —             | Raw `using` / `withCheck` / `as` / `to` |

`for` accepts `select` · `insert` · `update` · `delete` · `all`. Optional `as`:
`"permissive"` (default) or `"restrictive"`. Optional `to` limits Postgres roles.

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";
import { member, bookingCreate } from "@/core/gate";

export const bookings = store.schema.table(
  "bookings",
  {
    id: field.id().primaryKey(),
    owner: field.text().notNull(),
    tenantId: field.text().notNull(),
  },
  [
    store.schema.policy.gate("member", { for: "select" }),
    store.schema.policy.owner("owner", { for: "all" }),
    store.schema.policy.scope(bookingCreate, { for: "insert" }),
    store.schema.policy.tenant("tenantId"),
  ],
);
```

<Callout title="Detailed section">
  Schema extras, relations, and emit live under [Store · SQL · Schema Extras &
  RLS](/docs/elements/store/sql#schema-extras--rls). This page is the Gate ↔ row identity contract.
</Callout>

## Composition

Multiple **PERMISSIVE** policies for the same command OR together — a row is visible if
any permissive policy passes. **RESTRICTIVE** policies AND with the rest (`as: "restrictive"`).

```typescript
[
  store.schema.policy.gate("member", { for: "select" }),
  store.schema.policy.owner("owner", { for: "select" }),
];
```

A `member` principal sees rows that pass the gate predicate **or** the owner predicate
(for SELECT). Tighten with restrictive policies or a single compound raw `using`.

## Live Queries & Resources

Resource live (`store.resource({ live: true })`) and handwritten `liveQuery` classify
CDC events through the same RLS stamp. Needs `postgres` / `pglite` and a gated identity.

See [HTTP · Resource Live](/docs/elements/flow/http#resources) and
[Store · SQL](/docs/elements/store/sql).

## Troubleshooting

<Accordions>

<Accordion title="Every query returns zero rows">
  Stamp or policies are wrong. Confirm `.gate(member)` (or matching name), that `owner` / `tenantId`
  columns match `oke.user()` / `oke.tenant()`, and that the driver is `postgres` or `pglite` — not
  `memory`.
</Accordion>

<Accordion title="Extract: needs store.schema.policy.tenant or unscoped">
  Cause: `extract: table "{store}.{table}" needs store.schema.policy.tenant(...) or
  store.schema.unscoped() when gate.auth.tenant is on`. Add a tenant column policy or mark the table
  shared.
</Accordion>

<Accordion title="live query requires an RLS-capable SQL driver">
  Cause: `live query for "…" requires an RLS-capable SQL driver (postgres / pglite)`. Switch the SQL
  driver and attach a Gate chain so the stamp has a principal.
</Accordion>

<Accordion title="Operator / Console sees all rows">
  `plane: "operator"` and Call API `bypass` skip the RLS stamp by design. User-plane Flows always
  stamp when gated.
</Accordion>

<Accordion title="Wrong gate stamped on a long chain">
  The first non-rate gate name wins for `oke.gate()`. Reorder `.gate(member, write, rate)` so the
  policy you want in SQL predicates comes before rates and secondary scopes when those scopes use
  `oke.has_scope` instead of `oke.gate`.
</Accordion>

</Accordions>

## Learn more

- [Authorization](/docs/elements/gate/authorization) — `gate.policy` / `gate.scope` on triggers
- [Tenancy](/docs/elements/gate/tenancy) — `fx.tenant.id` and `policy.tenant`
- [Store · SQL](/docs/elements/store/sql) — schema extras, session handle, drivers
- [HTTP](/docs/elements/flow/http) — resource live + RLS

## Next

<Cards>
  <Card
    title="Rate Limits"
    description="Throttle with gate.rate on the same chain."
    href="/docs/elements/gate/rate-limits"
  />
  <Card
    title="Tenancy"
    description="Resolve fx.tenant.id and policy.tenant."
    href="/docs/elements/gate/tenancy"
  />
  <Card
    title="Store · SQL"
    description="Schema extras, fx.store, and RLS-capable drivers."
    href="/docs/elements/store/sql"
  />
</Cards>


# Tenancy (/docs/elements/gate/tenancy)

Tenancy is an identity **dimension** under `gate.auth.tenant`, not a separate gate you attach with
`.gate(...)`. When enabled, the runtime resolves a tenant for the request and exposes it as
`fx.tenant.id`. Your SQL / KV / files code (and optional RLS) use that id for isolation.

For developers shipping B2B or mixed B2C+B2B apps on okengine — enable the dimension, filter every
store access by `fx.tenant.id`.

<Callout title="The one rule">
  Enable `gate.auth.tenant`, keep membership checks on — then filter every store access by
  `fx.tenant.id`. Do not invent a `gate.auth.tenant` trigger handle.
</Callout>

## Smallest Example

<Steps>

<Step>
### Turn tenancy on

```typescript title="src/app.ts"
import { oke } from "okengine";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: {
    auth: {
      tenant: true, // claim source · header x-oke-tenant · required: false
    },
  },
});
```

Or pass options:

```typescript
tenant: {
  required: true, // every user-plane request needs a tenant
  source: "header", // "claim" | "header" | "subdomain" | "resolve"
  header: "x-oke-tenant",
}
```

</Step>

<Step>
### Protect the route and scope data

```typescript title="src/flows/invoices/list.ts"
import { on, flow, http } from "okengine";
import { eq } from "drizzle-orm";
import { member } from "@/core/gate";
import { db, invoices } from "@/schema";

export const list = on(
  http.get().gate(member),
  flow({
    do: async (_, fx) => {
      const tenantId = fx.tenant.id;
      if (!tenantId) return fx.fail("Forbidden", { reason: "tenant_required" });
      return await fx.store(db).select().from(invoices).where(eq(invoices.tenantId, tenantId));
    },
  }),
);
```

</Step>

<Step>
### Switch tenant (session)

```typescript
// Session-only
const session = await fx.auth.switchTenant("ten_acme");
// New access token carries tid; subsequent requests resolve fx.tenant.id
```

</Step>

</Steps>

## Progressive Patterns

From claim-based B2C+B2B to pure B2B, custom resolve, and Flow opt-out:

<Tabs items={["Claim", "Required", "Resolve", "Opt-out"]}>

<Tab value="Claim">

Default when `tenant: true`: `source: "claim"`, `required: false`, header name `x-oke-tenant`
(unused until you switch source).

The signed session / key claim supplies `tid`. Membership is still checked unless
`authoritative: true` on a custom `resolve`.

</Tab>

<Tab value="Required">

Pure B2B — every user-plane request must resolve a tenant:

```typescript
tenant: {
  required: true,
  source: "header",
  header: "x-oke-tenant",
}
```

**Consequence:** missing tenant fails before `do` with
`Forbidden` · `reason: "tenant_required"`. Operator-plane Flows are not forced through the
same required path.

</Tab>

<Tab value="Resolve">

Custom resolver (tier-3 escape hatch). Membership is still checked unless `authoritative`:

```typescript
tenant: {
  source: "resolve",
  resolve: ({ auth, request, claimTenantId }) => {
    if (claimTenantId) return claimTenantId;
    return request?.headers.get("x-workspace") ?? null;
  },
  // authoritative: true  // trust resolve without membership query (fail-open — rare)
}
```

</Tab>

<Tab value="Opt-out">

When tenancy is on, Flows default to tenant-scoped (role scopes may union into `fx.auth.scopes`).
Opt a Flow out with `tenantScoped: false`:

```typescript
flow("billing.globalReport", {
  plane: "operator",
  tenantScoped: false,
  do: async (_, fx) => {
    // No tenant-role scope union; fx.tenant.id may still be set from the request
  },
});
```

</Tab>

</Tabs>

## Resolution Sources

<Callout title="Detailed section">
  Three tiers: signed claim (no membership query), client-supplied header/subdomain (membership
  required), and custom `resolve` (membership unless `authoritative`).
</Callout>

<Tabs items={["claim", "header", "subdomain", "resolve"]}>

<Tab value="claim">

Signed `tid` on the session / API key. Fast path — no membership query:

```typescript
tenant: true;
// expands to source: "claim", required: false, header: "x-oke-tenant"
```

After `fx.auth.switchTenant(id)`, new access tokens carry `tid` for this source.

</Tab>

<Tab value="header">

Client sends the configured header; membership is required:

```typescript
tenant: {
  source: "header",
  header: "x-oke-tenant", // default name
}
```

```bash
curl -X GET http://localhost:6530/invoices \
  -H "authorization: Bearer …" \
  -H "x-oke-tenant: ten_acme"
```

Not a member → `Forbidden` · `reason: "not_member"`. Anonymous with a header →
`Unauthorized`.

</Tab>

<Tab value="subdomain">

First Host label is the tenant id (`acme.example.com` → `acme`). Needs at least three
labels; membership is required:

```typescript
tenant: {
  source: "subdomain";
}
```

Internal / cron calls without an HTTP request keep the stamped claim when present.

</Tab>

<Tab value="resolve">

Callback receives `{ auth, request, claimTenantId }`. Membership still runs unless
`authoritative: true`:

```typescript
tenant: {
  source: "resolve",
  resolve: ({ claimTenantId, request }) =>
    claimTenantId ?? request?.headers.get("x-workspace") ?? null,
}
```

**Consequence:** `authoritative: true` is fail-open — use only when you own the resolver’s
trust boundary.

</Tab>

</Tabs>

## Options

| Option          | Type                                                    | Default        | Meaning                                     |
| --------------- | ------------------------------------------------------- | -------------- | ------------------------------------------- |
| `required`      | `boolean`                                               | `false`        | Reject user-plane requests without a tenant |
| `source`        | `"claim"` \| `"header"` \| `"subdomain"` \| `"resolve"` | `"claim"`      | Where the tenant id comes from              |
| `header`        | `string`                                                | `x-oke-tenant` | Header name when `source: "header"`         |
| `resolve`       | `(ctx) => string \| null \| undefined`                  | —              | Custom resolver; membership still checked   |
| `authoritative` | `boolean`                                               | `false`        | Trust `resolve` without a membership query  |

`tenant: true` expands to `{ required: false, source: "claim", header: "x-oke-tenant", authoritative: false }`.

## `fx.auth` tenant methods

Session-only. API keys get `Forbidden` with `reason: "session_only"`:

| Method                                             | Meaning                              |
| -------------------------------------------------- | ------------------------------------ |
| `listTenants()`                                    | Tenants the user belongs to          |
| `switchTenant(id)`                                 | Mint a new session family with `tid` |
| `createTenant({ name, slug?, id? })`               | Create + add caller as member        |
| `deleteTenant(id)`                                 | Remove tenant                        |
| `addMember` / `removeMember`                       | Membership                           |
| `listMembers(tenantId)`                            | Members of a tenant                  |
| `upsertTenantRole({ tenantId, roleName, scopes })` | Role → scopes (unioned when scoped)  |

```typescript title="src/flows/tenants/switch.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

export const switchTenant = on(
  http
    .post({
      in: z.object({ tenantId: z.string() }),
    })
    .gate(member),
  flow({
    do: async ({ tenantId }, fx) => fx.auth.switchTenant(tenantId),
  }),
);
```

## Store isolation

| Facet | Pattern                                                                  |
| ----- | ------------------------------------------------------------------------ |
| SQL   | Filter / RLS by `fx.tenant.id` (and table tenant policies when declared) |
| KV    | Optional `{tenantId}:` key prefix when the namespace is tenant-scoped    |
| Files | Path / ACL under the active tenant                                       |

RLS helper for a tenant column:

```typescript
import { store, field } from "okengine";

export const invoices = store.schema.table(
  "invoices",
  {
    id: field.text().primaryKey(),
    tenantId: field.text(),
    total: field.integer(),
  },
  [store.schema.policy.tenant("tenantId"), store.schema.rls()],
);
```

Globally shared tables when tenancy is on need an explicit opt-out:

```typescript
store.schema.table("plans", { id: field.text().primaryKey() }, [store.schema.unscoped()]);
```

**Consequence:** Gate resolves identity; your Flow still owns the filter. A missing `where
tenant_id = …` is a data leak, not a Gate bug.

## Denial Mapping

| Situation                                  | Code           | `error.data.reason` |
| ------------------------------------------ | -------------- | ------------------- |
| `required: true`, authenticated, no tenant | `Forbidden`    | `tenant_required`   |
| Header / subdomain / resolve, not a member | `Forbidden`    | `not_member`        |
| Header / subdomain without session         | `Unauthorized` | —                   |
| Tenant admin from API key                  | `Forbidden`    | `session_only`      |

## Troubleshooting

<Accordions>

<Accordion title="fx.tenant.id is null on every request">
  Tenancy off, `required: false` with no claim/header, or membership failed. Enable
  `gate.auth.tenant`, send the claim / header, and confirm the user is a member.
</Accordion>

<Accordion title="Forbidden · session_only on listTenants / switchTenant">
  Tenant admin methods refuse API-key principals. Use a user session.
</Accordion>

<Accordion title="Tenant-granted scope does not authorize a route">
  The Flow set `tenantScoped: false`, or the role was never upserted. Tenant-role union only applies
  when the Flow is tenant-scoped (default when tenancy is on).
</Accordion>

<Accordion title="required: true rejects browser calls">
  Pure B2B needs a tenant on every user-plane request — send `tid` (claim) or the configured header
  before calling gated APIs.
</Accordion>

<Accordion title="Forbidden · not_member on header / subdomain">
  The supplied tenant id is not in the user’s membership set. Add the member, or switch to `source:
  "claim"` after `switchTenant` so the signed `tid` is trusted.
</Accordion>

</Accordions>

## Learn more

- [Authentication](/docs/elements/gate/auth) — `gate.auth` and `fx.auth`
- [Authorization](/docs/elements/gate/authorization) — scopes after tenant-role union
- [RLS](/docs/elements/gate/rls) — `policy.tenant` and the SQL stamp
- [Store](/docs/elements/store) — SQL / KV / files facets
- [HTTP](/docs/elements/flow/http) — gated routes that read `fx.tenant`

## Next

<Cards>
  <Card
    title="RLS"
    description="policy.tenant and the SQL identity stamp."
    href="/docs/elements/gate/rls"
  />
  <Card
    title="Vault Element"
    description="Secrets and protected configuration."
    href="/docs/elements/vault"
  />
  <Card title="Gate Overview" description="Return to Gate overview." href="/docs/elements/gate" />
</Cards>


# Broadcast (/docs/elements/signal/broadcast)

`signal.broadcast` delivers each emission to every subscribed Flow at once. Use it when every
active listener should react and you do not need a retained history.

For developers invalidating caches or syncing in-process state — declare the Signal, bind one or
more `on(signal, flow)` subscribers, emit with `fx.emit`.

<Callout title="The one rule">
  Broadcast is ephemeral. If a subscriber is offline or restarts during the emit, it does not
  receive past events. Use [`live`](/docs/elements/signal/live) when clients need replay. Use
  [`once`](/docs/elements/signal/once) when exactly one worker must claim the job.
</Callout>


> On broadcast, one emit fans out an independent copy to every active subscriber; a process that was offline at emit time does not receive past events — there is no retained tape.


## Smallest Example

<Callout title="One handle, three independent uses">
  `cacheInvalidated` is a shared const. Declare it, bind any number of subscribers, and emit —
  different files, any order. Bind and emit do not have a required sequence.
</Callout>

### Declare

```typescript title="src/signals/cache.ts"
import { signal } from "okengine";
import { z } from "zod";

export const cacheInvalidated = signal.broadcast("cache.invalidated", {
  schema: z.object({ key: z.string() }),
});
```

### Bind a subscriber

```typescript title="src/flows/cache/purge.ts"
import { on, flow } from "okengine";
import { cacheInvalidated } from "@/signals/cache";

export const purgeLocalCache = on(
  cacheInvalidated,
  flow("cache.purgeLocal", {
    do: async ({ key }, fx) => {
      await fx.store.kv.delete(key);
    },
  }),
);
```

Multiple Flows may bind the same handle — every one gets a copy. That is fan-out, not a race.
See [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast)
when you meant a work queue instead.

### Emit

```typescript title="src/flows/skus/[sku]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { cacheInvalidated } from "@/signals/cache";

export const update = on(
  http.patch({
    in: z.object({ sku: z.string(), title: z.string().min(1) }),
  }),
  flow({
    do: async ({ sku, title }, fx) => {
      // … persist the change …
      await fx.emit(cacheInvalidated, { key: `sku:${sku}` });
      return { sku, title };
    },
  }),
);
```

The compiler records `emits: ["cache.invalidated"]` on the producer. Emit resolves when the
outbox commits — the HTTP request does not wait for every subscriber to finish.

<Callout title="Same handle everywhere">
  Import the declared Signal handle (or the same name) in every subscriber and producer. A typo in
  the name creates a different Manifest entry — fan-out never crosses names.
</Callout>

## Progressive Patterns

Explore broadcast from one listener to multi-Flow fan-out, optional hooks, and schema-checked
payloads:

<Tabs items={["Minimal", "Fan-out", "Optional", "Schema"]}>

<Tab value="Minimal">

One Flow subscribed — still broadcast physics (no competing claim, no once DLQ path):

```typescript title="src/signals/catalog.ts"
import { signal } from "okengine";
import { z } from "zod";

export const catalogChanged = signal.broadcast("catalog.changed", {
  schema: z.object({ sku: z.string() }),
});
```

```typescript title="src/flows/cache/invalidate.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";

export const invalidate = on(
  catalogChanged,
  flow("cache.invalidate", {
    do: async ({ sku }, fx) => {
      await fx.store.kv.delete(`sku:${sku}`);
    },
  }),
);
```

</Tab>

<Tab value="Fan-out">

Bind a second Flow to the same Signal — both run. That is the fan-out:

```typescript title="src/flows/search/reindex.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";

export const reindexSku = on(
  catalogChanged,
  flow("search.reindexSku", {
    do: async ({ sku }, fx) => {
      await fx.call(rebuildSearchDoc, { sku });
    },
  }),
);
```

**Consequence:** each subscribed Flow gets its own copy. A failure in one handler does not
claim-lock the message away from siblings the way `once` leases do.

</Tab>

<Tab value="Optional">

Hooks that may have no subscriber yet need `optional: true` or emit throws **OKE1240**:

```typescript
export const clusterHint = signal.broadcast("cluster.hint", {
  optional: true,
  schema: z.object({ nodeId: z.string() }),
});
```

**OKE1240** cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
Fix: bind at least one `on(signal, flow)` or set `{ optional: true }`.

</Tab>

<Tab value="Schema">

Invalid payloads fail at `fx.emit` with **OKE1250** before any subscriber runs:

```typescript
export const priceTick = signal.broadcast("prices.tick", {
  schema: z.object({
    sku: z.string(),
    cents: z.number().int().nonnegative(),
  }),
});
```

Cause shape: `"{resource}": {detail}`. Align the payload with `schema` — no subscriber started.

</Tab>

</Tabs>

## Delivery Reference

How broadcast sits next to the other helpers when you are choosing physics:

| Helper             | Pattern    | Who runs                     | Replay                 | Typical use                           |
| ------------------ | ---------- | ---------------------------- | ---------------------- | ------------------------------------- |
| `signal.once`      | Work queue | Exactly one competing worker | Retries + DLQ          | Emails, fulfillment, sync             |
| `signal.broadcast` | Pub/sub    | Every active subscriber      | None (miss if offline) | Cache bust, multi-side-effect fan-out |
| `signal.live`      | SSE tape   | HTTP clients via `http.live` | `Last-Event-ID` resume | Status feeds, progress                |

| Guarantee                          | Broadcast              | Once               | Live              |
| ---------------------------------- | ---------------------- | ------------------ | ----------------- |
| Competing claim / visibility lease | No                     | Yes (default 30s)  | No                |
| Dead-letter queue path             | No                     | Yes (`deadLetter`) | No                |
| Retained history for late joiners  | No                     | No                 | Yes               |
| Multiple Flows on one emit         | Yes — each gets a copy | One claimer        | Not a Flow worker |

## Options for broadcast

Second argument to `signal.broadcast(name, options?)`. Delivery is the helper name — not an
option.

| Option        | Type            | Default  | Meaning                                                  |
| ------------- | --------------- | -------- | -------------------------------------------------------- |
| `schema`      | Standard Schema | omitted  | **Emit** contract — validated at `fx.emit` (**OKE1250**) |
| `optional`    | `boolean`       | `false`  | Allow emit with zero subscribers                         |
| `description` | `string`        | the name | Console / docs blurb                                     |
| `retries`     | `number`        | `3`      | Declared; not the `once` lease / DLQ path                |
| `deadLetter`  | `boolean`       | `true`   | Declared; broadcast does not use once DLQ                |

`retention` is a type error on `signal.broadcast` — that option is live-only:

```text
signal.broadcast("…"): retention is only valid with signal.live
```

## Fan-out Physics

<Callout title="Detailed section">
  If you only need one subscriber, jump to Emit. Fan-out is the distinctive physics: one emit, N
  independent Flow runs, no competing claim.
</Callout>

Bind any number of Flows with `on(theSameSignal, flow(...))`. At dispatch, every matching
binding runs with the same payload.

```typescript title="src/flows/orders/side-effects.ts"
import { on, flow } from "okengine";
import { orderChanged } from "@/signals/orders";

export const bustOrderCache = on(
  orderChanged,
  flow("orders.bustCache", {
    do: async ({ orderId }, fx) => {
      await fx.store.kv.delete(`order:${orderId}`);
    },
  }),
);

export const notifyOrderWatchers = on(
  orderChanged,
  flow("orders.notifyWatchers", {
    do: async ({ orderId }, fx) => {
      await fx.call(pushOrderWatchers, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderChanged, { orderId: "ord_42", kind: "placed" });
```

| Concept    | Meaning                                                    |
| ---------- | ---------------------------------------------------------- |
| Subscriber | An adopted Flow bound with `on(broadcastSignal, flow)`     |
| Copy       | Each subscriber receives the payload independently         |
| Isolation  | One handler's failure does not lease-lock siblings         |
| Offline    | A process that was down at emit time does not get a replay |

**Consequence:** treat broadcast handlers as best-effort side effects. If the work must run
exactly once with retries and a DLQ, declare a separate `signal.once` for that job.

## Emit and Effects

<Callout title="Detailed section">
  If you only need `fx.emit(signal, payload)`, jump to the example. Emit commits the outbox when the
  call resolves. Subscribers run asynchronously afterward.
</Callout>

| Call                                | Records | Use                                                         |
| ----------------------------------- | ------- | ----------------------------------------------------------- |
| `fx.emit(signal, payload?)`         | `emits` | Publish to every active subscriber                          |
| `fx.emit(signal, payload, { key })` | `emits` | `key` is for `once` ordering — ignore for broadcast fan-out |

```typescript title="src/flows/catalog/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { catalogChanged } from "@/signals/catalog";

export const create = on(
  http.post({
    in: z.object({ sku: z.string(), title: z.string().min(1) }),
  }),
  flow({
    do: async ({ sku, title }, fx) => {
      // … write …
      await fx.emit(catalogChanged, { sku });
      return { sku, title };
    },
  }),
);
```

The producer WideEvent / run id is stamped as `parentRunId` for Console trace chains. Schema
mismatches fail at emit (**OKE1250**) before any subscriber runs.

## Subscribers

Each verb of fan-out is a normal Flow. Bind with `on(signal, flow(...))` — same species as
[Consumers](/docs/elements/flow/consumers).

<Tabs items={["Cache", "Notify", "Reindex", "Compose"]}>

<Tab value="Cache">

Drop hot keys when the source of truth changes:

```typescript title="src/flows/cache/on-catalog.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";

export const onCatalogCache = on(
  catalogChanged,
  flow("cache.onCatalog", {
    do: async ({ sku }, fx) => {
      await fx.store.kv.delete(`sku:${sku}`);
      await fx.store.kv.delete(`sku:${sku}:meta`);
    },
  }),
);
```

</Tab>

<Tab value="Notify">

Fan a domain event into a Channel or another Flow without competing for a lease:

```typescript title="src/flows/notify/on-order.ts"
import { on, flow } from "okengine";
import { orderChanged } from "@/signals/orders";
import { orderWatchers } from "@/channels/orders";

export const onOrderNotify = on(
  orderChanged,
  flow("notify.onOrder", {
    do: async ({ orderId }, fx) => {
      await fx.send(orderWatchers, {
        to: "ops@example.com",
        data: { orderId },
      });
    },
  }),
);
```

</Tab>

<Tab value="Reindex">

Kick search / projection work as a side effect of the same emit:

```typescript title="src/flows/search/on-catalog.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";

export const onCatalogReindex = on(
  catalogChanged,
  flow("search.onCatalog", {
    do: async ({ sku }, fx) => {
      await fx.call(rebuildSearchDoc, { sku });
    },
  }),
);
```

</Tab>

<Tab value="Compose">

Need durable retries for one side effect? Emit a `once` job from the broadcast handler (or from
the producer) — do not stretch broadcast into a queue:

```typescript title="src/flows/orders/on-changed.ts"
import { on, flow } from "okengine";
import { orderChanged, orderSyncJob } from "@/signals/orders";

export const onOrderChanged = on(
  orderChanged,
  flow("orders.onChanged", {
    do: async ({ orderId }, fx) => {
      await fx.store.kv.delete(`order:${orderId}`);
      await fx.emit(orderSyncJob, { orderId });
    },
  }),
);
```

`orderSyncJob` is `signal.once(...)` — competing workers, retries, and DLQ live there.

</Tab>

</Tabs>

## Choosing Physics

<Callout title="Detailed section">
  Pick physics from the guarantee you need. Broadcast is the wrong tool when work must be claimed
  once, or when a late client must catch up.
</Callout>

| Need                                  | Use instead                                          |
| ------------------------------------- | ---------------------------------------------------- |
| Exactly one worker processes the job  | [`once`](/docs/elements/signal/once)                 |
| Browser / SSE resume after disconnect | [`live`](/docs/elements/signal/live)                 |
| Durable multi-step work with journal  | [Durable Workflows](/docs/elements/flow/workflows)   |
| HTTP SSE firehose on a path           | [`http.live`](/docs/elements/flow/http#live-streams) |

Broadcast does **not** use the `once` visibility lease. There is no competing claim and no
dead-letter queue for exhausted retries in the queue sense — see
[Once · Retries](/docs/elements/signal/once#retries-and-dead-letters).

<Accordions>

<Accordion title="Orphan emit (OKE1240)">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  Fix: bind at least one `on(signal, flow)` or set `{ optional: true }`.
</Accordion>

<Accordion title="Schema (OKE1250)">
  Cause: `"{resource}": {detail}`. Fix the payload or the `schema`. No subscriber ran.
</Accordion>

<Accordion title="Missed while offline">
  Expected. Restarted processes do not replay broadcast history. Emit again after boot if the
  listener must refresh state, or switch to `live` for a retained tape.
</Accordion>

<Accordion title="retries / deadLetter on the declaration">
  Those fields exist on the shared options type (defaults `3` / `true`) so Console and Manifest stay
  uniform. Broadcast delivery does not follow the once lease + DLQ path — do not expect
  `fx.deadLetters(broadcastSignal)` to behave like a queue operator surface.
</Accordion>

<Accordion title="retention on broadcast">
  Throws at declare: `signal.broadcast("…"): retention is only valid with signal.live`. Cap a
  client-visible tape with `signal.live({retention})` instead.
</Accordion>

</Accordions>

## Troubleshooting

<Accordions>

<Accordion title="Only one of two subscribers runs">
  Confirm both Flows import the same Signal handle / name and are adopted into the app. Names must
  match the Manifest entry exactly. A second `signal.broadcast("catalog.changed")` with a different
  spelling is a different signal.
</Accordion>

<Accordion title="Subscriber missed an event after restart">
  Broadcast has no tape. Re-emit on boot, hydrate from Store, or use
  [`signal.live`](/docs/elements/signal/live).
</Accordion>

<Accordion title="OKE1240 on emit">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  No subscriber is bound. Add `on(signal, …)` or `{ optional: true }`.
</Accordion>

<Accordion title="OKE1250 on emit">
  Cause: `"{resource}": {detail}`. Align the payload with `schema` — no subscriber started.
</Accordion>

<Accordion title="OKE1072 — Signal flow unnamed">
  Cause: `A signal flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("cache.purgeLocal", { do }))`.
</Accordion>

<Accordion title="Expecting retries / DLQ like a queue">
  Use `signal.once`. Broadcast fan-out is not the lease + DLQ path documented under
  [Once](/docs/elements/signal/once).
</Accordion>

<Accordion title="TypeError: retention is only valid with signal.live">
  You passed `retention` to `signal.broadcast`. Drop it, or switch the helper to `signal.live` if
  you need a retained SSE tape.
</Accordion>

<Accordion title="HTTP client never sees the event">
  Broadcast is for Flow subscribers, not browsers. Expose a
  [`signal.live`](/docs/elements/signal/live) tape with
  [`http.live`](/docs/elements/flow/http#live-streams) (or a gated GET `.live(signal)`).
</Accordion>

</Accordions>

## Learn more

- [Signal Overview](/docs/elements/signal) — delivery matrix and drivers
- [Once](/docs/elements/signal/once) — competing workers and DLQ
- [Live](/docs/elements/signal/live) — retained SSE
- [Consumers](/docs/elements/flow/consumers) — binding `on(signal)`
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — when the consumer is a browser
- [fx](/docs/reference/fx) — `fx.emit`
- [Errors](/docs/reference/errors) — OKE1072 · OKE1240 · OKE1250

## Next

<Cards>
  <Card
    title="Live"
    description="Retained event tapes over HTTP SSE."
    href="/docs/elements/signal/live"
  />
  <Card
    title="Once"
    description="Competing workers with leases and dead letters."
    href="/docs/elements/signal/once"
  />
  <Card
    title="Consumers"
    description="Signal workers, Clock jobs, and SQL CDC."
    href="/docs/elements/flow/consumers"
  />
</Cards>


# Overview (/docs/elements/signal)

Signal is how your backend **moves data when the producer should not wait**. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: `signal.once`, `signal.broadcast`, or `signal.live`.

For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(handle, flow("name", { do }))`.

<Callout title="The one rule">
  Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live` as an
  exported const. Bind with `on(handle, flow("name", { do }))`. Physics and the **emit**
  `schema` live on the Signal; the worker inherits the payload type, not `flow.in`.
</Callout>


> Signal delivery physics: once — two workers compete and exactly one claims; broadcast — every subscriber gets a copy; live — a late bus.live() subscriber replays the full retained history.


## Smallest Example

<Callout title="One handle, three independent uses">
  `orderPlaced` is a shared const. Declare it, bind a worker, and emit from a producer — different
  files, different people, any order. None of these is a prerequisite step for the others.
</Callout>

### Declare

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderPlaced = signal.once("orders.placed", {
  schema: z.object({
    orderId: z.string(),
    amount: z.number(),
    userId: z.string(),
  }),
  retries: 5,
  deadLetter: true,
});
```

### Bind a worker

```typescript title="src/flows/orders/fulfill.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const fulfill = on(
  orderPlaced,
  flow("orders.fulfill", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);
```

### Emit

```typescript
// Inside any Flow — need not live next to the worker:
await fx.emit(orderPlaced, {
  orderId: "ord_99",
  amount: 150,
  userId: "usr_1",
});
```

The compiler records `emits: ["orders.placed"]` on the producer. The HTTP request does not wait
for the worker.

<Callout title="Live is not a worker">
  `signal.live` is an HTTP SSE tape. Expose it with
  [`http.live`](/docs/elements/flow/http#live-streams) — do not bind `on(liveSignal, flow)` as a
  competing consumer.
</Callout>

A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
type-check. Import the exported const.

## Progressive Patterns

Same helpers + `fx.emit` from a queue job to a fan-out and a browser feed:

<Tabs items={["Once", "Broadcast", "Live", "Optional"]}>

<Tab value="Once">

Competing workers — exactly one claims each message. Failed attempts retry, then dead-letter
when `deadLetter` is true (default):

```typescript title="src/signals/email.ts"
import { signal } from "okengine";
import { z } from "zod";

export const emailTask = signal.once("tasks.email", {
  schema: z.object({ to: z.string().email(), body: z.string() }),
  retries: 3,
  deadLetter: true,
});
```

```typescript title="src/flows/workers/email.ts"
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";

export const processEmail = on(
  emailTask,
  flow("workers.email", {
    do: async ({ to, body }, fx) => {
      await fx.send(rawEmail, { to, body });
    },
  }),
);
```

</Tab>

<Tab value="Broadcast">

Every subscribed Flow gets a copy. Offline listeners miss the event — there is no replay tape:

```typescript title="src/signals/cache.ts"
import { signal } from "okengine";
import { z } from "zod";

export const catalogChanged = signal.broadcast("catalog.changed", {
  schema: z.object({ sku: z.string() }),
});
```

```typescript title="src/flows/cache/invalidate.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/cache";

export const invalidate = on(
  catalogChanged,
  flow("cache.invalidate", {
    do: async ({ sku }, fx) => {
      await fx.store.kv.delete(`sku:${sku}`);
    },
  }),
);
```

</Tab>

<Tab value="Live">

Retained tape for browsers. Mount with `http.live` (or a gated GET path):

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});
```

```typescript title="src/flows/orders/firehose.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const firehose = on(http.live(orderStatus).gate(member));
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

</Tab>

<Tab value="Optional">

Emit with zero subscribers throws **OKE1240** unless `optional: true`. Use that for live
firehoses and hooks that may have no worker yet:

```typescript
export const webhook = signal.once("hooks.inbound", {
  optional: true,
  schema: z.object({ id: z.string() }),
});
```

**OKE1240** cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
Fix: add `on(signal, …)` or mark `{ optional: true }`.

</Tab>

</Tabs>

## Delivery Physics

| Helper             | Pattern    | Consumer                      | Replay                 | Typical use                 |
| ------------------ | ---------- | ----------------------------- | ---------------------- | --------------------------- |
| `signal.once`      | Work queue | Competing workers, lease lock | Retries + DLQ          | Emails, fulfillment, sync   |
| `signal.broadcast` | Pub/sub    | Every active subscriber       | None (miss if offline) | Cache invalidation, fan-out |
| `signal.live`      | SSE tape   | HTTP clients via `http.live`  | `Last-Event-ID` resume | Status feeds, progress      |

## Competing consumers (once vs broadcast)

This is the most common mix-up. `signal.once` is a work queue: **exactly one** worker claims each
message. It is not fan-out.

Three differently-named Flows bound to the same `once` signal, then one emit:

```typescript title="src/flows/orders/side-effects.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const charge = on(
  orderPlaced,
  flow("orders.charge", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeOrder, { orderId });
    },
  }),
);
export const ship = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(shipOrder, { orderId });
    },
  }),
);
export const notify = on(
  orderPlaced,
  flow("orders.notify", {
    do: async ({ orderId }, fx) => {
      await fx.call(notifyOrder, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
```

| Expectation                         | Real result                                                          |
| ----------------------------------- | -------------------------------------------------------------------- |
| All three Flows run                 | **No.** Exactly one of the three runs                                |
| Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
| A sticky assignment to one Flow     | **No.** There is no owner — only an exclusive claim per message      |

**Consequence:** if every bound Flow should independently receive its own copy, that is
[`signal.broadcast`](/docs/elements/signal/broadcast) — switch the declaration. That is the correct
fix for this exact mistake.

Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.

Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`

Load-balancing competing consumers is the **same** Flow on many process replicas — still one
`on()` in source.

Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`

## The Capabilities of Signal

<Cards>
  <Card
    title="Once"
    description="Competing workers, visibility leases, retries, dead letters, and per-key ordering."
    href="/docs/elements/signal/once"
  />
  <Card
    title="Broadcast"
    description="Ephemeral fan-out across subscribed Flows — no retained tape."
    href="/docs/elements/signal/broadcast"
  />
  <Card
    title="Live"
    description="Retained event tapes over HTTP SSE with Last-Event-ID resume."
    href="/docs/elements/signal/live"
  />
</Cards>

## Options Reference

Optional second argument to `signal.once` / `signal.broadcast` / `signal.live`. Delivery is the helper name — not an option.

| Option        | Type                     | Default   | Meaning                                                                                                                        |
| ------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `schema`      | Standard Schema          | omitted   | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow()`. |
| `retries`     | `number`                 | `3`       | Extra attempts after the first (`retries + 1` total) — `once` path                                                             |
| `deadLetter`  | `boolean`                | `true`    | Keep exhausted `once` messages; `false` marks them delivered                                                                   |
| `optional`    | `boolean`                | `false`   | Allow emit with zero subscribers                                                                                               |
| `retention`   | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast`                                                                    |
| `description` | `string`                 | the name  | Console / docs blurb                                                                                                           |

**Consequence:** `deadLetter` is a boolean flag, not a queue name string.

## Emit through fx

<Callout title="Detailed section">
  If you only need `fx.emit(signal, payload)`, jump to the table. Emit commits the outbox when the
  call resolves. The producer run id is stamped as `parentRunId` for Console trace chains.
</Callout>

| Call                                  | Records                 | Use                                          |
| ------------------------------------- | ----------------------- | -------------------------------------------- |
| `fx.emit(signal, payload?, { key? })` | `emits`                 | Publish; `{ key }` serializes `once` per key |
| `fx.deadLetters(signal)`              | `reads` `signal:<name>` | Inspect exhausted `once` messages            |
| `fx.live(signal, { match? })`         | `reads` `signal:<name>` | Server SSE body for a live tape              |

Invalid `schema` payloads fail at emit with **OKE1250** (`"{resource}": {detail}`) before any
consumer runs. Cross-signal `fx.deadLetters` / `fx.live` without a declared read throws
**OKE1001**.

```typescript
await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });
```

Omit `key` for a pure competing pool with no ordering.

## Per-environment drivers

Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
`drivers.signal`):

| Env    | Default  | Boot today                                                              |
| ------ | -------- | ----------------------------------------------------------------------- |
| `dev`  | `redis`  | Emit relays to Redis; consume / live / drain use a process-local outbox |
| `test` | `memory` | In-process bus                                                          |
| `prod` | `redis`  | Same redis honesty as `dev`                                             |

```typescript title="oke.config.ts"
export default defineConfig({
  drivers: {
    signal: { test: "memory", prod: "redis" },
  },
});
```

`postgres` and `nats` fail loud at boot until a native bind ships — never silently fall back to
`memory`. Prefer `memory` for tests; pin `redis` when Compose provides Redis.

## Troubleshooting

<Accordions>

<Accordion title="TypeError: retention is only valid with signal.live">
  `retention: { maxAge, maxCount }` is live-only. Drop it on queue / pub-sub Signals, or switch
  to `signal.live`.
</Accordion>

<Accordion title="OKE1240 — emit with no subscriber">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  Fix: bind `on(signal, flow)` or set `{ optional: true }`.
</Accordion>

<Accordion title="OKE1250 — emit payload failed schema">
  Cause: `"{resource}": {detail}` from the Standard Schema issues. Fix the payload or the `schema`
  on the Signal. The consumer never ran.
</Accordion>

<Accordion title="OKE1070 — flow name defined twice">
  Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings collide. Give at least one a
  distinct name.
</Accordion>

<Accordion title="OKE1072 — Signal flow unnamed">
  Cause: `A signal flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("orders.fulfill", { do }))`.
</Accordion>

<Accordion title="OKE1071 — once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should independently receive this event, or bind only one Flow. See [Competing
  consumers](#competing-consumers-once-vs-broadcast).
</Accordion>

<Accordion title='oke boot: signal driver "postgres" / "nats"'>
  Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
  custom `elements.signal` runtime.
</Accordion>

<Accordion title="redis Signal — process-local consume">
  Boot warns that redis emit relays to Redis while consume / live / drain stay process-local.
  Multi-instance competing consumers need a shared durable outbox path (or a single consumer
  instance) until Redis Streams consume ships.
</Accordion>

</Accordions>

## Learn more

- [Once](/docs/elements/signal/once) — leases, retries, partition keys, DLQ
- [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
- [Live](/docs/elements/signal/live) — SSE tapes and retention
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
- [Client](/docs/client/live) — `api.live` for browsers
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210

## Next

<Cards>
  <Card
    title="Once"
    description="Competing workers, leases, retries, and dead letters."
    href="/docs/elements/signal/once"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="The Model"
    description="Eight elements overview."
    href="/docs/understand/the-architecture"
  />
</Cards>


# Live (/docs/elements/signal/live)

`signal.live` keeps a retained event tape and exposes it over Server-Sent Events. Emit with
`fx.emit`, and mount the feed with `http.live` (or a gated GET).

For developers shipping status feeds and progress UIs — declare the Signal, expose SSE, subscribe
from the typed client.

<Callout title="The one rule">
  `signal.live` is an HTTP SSE tape — not a competing worker. Bind with
  [`http.live`](/docs/elements/flow/http#live-streams). Do not use `on(liveSignal, flow)` as a
  queue consumer. Prefer `{ optional: true }` so emit succeeds when no client is connected yet.
</Callout>


> live retains every payload; a late bus.live() subscriber replays the full history (placed → fulfilling → shipped).


## Smallest Example

<Callout title="One handle, three independent uses">
  `orderStatus` is a shared const. Declare it, expose SSE, and emit — different files, any order.
  The firehose is not "step 2"; emit is not "step 3".
</Callout>

### Declare

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});
```

### Expose SSE

```typescript title="src/flows/orders/firehose.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const firehose = on(http.live(orderStatus).gate(member));
```

### Emit and subscribe

```typescript
// Inside any Flow:
await fx.emit(orderStatus, { orderId: "ord_1", status: "shipped" });
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

Response `Content-Type` is `text/event-stream`. Frames are JSON `data:` lines
(optional `id:` for resume), then `data: [DONE]`.

<Callout title="Pathless firehose">
  `on(http.live(signal))` always mounts `GET /_oke/live/{name}` — there is no pathless file-tree
  stamp for live. Custom paths use `http.get(path).live(signal)`. See [Exposure](#exposure).
</Callout>

## Progressive Patterns

From a default firehose to filtered paths, retention, and the typed client:

<Tabs items={["Firehose", "Filtered", "Retention", "Client"]}>

<Tab value="Firehose">

`on(http.live(signal))` mounts `GET /_oke/live/{name}`. Chain `.gate(...)` like any GET:

```typescript title="src/flows/orders/firehose.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const firehose = on(http.live(orderStatus).gate(member));
```

Signal names in the path are `encodeURIComponent`'d (`chat.message` stays readable).

</Tab>

<Tab value="Filtered">

Path params become a filter: an event forwards when each `:param` that **exists on the payload**
equals the request value. Params missing from the payload are skipped:

```typescript title="src/flows/orders/events.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
```

`GET /orders/ord_1/events` receives `{ orderId: "ord_1", status: "shipped" }` and drops other
orders.

</Tab>

<Tab value="Retention">

Cap the tape with `retention` (live-only). Omit for unbounded history:

```typescript title="src/signals/chat.ts"
import { signal } from "okengine";
import { z } from "zod";

export const chatMessage = signal.live("chat.message", {
  optional: true,
  retention: { maxAge: "7d", maxCount: 10_000 },
  schema: z.object({
    room: z.string(),
    text: z.string(),
    author: z.string(),
  }),
});
```

Invalid `maxAge` / `maxCount` throw at declare — see [Retention](#retention).

</Tab>

<Tab value="Client">

Browsers use the typed client callback — not raw `EventSource` on an invented path:

```typescript
const stop = api.live(
  orderStatus,
  { orderId: "ord_1" },
  {
    onEvent: (event) => {
      /* { orderId, status } */
    },
    onError: (err) => {
      /* 4xx, envelope, or drop */
    },
    autoResubscribe: false,
  },
);
stop();
```

Reconnects send `Last-Event-ID` from the last `id:` received. See
[Client subscription](#client-subscription).

</Tab>

</Tabs>

## Options Reference

Optional second argument to `signal.live(name, options?)`. Delivery is the helper name — not an
option.

| Option        | Type                     | Default   | Meaning                                                  |
| ------------- | ------------------------ | --------- | -------------------------------------------------------- |
| `schema`      | Standard Schema          | omitted   | **Emit** contract — validated at `fx.emit` (**OKE1250**) |
| `optional`    | `boolean`                | `false`   | Allow emit with zero SSE clients / subscribers           |
| `retention`   | `{ maxAge?, maxCount? }` | unbounded | Prune the tape (live-only)                               |
| `description` | `string`                 | the name  | Console / docs blurb                                     |
| `retries`     | `number`                 | `3`       | Declared; live uses the tape, not once DLQ               |
| `deadLetter`  | `boolean`                | `true`    | Declared; live does not use once DLQ                     |

**Consequence:** `retention` on `signal.once` / `signal.broadcast` is a type error — switch to
`signal.live` or drop the option.

## Exposure

<Callout title="Detailed section">
  If you only need the default firehose, jump to the example below. `.live(…)` is GET-only —
  `on(http.post("/x").live(signal))` throws `on(http.*.live(signal)): live exposure must be GET`.
</Callout>

`http.live(signal)` is one-arg `on()` — the engine synthesizes the stream Flow
(`fx.live` + `effects.reads: ["signal:<name>"]`). Chain `.gate(...)` like any GET.

```typescript title="src/flows/orders/firehose.ts"
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});

export const firehose = on(http.live(orderStatus).gate(member));
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

Three GET shapes expose a live SSE body. Pick the physics first, then the path.

| Declaration                                        | Path                    | Physics                               |
| -------------------------------------------------- | ----------------------- | ------------------------------------- |
| `on(http.live(signal))`                            | `GET /_oke/live/{name}` | Signal tape — every event             |
| `on(http.get(path).live(signal))`                  | Your path               | Signal tape — auto-match on `:params` |
| `on(http.get(path).live(table), flow)`             | Your path               | Live **query** — classified CDC rows  |
| `store.resource({ live: true })` + `http.resource` | `GET <path>/live`       | Same live-query physics               |

Signal firehoses and resource live queries are different physics — see Live Queries below.

<Accordions>

<Accordion title="Filtered Paths">
  Path params become a filter: an event is forwarded when each `:param` that
  **exists on the payload** equals the request value. Params missing from the
  payload are skipped (the event still flows). No params = firehose.

```typescript title="src/flows/orders/events.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
```

`GET /orders/ord_1/events` receives `{ orderId: "ord_1", status: "shipped" }`
and drops events for other orders.

</Accordion>

<Accordion title="Custom Match">
  Pass your own Flow as the second argument to `on()` when auto-match is not
  enough. Return `fx.live(signal, { match })` from `do` — do not wrap it with
  `fx.json.stream`.

```typescript title="src/flows/orders/vip-feed.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const vipFeed = on(
  http.get("/orders/vip/events").gate(member).live(orderStatus),
  flow({
    do: (_input, fx) =>
      fx.live(orderStatus, {
        match: (payload) => payload.status === "shipped",
      }),
  }),
);
```

**Consequence:** a custom Flow stamps a distinct match key, so it can coexist
with the auto-match route for the same signal (different path). Two synthesized
firehoses that share signal **and** gates fail uniqueness — see Uniqueness.

</Accordion>

<Accordion title="Live Queries">
  Resource / table live is **not** a signal tape. Each subscriber gets classified
  row events (RLS + list filters). Prefer `http.resource` + `{ live: true }` when
  you already mount the five CRUD ops.

| `live` on the resource | Result                                         |
| ---------------------- | ---------------------------------------------- |
| `{ live: true }`       | Mount `GET <path>/live` now                    |
| omitted                | Mount only if `oke({ store: { live: true } })` |
| `{ live: false }`      | Never mount live for this resource             |

Wire events (consumed with `useLiveQuery` on the [typed client](/docs/client/react)):

| `kind`    | Meaning                                                     |
| --------- | ----------------------------------------------------------- |
| `upsert`  | Row visible under stamp + query — merge by primary key      |
| `revoked` | Row left visibility (`reason: "rls"` or `"query"`) — remove |
| `delete`  | Row deleted — remove                                        |

For a handwritten list, bind the table on GET and open the window with
`liveQuery` — full detail under [HTTP · Live Streams](/docs/elements/flow/http#live-streams).

</Accordion>

<Accordion title="Uniqueness">
  Boot keys each live HTTP route as `(signal, gates, match)`. Match is the
  sorted path-param names, or `custom:<flow>` when you passed a Flow, or
  `(firehose)` when there are no params.

| Pair                                    | Boots?                                    |
| --------------------------------------- | ----------------------------------------- |
| Member `:orderId` + admin firehose      | Yes — gates and match differ              |
| Same params, different gates            | Yes — the client disambiguates with `via` |
| Two member firehoses on different paths | No — **OKE1050**                          |
| Same method + path twice                | No — **OKE1041** first                    |

**OKE1050** cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).`
Fix: a different gate, a path-param filter, or drop the extra route.

</Accordion>

</Accordions>

## Emit through fx

<Callout title="Detailed section">
  If you only need `fx.emit(signal, payload)`, jump to the table. Emit appends to the retained tape
  when the call resolves. The producer run id is stamped as `parentRunId` for Console trace chains.
</Callout>

| Call                          | Records                 | Use                             |
| ----------------------------- | ----------------------- | ------------------------------- |
| `fx.emit(signal, payload?)`   | `emits`                 | Append to the live tape         |
| `fx.live(signal, { match? })` | `reads` `signal:<name>` | Server SSE body for a live tape |

Invalid `schema` payloads fail at emit with **OKE1250** (`"{resource}": {detail}`) before any
client receives the frame. Cross-signal `fx.live` without a declared read throws **OKE1001**.

```typescript title="src/flows/orders/[id]/ship.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { orderStatus } from "@/signals/orders";

export const ship = on(
  http.post({
    in: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }, fx) => {
      await fx.emit(orderStatus, { orderId: id, status: "shipped" });
      return { id, status: "shipped" };
    },
  }),
);
```

Zero SSE clients + `optional: false` → **OKE1240**. Cause:
`Flow "{flow}" emits signal "{resource}" with no subscriber.`
Fix: mount `http.live` (or a path `.live`) before emitting, or set `{ optional: true }`.

## Retention

<Callout title="Detailed section">
  If you only need an unbounded tape, skip this section. `retention` is live-only — omit both fields
  (or omit `retention`) for unlimited history.
</Callout>

| Field      | Type                                  | Meaning                       |
| ---------- | ------------------------------------- | ----------------------------- |
| `maxAge`   | duration string (`"24h"`, `"30s"`, …) | Drop events older than this   |
| `maxCount` | integer ≥ 1                           | Keep only the newest N events |

```typescript title="src/signals/chat.ts"
import { signal } from "okengine";
import { z } from "zod";

export const chatMessage = signal.live("chat.message", {
  optional: true,
  retention: { maxAge: "7d", maxCount: 10_000 },
  schema: z.object({
    room: z.string(),
    text: z.string(),
    author: z.string(),
  }),
});
```

Invalid values throw at declare:

```text
signal.live("…"): retention.maxAge must be a duration like "24h" or "30s"
signal.live("…"): retention.maxCount must be an integer ≥ 1
```

**Consequence:** a pruned `id:` becomes a resume gap — reconnects that still send that
`Last-Event-ID` hit **OKE1210** / 410 `LiveResumeGap`. Prefer `autoResubscribe: true` on flaky
networks, or raise `maxCount` / `maxAge` if clients need longer catch-up.

## Resume and gaps

<Callout title="Detailed section">
  Resume is exclusive: replay events **after** `Last-Event-ID`, then continue live. Unknown or
  pruned ids throw **OKE1210** before the SSE body — HTTP maps that to **410** `LiveResumeGap`.
</Callout>

| Symptom                             | Meaning                         | Fix                                    |
| ----------------------------------- | ------------------------------- | -------------------------------------- |
| **OKE1210** / 410 `LiveResumeGap`   | Cursor gone from the tape       | Drop `Last-Event-ID`; replay remaining |
| `autoResubscribe: true`             | Client clears gap after backoff | Prefer for flaky networks              |
| Custom `fx.live(signal, { match })` | Server-side filter              | Do not wrap with `fx.json.stream`      |

```text
Cursor "{afterId}" missing on "{signal}".
```

SSE frames carry optional `id:` lines. Clients that reconnect send
`Last-Event-ID` from the last `id:` they actually received. A **410** means that cursor is
gone — drop it and replay the remaining tape.

## Client subscription

<Callout title="Detailed section">
  `signal.live` is HTTP SSE. `for await` stays on the server; the browser uses a callback. Prefer
  `api.live` or `useLive` — not raw `EventSource` on an invented path.
</Callout>

The client picks the unique exposure whose `matchKey` fields are a subset of the input,
preferring the largest match (`{ orderId }` beats firehose). A tie needs `via: "unit.flow"`.

```typescript
const stop = api.live(
  orderStatus,
  { orderId: "ord_1" },
  {
    onEvent: (event) => {
      /* { orderId, status } */
    },
    onError: (err) => {
      /* 4xx, envelope, or drop */
    },
    onOpen: () => {
      /* HTTP 200, including reconnects */
    },
    autoResubscribe: false,
  },
);
stop();
```

`api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing Flow.
Reconnects send `Last-Event-ID` from the last `id:` received.

| Option            | Default      | Meaning                                             |
| ----------------- | ------------ | --------------------------------------------------- |
| `onEvent`         | _(required)_ | Each JSON frame from the tape                       |
| `onError`         | omitted      | 4xx, envelope errors, or network drop               |
| `onOpen`          | omitted      | After a successful SSE open (incl. reconnects)      |
| `autoResubscribe` | `false`      | Re-open after a drop (500ms…30s backoff)            |
| `via`             | omitted      | Disambiguate when two exposures share a match shape |
| `signal`          | omitted      | `AbortSignal` to cancel the subscribe               |

React:

```typescript
import { useLive } from "okengine/client-react";

const { events, latest, error, isConnected } = useLive(
  api,
  orderStatus,
  { orderId: "ord_1" },
  { autoResubscribe: true },
);
```

Resource live queries use `useLiveQuery` (snapshot + classified events), not `api.live`.
See [Client · Live](/docs/client/live).

## What live is not

<Callout title="Detailed section">
  Pick physics from the guarantee you need. Live is the wrong tool when work must be claimed once,
  or when every in-process listener should react with no retained history.
</Callout>

| Need                                   | Use instead                                                                 |
| -------------------------------------- | --------------------------------------------------------------------------- |
| Exactly one worker processes the job   | [`once`](/docs/elements/signal/once)                                        |
| Every active Flow gets a copy, no tape | [`broadcast`](/docs/elements/signal/broadcast)                              |
| Classified CDC rows for a list window  | [`http.resource` live](/docs/elements/flow/http#resources) / `useLiveQuery` |
| Durable multi-step work with journal   | [Durable Workflows](/docs/elements/flow/workflows)                          |

Live does **not** use the `once` visibility lease or dead-letter queue. `retries` /
`deadLetter` may appear on the declare options bag, but the live path is the retained tape +
SSE resume — not competing-consumer physics.

## Troubleshooting

<Accordions>

<Accordion title="404 on /_oke/live/…">
  Confirm `on(http.live(signal))` (or a path `.live(signal)`) is adopted. Names in the default path
  are URI-encoded (`encodeURIComponent`). A bare `404` with body `Not Found` means the router found
  no match.
</Accordion>

<Accordion title="TypeError: live exposure must be GET">
  `.live(signal)` only attaches to `http.get` / `http.live`. Other verbs reject live synthesis:
  `on(http.*.live(signal)): live exposure must be GET`.
</Accordion>

<Accordion title="OKE1050 — live signal exposed twice">
  Cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}
  ).` Two firehoses (`http.live` or param-less `.live`) that share the signal and gates cannot boot.
  Change the gate, add a path-param filter, or remove a route.
</Accordion>

<Accordion title="OKE1041 — method + path bound twice">
  Cause: `{method} {path} is bound twice (flow "{flow}").` Two mounts collide on the same method +
  path (for example two `http.live` firehoses that resolve to the same URL). Drop one binding.
</Accordion>

<Accordion title="OKE1210 — 410 LiveResumeGap">
  Cause: `Cursor "{afterId}" missing on "{signal}".` That `Last-Event-ID` was pruned or never
  existed. Reconnect without it; remaining events replay. `autoResubscribe: true` does this after
  backoff.
</Accordion>

<Accordion title="OKE1240 when emitting live">
  No subscriber / exposure counted. Set `{ optional: true }` (usual for firehoses) or mount
  `http.live` before emitting in tests.
</Accordion>

<Accordion title="OKE1250 on emit">
  Cause: `"{resource}": {detail}` from the Standard Schema issues. Align the payload with `schema` —
  no client receives a frame.
</Accordion>

<Accordion title="Bound on(liveSignal, flow) as a worker">
  Live is not competing-consumer physics. Use `signal.once` for workers, or expose SSE with
  `http.live`.
</Accordion>

<Accordion title="Raw EventSource on /api/signals/…">
  That path is not the engine firehose. Use `GET /_oke/live/{name}`, a gated `.live` route, or
  `api.live` / `useLive` from the typed client.
</Accordion>

<Accordion title="Multiple live exposures (client)">
  Two routes share the same match shape. Pass `via: "unit.flow"` or call the exposing Flow
  (`api.orders.events({orderId}, {onEvent})`) instead of root `api.live`.
</Accordion>

<Accordion title="TypeError: retention is only valid with signal.live">
  `retention: { maxAge, maxCount }` is live-only. Drop it on queue / pub-sub Signals, or switch
  to `signal.live`.
</Accordion>

<Accordion title="live query requires a primary key / RLS driver">
  Extract: `live: true on table "…" requires a primary key column`. Runtime needs an RLS-capable SQL
  driver (`postgres` / `pglite`) and a gated identity. That path is resource live — not
  `signal.live`. Attach `.gate(...)` and declare a PK.
</Accordion>

</Accordions>

## Learn more

- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — exposure, uniqueness, match
- [Signal Overview](/docs/elements/signal) — delivery matrix and drivers
- [Once](/docs/elements/signal/once) — when you need competing workers instead
- [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out without a tape
- [Client](/docs/client/live) — `api.live`, `useLive`, `useLiveQuery`
- [fx](/docs/reference/fx) — `fx.emit`, `fx.live`
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on triggers
- [Errors](/docs/reference/errors) — OKE1050 · OKE1210 · OKE1240 · OKE1250

## Next

<Cards>
  <Card
    title="HTTP · Live Streams"
    description="Firehose paths, filters, uniqueness, and client subscribe."
    href="/docs/elements/flow/http#live-streams"
  />
  <Card title="Client" description="api.live and useLive for browsers." href="/docs/client/live" />
  <Card
    title="Signal Overview"
    description="once / broadcast / live in one place."
    href="/docs/elements/signal"
  />
</Cards>


# Once (/docs/elements/signal/once)

`signal.once` processes background work with competing consumers — exactly one worker claims each
message; failed attempts retry, then dead-letter.

For developers shipping jobs on okengine — declare the Signal, bind `on(signal, flow)`, emit with
`fx.emit`.

<Callout title="The one rule">
  Physics live on the Signal (`retries`, `deadLetter`, `optional`). The Flow is the subscriber.
  Visibility lease defaults to **30s** — an inflight worker that dies is reclaimed on the next
  claim.
</Callout>


> On once, a claim sets lockedBy and leaseExpiresAt (default 30s); after expiry the next consumer reclaims the same message (at-least-once). No background sweeper.


## Smallest Example

<Callout title="One handle, three independent uses">
  `emailTask` is a shared const. Declare it, bind a worker, and emit — different files, any order.
  Binding is not "step 2" after declare; emit is not "step 3".
</Callout>

### Declare

```typescript title="src/signals/email.ts"
import { signal } from "okengine";
import { z } from "zod";

export const emailTask = signal.once("tasks.email", {
  schema: z.object({ to: z.string().email(), body: z.string() }),
  retries: 3,
  deadLetter: true,
});
```

### Bind a worker

```typescript title="src/flows/workers/email.ts"
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";

export const processEmail = on(
  emailTask,
  flow("workers.email", {
    do: async ({ to, body }, fx) => {
      await fx.send(rawEmail, { to, body });
    },
  }),
);
```

### Emit

```typescript
await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });
```

The emit resolves when the outbox commits. The worker runs asynchronously — the producer does not
wait for `fx.send` to finish.

## Competing consumers (once vs broadcast)

This is the most common mix-up. `signal.once` is a competing-consumer work queue: **exactly one**
worker claims each message. It is not fan-out.

Three differently-named Flows bound to the same `once` signal:

```typescript title="src/flows/orders/side-effects.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const charge = on(
  orderPlaced,
  flow("orders.charge", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeOrder, { orderId });
    },
  }),
);

export const ship = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(shipOrder, { orderId });
    },
  }),
);

export const notify = on(
  orderPlaced,
  flow("orders.notify", {
    do: async ({ orderId }, fx) => {
      await fx.call(notifyOrder, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
```

| Expectation                         | Real result                                                          |
| ----------------------------------- | -------------------------------------------------------------------- |
| All three Flows run                 | **No.** Exactly one of the three runs                                |
| Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
| A sticky assignment to one Flow     | **No.** There is no owner — only an exclusive claim per message      |

**Consequence:** if every bound Flow should independently receive its own copy, use
[`signal.broadcast`](/docs/elements/signal/broadcast). That is the correct fix for this exact
mistake.

Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.

Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`

Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`

Load-balancing competing consumers is the **same** Flow on many process replicas — still one
`on()` in source. That case is not a second Flow definition.

## Progressive Patterns

From a minimal job to ordered partitions and DLQ inspection:

<Tabs items={["Minimal", "Retries", "Ordered", "Dead letters"]}>

<Tab value="Minimal">

Defaults are `retries: 3`, `deadLetter: true`, `optional: false`:

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderPlaced = signal.once("orders.placed", {
  schema: z.object({
    orderId: z.string(),
    amount: z.number(),
    userId: z.string(),
  }),
});
```

```typescript title="src/flows/orders/fulfill.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const fulfill = on(
  orderPlaced,
  flow("orders.fulfill", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);
```

</Tab>

<Tab value="Retries">

`retries` is extra attempts after the first — total handler invocations are `retries + 1`.
Failed attempts requeue immediately for another claim (no delay between attempts).
Make consumers idempotent; at-least-once delivery can re-run after lease reclaim:

```typescript
export const syncPayment = signal.once("payments.sync", {
  retries: 5,
  deadLetter: true,
  schema: z.object({ chargeId: z.string() }),
});
```

</Tab>

<Tab value="Ordered">

Pass `{ key }` on emit. No two `once` messages sharing `(signal, key)` are claimed at once —
the in-flight visibility lease is the lock:

```typescript
await fx.emit(emailTask, payload, { key: user.id });
```

Same key → FIFO. Different keys run in parallel. Omit `key` for a pure competing pool.

</Tab>

<Tab value="Dead letters">

After `retries + 1` failures with `deadLetter: true`, the message is `dead`. Inspect with
`fx.deadLetters` (records `reads` on `signal:<name>`):

```typescript title="src/flows/ops/email-dlq.ts"
import { on, flow, http } from "okengine";
import { emailTask } from "@/signals/email";

export const list = on(
  http.get(),
  flow({
    effects: { reads: ["signal:tasks.email"] },
    do: async (_, fx) => {
      return await fx.deadLetters(emailTask);
    },
  }),
);
```

`deadLetter: false` marks the message delivered after the last attempt — nothing lands in the DLQ.

</Tab>

</Tabs>

## Delivery Reference

| Surface     | Signature                             | Purpose                                             |
| ----------- | ------------------------------------- | --------------------------------------------------- |
| Declare     | `signal.once(name, options?)`         | Competing queue — one claim per message             |
| Bind        | `on(signalHandle, flow)`              | Worker Flow; payload is `do`'s input                |
| Emit        | `fx.emit(signal, payload?, { key? })` | Enrol in the outbox; records `emits`                |
| Inspect DLQ | `fx.deadLetters(signal)`              | Exhausted messages; records `reads` `signal:<name>` |

| Need                                   | Use instead                                        |
| -------------------------------------- | -------------------------------------------------- |
| Every active subscriber gets a copy    | [`broadcast`](/docs/elements/signal/broadcast)     |
| Browser / SSE resume after disconnect  | [`live`](/docs/elements/signal/live)               |
| Durable multi-step work with a journal | [Durable Workflows](/docs/elements/flow/workflows) |

## Options for once

Optional second argument to `signal.once`. Delivery is the helper name — not an option.

| Option        | Type            | Default  | Meaning                                                                                   |
| ------------- | --------------- | -------- | ----------------------------------------------------------------------------------------- |
| `schema`      | Standard Schema | omitted  | **Emit** contract — validated at `fx.emit` (**OKE1250**). Workers inherit payload typing. |
| `retries`     | `number`        | `3`      | Extra attempts after the first (`retries + 1` total)                                      |
| `deadLetter`  | `boolean`       | `true`   | Keep exhausted messages; not a queue name                                                 |
| `optional`    | `boolean`       | `false`  | Allow emit with zero subscribers                                                          |
| `description` | `string`        | the name | Console / docs blurb                                                                      |

`retention` is a type error on `signal.once` — that option is live-only:

```text
signal.once("…"): retention is only valid with signal.live
```

**Consequence:** `deadLetter` is a boolean flag, not a DLQ signal name string.

## Binding Workers

Each worker binds with `on(signalHandle, flow(...))`. The Signal carries delivery physics; the
Flow is only the handler.

<Tabs items={["Declare", "Bind", "Competing", "Optional"]}>

<Tab value="Declare">

Name the Signal and attach retry / DLQ / schema policy:

```typescript title="src/signals/email.ts"
import { signal } from "okengine";
import { z } from "zod";

export const emailTask = signal.once("tasks.email", {
  schema: z.object({ to: z.string().email(), body: z.string() }),
  retries: 3,
  deadLetter: true,
});
```

</Tab>

<Tab value="Bind">

Subscribe with the same handle. Payload fields destructure in `do`:

```typescript title="src/flows/workers/email.ts"
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";

export const processEmail = on(
  emailTask,
  flow("workers.email", {
    do: async ({ to, body }, fx) => {
      await fx.send(rawEmail, { to, body });
    },
  }),
);
```

The compiler records `emits: ["tasks.email"]` on producers that call `fx.emit(emailTask, …)`.

</Tab>

<Tab value="Competing">

Two different Flows on the same `once` Signal is the once-vs-broadcast mix-up — see
[Competing consumers](#competing-consumers-once-vs-broadcast). The bus would let only one claim
each message (race winner, not both, not a fixed Flow). The Manifest now fails **OKE1071**:

```typescript
export const fulfillA = on(
  orderPlaced,
  flow("orders.fulfillA", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);

export const fulfillB = on(
  orderPlaced,
  flow("orders.fulfillB", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);
```

**Consequence:** bind one Flow (replicas of that process still compete for claims), or switch the
declaration to [`signal.broadcast`](/docs/elements/signal/broadcast) so every Flow gets a copy.

</Tab>

<Tab value="Optional">

Hooks that may have no worker yet need `optional: true` or emit throws **OKE1240**:

```typescript
export const webhook = signal.once("hooks.inbound", {
  optional: true,
  schema: z.object({ id: z.string() }),
});
```

</Tab>

</Tabs>

## Emit

Before any worker runs, `fx.emit` validates `schema` (when set) and enrols the message in the
outbox. The call resolves on commit — not when the handler finishes.

```typescript title="src/flows/orders/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { orderPlaced } from "@/signals/orders";

export const create = on(
  http.post({
    in: z.object({ userId: z.string(), amount: z.number() }),
  }),
  flow({
    do: async ({ userId, amount }, fx) => {
      const orderId = fx.id();
      await fx.emit(orderPlaced, { orderId, amount, userId }, { key: userId });
      return { orderId };
    },
  }),
);
```

| Call                                | Records                 | Meaning                               |
| ----------------------------------- | ----------------------- | ------------------------------------- |
| `fx.emit(signal, payload?)`         | `emits`                 | Competing pool — no ordering          |
| `fx.emit(signal, payload, { key })` | `emits`                 | Per-key FIFO for that `(signal, key)` |
| `fx.deadLetters(signal)`            | `reads` `signal:<name>` | Inspect `dead` messages               |

`parentRunId` is stamped automatically from the producer run for Console trace chains — you do
not set it by hand in app code.

## Lease and reclaim

<Callout title="Detailed section">
  If you only need the default 30s lease, jump to Ordering. Claims set `lockedBy` +
  `leaseExpiresAt`. There is no background sweeper — reclaim happens lazily on the next claim after
  expiry.
</Callout>

| Concept          | Default                                        | Meaning                                      |
| ---------------- | ---------------------------------------------- | -------------------------------------------- |
| Visibility lease | `30_000` ms                                    | How long an inflight claim holds the message |
| Reclaim          | next claim after expiry                        | Another worker may take the same message     |
| Status path      | `pending` → `inflight` → `delivered` \| `dead` | Operator inspect / Console                   |

**Consequence:** treat every `once` handler as at-least-once. Prefer idempotent `do` bodies
(or durable steps for side effects that must not double-fire).

<Accordions>

<Accordion title="Claim">
  Eligible messages are `pending` unlocked, or `inflight` whose `leaseExpiresAt` has passed. Claim
  sets status to `inflight`, stamps `lockedBy` + `leaseExpiresAt`, and increments `attempts` before
  the handler runs — so a crash mid-handler leaves a reclaimable row.
</Accordion>

<Accordion title="Reclaim">
  There is no timeout daemon. After the lease expires, the next drain/claim may hand the same
  message to another (or the same) worker. Slow handlers that outlive the lease can overlap with a
  reclaim — keep side effects short or journal them.
</Accordion>

<Accordion title="Status path">
  Successful `do` → `delivered`. Exhausted retries with `deadLetter: true` → `dead`. With
  `deadLetter: false` → `delivered` and nothing in the DLQ.
</Accordion>

<Accordion title="Lease is not a declare option">
  The 30s default lives on the signal bus open options. App authors do not pass `leaseMs` on
  `signal.once(…)`. Tests and custom runtimes may override it when opening the bus.
</Accordion>

</Accordions>

## Ordering

<Callout title="Detailed section">
  Partition with `{key}` only when you need per-tenant / per-user FIFO. Unkeyed messages stay a
  competing pool and may run concurrently.
</Callout>

```typescript title="src/flows/orders/ship.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const shipOrder = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(fulfillOrder, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });
```

| Emit           | Concurrency             | Order             |
| -------------- | ----------------------- | ----------------- |
| No `key`       | Competing — may overlap | None              |
| Same `key`     | Serialized by lease     | FIFO for that key |
| Different keys | May overlap             | Independent       |

<Accordions>

<Accordion title="Lease is the lock">
  No two messages sharing `(signal, key)` are claimed while one holds an unexpired lease. When the
  first completes (or its lease expires and is reclaimed), the next same-key message becomes
  eligible — emission order is preserved for that key.
</Accordion>

<Accordion title="Unkeyed pool">
  Omit `key` for maximum parallelism across workers. There is no global FIFO across unkeyed messages
  — only competing claim exclusivity per message.
</Accordion>

</Accordions>

## Retries and dead letters

<Callout title="Detailed section">
  If you only need defaults (`retries: 3`, `deadLetter: true`), jump to Idempotency. Retries requeue
  immediately — there is no delay backoff between attempts.
</Callout>

<Accordions>

<Accordion title="Attempt budget">
  Handler invocations = `retries + 1`. On each failure the bus records a typed
  `{ code, message, at, attempt }` reason. When `attempts` exceeds `retries` and
  `deadLetter: true`, status becomes `dead`.
</Accordion>

<Accordion title="Failure reasons">
  Each failed attempt appends a `SignalFailureReason`:

| Field     | Meaning                            |
| --------- | ---------------------------------- |
| `code`    | Machine-readable failure code      |
| `message` | Human-readable detail              |
| `at`      | Epoch-ms when the attempt failed   |
| `attempt` | 1-based attempt number that failed |

The full history survives on the dead-letter entry for operator inspect.

</Accordion>

<Accordion title="deadLetter: false">
  Exhausted messages are marked `delivered` instead of entering the DLQ. Use when dropping is
  acceptable and you do not want operator replay.
</Accordion>

<Accordion title="fx.deadLetters">
  Requires a bound signal runtime and `effects.reads` including `signal:<name>`.
  Cross-signal reads throw **OKE1001**. Without a runtime:
  `fx.deadLetters requires a bound signal runtime`.

Returned entries include `payload`, `attempts`, `failures`, `key`, `createdAt`, and
`status: "dead"`.

</Accordion>

<Accordion title="Schema at emit">
  Invalid payloads fail at `fx.emit` with **OKE1250** before any worker runs. The Signal's
  `schema` is an **emit** contract (like Channel `schema` at `fx.send`) — distinct from HTTP
  invoke contracts on `http.*` / `call` / `mcp.tool`.

Cause: `"{resource}": {detail}`.

</Accordion>

<Accordion title="Orphan emit">
  Zero subscribers + `optional: false` → **OKE1240**.
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  Fix: add `on(signal, …)` or mark `{ optional: true }`.
</Accordion>

</Accordions>

## Idempotency

Lease reclaim and retries mean a handler can run more than once for the same message.

| Approach                    | When                                                     |
| --------------------------- | -------------------------------------------------------- |
| Idempotent `do`             | Side effects are safe to repeat (upsert, set-once flags) |
| `durable: true` + `fx.step` | Multi-step work that must not double-fire                |
| Short handlers              | Finish before the 30s lease so reclaim does not overlap  |

```typescript title="src/flows/payments/sync.ts"
import { on, flow } from "okengine";
import { syncPayment } from "@/signals/payments";

export const runSync = on(
  syncPayment,
  flow("payments.sync", {
    durable: true,
    do: async ({ chargeId }, fx) => {
      await fx.step("charge", async () => {
        await fx.call(applyCharge, { chargeId });
      });
      await fx.step("receipt", async () => {
        await fx.call(sendReceipt, { chargeId });
      });
    },
  }),
);
```

See [Durable Workflows](/docs/elements/flow/workflows).

## Troubleshooting

<Accordions>

<Accordion title="Worker never runs after emit">
  Confirm `on(signalHandle, flow)` uses the same declared handle (or the same name) and is adopted
  into the app. Check **OKE1240** if the emit itself threw. Live Signals are not workers — use Once
  or Broadcast.
</Accordion>

<Accordion title="Same message processed twice">
  Lease reclaim after a crash or slow handler is expected at-least-once physics. Make `do`
  idempotent, or journal side effects with `durable: true` + `fx.step`.
</Accordion>

<Accordion title="Both of two Flows ran on one once message">
  That is broadcast physics, not once. Two different Flow definitions on one `once` signal now fail
  **OKE1071** at boot. If you need every Flow to run, switch the declaration to
  [`signal.broadcast`](/docs/elements/signal/broadcast).
</Accordion>

<Accordion title="OKE1071 — once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should independently receive this event, or bind only one Flow. Replicas of one Flow
  are still one `on()` in source.
</Accordion>

<Accordion title="OKE1072 — Signal flow unnamed">
  Cause: `A signal flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("workers.email", { do }))`.
</Accordion>

<Accordion title="Messages stuck inflight">
  Wait for the 30s lease and the next drain/claim. There is no separate timeout daemon. A handler
  still running past the lease can overlap with a reclaim — shorten the work or journal it.
</Accordion>

<Accordion title="OKE1250 on emit">
  Cause: `"{resource}": {detail}`. Align the payload with `schema` — the worker never started.
</Accordion>

<Accordion title="OKE1240 on emit">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  Add a subscriber or set `{ optional: true }` on the Signal.
</Accordion>

<Accordion title="OKE1001 on fx.deadLetters">
  Cause: `Flow "…" reads "signal:…" without declaring it.`
  Add `effects.reads: ["signal:<name>"]` (or the matching `signalReadRef`) on that Flow.
</Accordion>

<Accordion title="deadLetter as a string name">
  `deadLetter` is `boolean` (default `true`). There is no separate named DLQ signal string — inspect
  with `fx.deadLetters(signal)`.
</Accordion>

<Accordion title="TypeError: retention is only valid with signal.live">
  Drop `retention` on `signal.once`, or switch the helper to `signal.live`.
</Accordion>

</Accordions>

## Learn more

- [Signal Overview](/docs/elements/signal) — delivery matrix and drivers
- [Broadcast](/docs/elements/signal/broadcast) — fan-out without leases
- [Live](/docs/elements/signal/live) — retained SSE tapes
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` next to Clock / CDC
- [Workflows](/docs/elements/flow/workflows) — `durable` + `fx.step` for idempotent side effects
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`
- [Errors](/docs/reference/errors) — OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1001

## Next

<Cards>
  <Card
    title="Broadcast"
    description="Ephemeral fan-out across subscribed Flows."
    href="/docs/elements/signal/broadcast"
  />
  <Card
    title="Live"
    description="Retained SSE tapes for browsers."
    href="/docs/elements/signal/live"
  />
  <Card
    title="Consumers"
    description="Signal workers, Clock jobs, and SQL CDC."
    href="/docs/elements/flow/consumers"
  />
</Cards>


# Files (/docs/elements/store/files)

The Files facet is a typed object bucket for uploads, exports, and media. Declare with `store.files(name)`, then call `fx.store(decl)`.

For developers storing blobs on okengine — put bytes, optionally derive image variants, serve keys from Flows.

<Callout title="The one rule">
  Pass the **declaration** into `fx.store(uploads)`. There is no `fx.store.files` namespace, no
  `presignPut`, and no `transform(…)` helper — use `put` / `get` / `putImage` / `image(…)`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a bucket

```typescript title="src/core.ts"
import { store } from "okengine";

export const uploads = store.files("uploads");
```

</Step>

<Step>
### Put and get in a Flow

```typescript title="src/flows/uploads/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const create = on(
  http.post({
    in: z.object({
      name: z.string().min(1),
      bytes: z.string(), // base64 for the demo
    }),
    out: z.object({ key: z.string() }),
  }),
  flow({
    do: async ({ name, bytes }, fx) => {
      const key = `uploads/${fx.id()}-${name}`;
      const data = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
      await fx.store(uploads).put(key, data);
      return { key };
    },
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/uploads \
  -H "content-type: application/json" \
  -d '{"name":"note.txt","bytes":"aGVsbG8="}'
```

Response:

```json
{
  "data": { "key": "uploads/…-note.txt" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Effects are inferred">
  Every `fx.store(uploads)` touch stamps `reads` / `writes` as `files:uploads` on the Flow. That
  powers the Manifest, Console, and least privilege — no hand-written effect lists.
</Callout>

## Progressive Patterns

From a bare put to list/delete, image variants, and the chainable pipeline:

<Tabs items={["Put / get", "List / delete", "putImage", "Pipeline"]}>

<Tab value="Put / get">

`put` accepts `Uint8Array` or UTF-8 `string`. `get` returns bytes or `null`:

```typescript title="src/flows/exports/create.ts"
import { on, flow, http } from "okengine";
import { uploads } from "@/core";

export const create = on(
  http.post(),
  flow({
    do: async (_, fx) => {
      const key = `exports/${fx.id()}.csv`;
      await fx.store(uploads).put(key, "id,title\n1,Ship\n");
      const bytes = await fx.store(uploads).get(key);
      return { key, bytes: bytes?.byteLength ?? 0 };
    },
  }),
);
```

</Tab>

<Tab value="List / delete">

Prefix `list` for browsing; `delete` returns whether an object was removed:

```typescript title="src/flows/avatars/route.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const cleanup = on(
  http.delete({
    in: z.object({ prefix: z.string().default("avatars/") }),
  }),
  flow({
    do: async ({ prefix }, fx) => {
      const keys = await fx.store(uploads).list(prefix);
      let removed = 0;
      for (const key of keys) {
        if (await fx.store(uploads).delete(key)) removed += 1;
      }
      return { listed: keys.length, removed };
    },
  }),
);
```

Prefer stable, printable-ASCII object keys. Avoid `..` and leading `/` — the
`fs` driver rejects those with `Invalid object key: …`.

</Tab>

<Tab value="putImage">

Write the original plus named derivatives and an optional ThumbHash LQIP data
URL on the **result** (not a stored object):

```typescript title="src/flows/photos/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const create = on(
  http.post({
    in: z.object({ bytes: z.string() }),
  }),
  flow({
    do: async ({ bytes }, fx) => {
      const key = `photos/${fx.id()}.jpg`;
      const data = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
      const result = await fx.store(uploads).putImage(key, data, {
        placeholder: true,
        variants: {
          thumb: {
            resize: [320],
            webp: { quality: 80 },
          },
        },
      });
      return {
        key: result.key,
        thumb: result.variants.thumb,
        placeholder: result.placeholder,
        width: result.meta.width,
        height: result.meta.height,
      };
    },
  }),
);
```

Variant key shape: `photos/x.jpg` + `thumb` + `webp` → `photos/x.thumb.webp`.

</Tab>

<Tab value="Pipeline">

Chain Bun.Image ops, then `put` / `bytes` / `blob` / `placeholder` / `metadata`:

```typescript title="src/flows/photos/card.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const card = on(
  http.post({
    in: z.object({ sourceKey: z.string() }),
  }),
  flow({
    do: async ({ sourceKey }, fx) => {
      const outKey = sourceKey.replace(/\.[^.]+$/, ".card.webp");
      await fx
        .store(uploads)
        .image(sourceKey)
        .resize(400, 400, { fit: "inside" })
        .webp({ quality: 80 })
        .put(outKey);
      return { outKey };
    },
  }),
);
```

`image(source)` accepts an object key **or** raw bytes. Resize `fit` is
`"fill"` \| `"inside"` (Bun.Image — not CSS `cover`).

</Tab>

</Tabs>

## Method Reference

`fx.store(filesDecl)`:

| Method     | Signature                    | Meaning                             |
| ---------- | ---------------------------- | ----------------------------------- |
| `put`      | `put(key, data)`             | Store bytes or UTF-8 string         |
| `get`      | `get(key)`                   | Read bytes, or `null` if missing    |
| `delete`   | `delete(key)`                | Remove; returns whether deleted     |
| `list`     | `list(prefix?)`              | List object keys (optional prefix)  |
| `image`    | `image(source, options?)`    | Chainable Bun.Image pipeline        |
| `putImage` | `putImage(key, data, opts?)` | Original + variants + optional LQIP |

Handle also exposes `ref` (`files:name`) and `driverId` (`memory` · `fs` · `s3`).

There is **no** public `presignPut`, `presignGet`, `copy`, `head`, or `transform`
alias on the fx handle.

## Object Keys

Keys are opaque strings the driver stores as-is. Pick a stable prefix + unique
suffix; keep SQL rows pointing at the key, not the bytes.

**Explicit key** — build the path in the Flow:

```typescript
const key = `avatars/${fx.auth.userId}/${fx.id()}.png`;
await fx.store(uploads).put(key, data);
```

**Content hash** — when the same bytes should collapse to one object, hash
yourself (SHA-256 hex) and put under that digest. The facet does not auto-
dedupe on `put`.

**Driver guards** — the `fs` driver throws on path escape:

```text
Invalid object key: ../secret
```

| Rule                   | Why                                                       |
| ---------------------- | --------------------------------------------------------- |
| Prefer printable ASCII | S3-compatible signed URLs are fragile with non-ASCII keys |
| No leading `/`         | Absolute paths are rejected on `fs`                       |
| No `..` segments       | Path escape is rejected on `fs`                           |
| Stable extensions      | MIME / Console kind inference uses the suffix             |

## Blob Operations

Each verb binds through `fx.store(decl)` inside `flow()`:

<Tabs items={["put", "get", "delete", "list"]}>

<Tab value="put">

Write bytes or a UTF-8 string. Overwrites an existing key:

```typescript title="src/flows/notes/[id]/attachment/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const create = on(
  http.post({
    in: z.object({
      id: z.string(),
      name: z.string().min(1),
      bytes: z.string(),
    }),
  }),
  flow({
    do: async ({ id, name, bytes }, fx) => {
      const key = `notes/${id}/${name}`;
      const data = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
      await fx.store(uploads).put(key, data);
      return { key };
    },
  }),
);
```

</Tab>

<Tab value="get">

Read bytes, or `null` when the key is missing — treat `null` as not found:

```typescript title="src/flows/notes/[id]/attachment/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const get = on(
  http.get({
    in: z.object({ id: z.string(), name: z.string() }),
    errors: { NotFound: z.object({ key: z.string() }) },
  }),
  flow({
    do: async ({ id, name }, fx) => {
      const key = `notes/${id}/${name}`;
      const bytes = await fx.store(uploads).get(key);
      if (!bytes) return fx.fail("NotFound", { key });
      return { key, size: bytes.byteLength };
    },
  }),
);
```

</Tab>

<Tab value="delete">

Remove one key. Returns `true` when an object was deleted, `false` if missing:

```typescript title="src/flows/notes/[id]/attachment/remove.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const remove = on(
  http.delete({
    in: z.object({ id: z.string(), name: z.string() }),
  }),
  flow({
    do: async ({ id, name }, fx) => {
      const key = `notes/${id}/${name}`;
      const removed = await fx.store(uploads).delete(key);
      return { key, removed };
    },
  }),
);
```

</Tab>

<Tab value="list">

List keys under an optional prefix. Use for Console browsing and admin Flows —
not as a relational index:

```typescript title="src/flows/notes/[id]/attachments.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const attachments = on(
  http.get({
    in: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }, fx) => {
      const keys = await fx.store(uploads).list(`notes/${id}/`);
      return { keys };
    },
  }),
);
```

</Tab>

</Tabs>

## putImage

<Callout title="Detailed section">
  If you only need the original + one thumb, jump to Progressive Patterns → putImage. This section
  covers options, variant key naming, and decode guards.
</Callout>

`putImage(key, data, opts?)` writes the **original** at `key`, then optional
named variants beside it, then optionally returns a ThumbHash LQIP **data URL**
on the result (the placeholder is not stored as an object).

```typescript
const result = await fx.store(uploads).putImage(key, data, {
  placeholder: true,
  variants: {
    thumb: { resize: [320], webp: { quality: 80 } },
    card: { resize: [800, 600, { fit: "inside" }], jpeg: { quality: 85 } },
  },
  maxPixels: 4096 * 4096,
  autoOrient: true,
});
```

<Accordions>

<Accordion title="putImage Options">

| Option        | Type                               | Default       | Meaning                               |
| ------------- | ---------------------------------- | ------------- | ------------------------------------- |
| `variants`    | `Record<string, ImageVariantSpec>` | omitted       | Named derivatives beside the original |
| `placeholder` | `boolean`                          | omitted       | ThumbHash **data URL** on the result  |
| `maxPixels`   | `number` \| `false`                | `4096 * 4096` | Decode ceiling; `false` disables      |
| `autoOrient`  | `boolean`                          | `true`        | Respect EXIF orientation              |

</Accordion>

<Accordion title="ImageVariantSpec">
  Each variant may set geometry and **exactly one** encode target:

| Field                                     | Meaning                                                     |
| ----------------------------------------- | ----------------------------------------------------------- |
| `resize`                                  | `[width]`, `[width, height]`, or `[width, height, options]` |
| `rotate` · `flip` · `flop`                | Geometry                                                    |
| `modulate`                                | `{ brightness?, saturation? }`                              |
| `jpeg` / `png` / `webp` / `heic` / `avif` | Encode — set **one** only                                   |

Resize options: `filter`, `fit` (`"fill"` \| `"inside"`), `withoutEnlargement`.

If no encode field is set, the variant keeps the source format when it is
`jpeg` / `png` / `webp` / `heic` / `avif`; decode-only sources (e.g. gif)
default to `webp`.

Setting two encode fields throws:

```text
files image variant: set only one of jpeg/png/webp/heic/avif (got jpeg, webp)
```

</Accordion>

<Accordion title="Variant key naming">
  Keys are derived from the original stem + variant name + encode extension
  (`jpeg` → `jpg`):

| Original            | Variant | Format | Stored key            |
| ------------------- | ------- | ------ | --------------------- |
| `photos/x.jpg`      | `thumb` | `webp` | `photos/x.thumb.webp` |
| `photos/x.png`      | `card`  | `jpeg` | `photos/x.card.jpg`   |
| `a/b/hero` (no ext) | `sm`    | `png`  | `a/b/hero.sm.png`     |

**Consequence:** `get(result.key)` is the original; read derivatives from
`result.variants.thumb` (and friends), not by guessing.

</Accordion>

<Accordion title="PutImageResult">

| Field         | Type                        | Meaning                           |
| ------------- | --------------------------- | --------------------------------- |
| `key`         | `string`                    | Original object key               |
| `meta`        | `{ width, height, format }` | Header metadata of the original   |
| `variants`    | `Record<string, string>`    | Variant name → object key         |
| `placeholder` | `string?`                   | ThumbHash data URL when requested |

</Accordion>

</Accordions>

## Image Pipeline

<Callout title="Detailed section">
  Prefer `putImage` when you want original + named variants in one call. Use `image(…)` when you
  need a one-off transform, metadata probe, or custom out key.
</Callout>


> putImage fans one upload into the original object plus named variant keys; optional placeholder returns a ThumbHash data URL, not another object.


Chain on `fx.store(uploads).image(source, options?)`:

| Step                                           | Meaning                          |
| ---------------------------------------------- | -------------------------------- |
| `.resize(w, h?, opts?)`                        | Resize (Bun fit options)         |
| `.rotate(degrees)` · `.flip()` · `.flop()`     | Geometry                         |
| `.modulate({ brightness?, saturation? })`      | Color                            |
| `.jpeg` / `.png` / `.webp` / `.heic` / `.avif` | Encode                           |
| `.metadata()`                                  | Width / height / format (header) |
| `.bytes()` · `.blob()`                         | Materialize                      |
| `.placeholder()`                               | ThumbHash data URL               |
| `.put(outKey)`                                 | Write the transformed bytes      |

Decode guards (`maxPixels`, `autoOrient`) pass as the second argument to
`image(…)`, same defaults as `putImage`.

<Accordions>

<Accordion title="Source resolution">
  `image(source)` accepts an object key **or** raw `Uint8Array`.

| Source       | Behavior                                                          |
| ------------ | ----------------------------------------------------------------- |
| `string` key | Loads via `get`; missing → `files image: object not found: {key}` |
| `Uint8Array` | Transforms in memory (no prior put required)                      |

```typescript
const webp = await fx
  .store(uploads)
  .image(rawBytes, { maxPixels: 2048 * 2048 })
  .resize(800)
  .webp({ quality: 80 })
  .bytes();
```

</Accordion>

<Accordion title="Encode & platform codecs">
  Call one encode step before materializing. HEIC / AVIF may fall back to WebP
  when the platform throws `ERR_IMAGE_FORMAT_UNSUPPORTED` (quality preserved
  when set).

```typescript
await fx
  .store(uploads)
  .image("photos/hero.jpg")
  .resize(1200, undefined, { fit: "inside", withoutEnlargement: true })
  .avif({ quality: 70 })
  .put("photos/hero.avif");
```

</Accordion>

<Accordion title="Decode guards">

| Option       | Default               | Meaning                                          |
| ------------ | --------------------- | ------------------------------------------------ |
| `maxPixels`  | `4096 * 4096` (16 MP) | Reject oversized width×height before pixel alloc |
| `autoOrient` | `true`                | Apply JPEG EXIF Orientation first                |

Exceeding the ceiling surfaces Bun’s `ERR_IMAGE_TOO_MANY_PIXELS`. Pass a higher
ceiling, or `maxPixels: false` only when you trust the source.

</Accordion>

</Accordions>

## Declare Options

| Option        | Type     | Default | Meaning                  |
| ------------- | -------- | ------- | ------------------------ |
| `description` | `string` | omitted | Console / Manifest label |

```typescript
export const uploads = store.files("uploads", {
  description: "User uploads and derived image variants",
});
```

## Drivers

| Driver   | Runs as                           | Best for                 |
| -------- | --------------------------------- | ------------------------ |
| `s3`     | S3-compatible (RustFS in Compose) | Dev + prod default       |
| `fs`     | Local filesystem root             | Single-node laptop paths |
| `memory` | Process map                       | Test default             |

Defaults: `s3` / `memory` / `s3` (dev / test / prod). Image pins (e.g.
RustFS) live under `images.store.files` — the driver id stays `s3`.

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // omit store.files to use defaults — pin only overrides
    // store: { files: { dev: "fs", test: "memory", prod: "s3" } },
  },
  images: {
    store: {
      files: "rustfs/rustfs:1.0.0-rc.5",
    },
  },
});
```

For `s3`, Compose / env typically supply `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`,
`S3_SECRET_ACCESS_KEY`, and optional `S3_REGION` / `S3_SESSION_TOKEN`. Without
`S3_ENDPOINT`, CreateBucket is skipped so real AWS is not auto-provisioned.

## Troubleshooting

<Accordions>

<Accordion title="No files driver configured">
  Boot needs `drivers.store.files` (or `DRIVER_DEFAULTS`). For `s3`, set the bucket / endpoint env
  your Compose stack expects (`S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`).
</Accordion>

<Accordion title="Unknown store ref for files">
  Cause: `Unknown store ref: …`. Import the module that calls `store.files(…)` before Flows run
  (starters load `@/core`).
</Accordion>

<Accordion title="Invalid object key">
  Cause: `Invalid object key: …` on the `fs` driver when the key starts with `/` or contains `..`.
  Use relative, non-escaping keys.
</Accordion>

<Accordion title="ERR_IMAGE_TOO_MANY_PIXELS">
  Decode exceeded `maxPixels` (default `4096 * 4096`). Pass a higher ceiling or `maxPixels: false`
  only when you trust the source.
</Accordion>

<Accordion title="files image: object not found">
  Cause: `files image: object not found: {key}`. `image(key)` loads via `get` first — put the
  original, or pass raw `Uint8Array` as the source.
</Accordion>

<Accordion title="files image variant: set only one of jpeg/png/…">
  Each `ImageVariantSpec` may set at most one encode field. Pick `webp` **or** `jpeg`, not both.
</Accordion>

<Accordion title="get returns null after putImage variant">
  Read the **variant key** from `result.variants.thumb`, not the original key. Naming is `{stem}.
  {variant}.{ext}` beside the original.
</Accordion>

<Accordion title="I expected presignPut / transform">
  Those helpers are not on the fx handle. Upload with `put` / `putImage`, and transform with
  `image(…).resize(…).webp(…).put(…)` (or `putImage` variants).
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — four facets; driver defaults
- [SQL](/docs/elements/store/sql) — relational data beside blob keys
- [HTTP](/docs/elements/flow/http) — routes that accept uploads
- [Vault](/docs/elements/vault) — credentials for S3 when you configure bindings
- [fx](/docs/reference/fx) — `fx.store(decl)`
- [Configuration](/docs/reference/configuration) — `drivers.store.files`

## Next

<Cards>
  <Card
    title="Search"
    description="BM25 ± LSH on SQL columns, plus store.index."
    href="/docs/elements/store/search"
  />
  <Card
    title="KV"
    description="Namespaced get/set with duration TTL."
    href="/docs/elements/store/kv"
  />
  <Card
    title="Store Overview"
    description="SQL · KV · files · index — one handle."
    href="/docs/elements/store"
  />
</Cards>


# Overview (/docs/elements/store)

Store is how your backend **holds data at rest**. Declare a facet once (`store.sql`, `store.kv`, `store.files`, `store.index`), then read and write through `fx.store(decl)` inside Flows.

For developers persisting domain data on okengine — one handle shape, drivers swapped by environment.

<Callout title="The one rule">
  World access goes through `fx.store(decl)`. Drivers are **protocol-named** in `oke.config.ts`
  (`postgres`, `redis`, `s3` — never vendors). Application code never imports a driver client
  directly.
</Callout>


> Four store facets — SQL tables, key-value cache, file blobs, and search index — behind one fx.store handle, drivers swapped per environment.


## Smallest Example

<Steps>

<Step>
### Declare a SQL store

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";

export const notes = store.schema.table("notes", {
  id: field.id().primaryKey(),
  title: field.text().notNull(),
  createdAt: field.timestamp().notNull().now(),
});

export const db = store.sql("app", { schema: { notes } });
```

</Step>

<Step>
### Read and write in a Flow

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      await fx.store(db).insert(notes).values({ id, title });
      return fx.json.create({ id, title });
    },
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/notes \
  -H "content-type: application/json" \
  -d '{"title":"Ship notes"}'
```

Response:

```json
{
  "data": { "id": "…", "title": "Ship notes" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Effects are inferred">
  Every `fx.store` touch is recorded on the Flow as `reads` / `writes` (`sql:app`, `kv:sessions`,
  `files:uploads`, …). That powers the Manifest, Console, cache invalidation, and least privilege —
  no hand-written effect lists.
</Callout>

## Progressive Patterns

Same `fx.store(decl)` from a row insert to cache, blobs, and hybrid search:

<Tabs items={["SQL", "KV", "Files", "Search"]}>

<Tab value="SQL">

Declare tables with `store.schema.table` + `field.*`, then use the session handle:

```typescript title="src/flows/notes/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

Prefer [`store.resource`](/docs/elements/store/sql#resources) when you want five CRUD Flows in one factory.

</Tab>

<Tab value="KV">

Pass the **declaration** into `fx.store` — there is no `fx.store.kv` namespace. TTL is a duration string:

```typescript title="src/flows/sessions/put.ts"
import { call } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const putSession = call("sessions.put", {
  in: z.object({ token: z.string(), userId: z.string() }),
  do: async ({ token, userId }, fx) => {
    await fx.store(sessions).set(`session:${token}`, userId, "1h");
    return { ok: true };
  },
});
```

```typescript title="src/core.ts"
import { store } from "okengine";

export const sessions = store.kv("sessions");
```

</Tab>

<Tab value="Files">

Bucket I/O is `put` / `get` / `delete` / `list`. Images use `putImage` or the chainable `image(…)` pipeline:

```typescript title="src/flows/avatars/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { uploads } from "@/core";

export const create = on(
  http.post({
    in: z.object({ bytes: z.string() }),
  }),
  flow({
    do: async ({ bytes }, fx) => {
      const key = `avatars/${fx.id()}.png`;
      const data = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
      await fx.store(uploads).put(key, data);
      return { key };
    },
  }),
);
```

</Tab>

<Tab value="Search">

Mark text with `.searchable()` for BM25; chain `.embed()` only when you want semantic LSH:

```typescript
const { data, meta } = await fx.store(db).search(articles, {
  query: "refund policy",
  limit: 20,
});
```

Full surface: [Search](/docs/elements/store/search).

</Tab>

</Tabs>

## Facet Reference

| Facet | Declare                    | `fx.store` handle                                        | Resource ref | Default drivers (dev / test / prod)   |
| ----- | -------------------------- | -------------------------------------------------------- | ------------ | ------------------------------------- |
| SQL   | `store.sql(name, opts?)`   | select / insert / update / page / search / …             | `sql:name`   | `postgres` / `pglite` / `postgres`    |
| KV    | `store.kv(name, opts?)`    | `get` · `set` · `delete` · `list` · `ttlMs`              | `kv:name`    | `redis` / `memory` / `redis`          |
| Files | `store.files(name, opts?)` | `put` · `get` · `delete` · `list` · `image` · `putImage` | `files:name` | `s3` / `memory` / `s3`                |
| Index | `store.index(name, opts?)` | vector or Meilisearch (by `driverId`)                    | `index:name` | pin explicitly — no three-env default |

Built-in hybrid search (`fx.store(db).search`) lives on the **SQL** handle — see [Search](/docs/elements/store/search). `store.index` is the optional external engine facet.

## Per-environment drivers

Standard starters inherit `DRIVER_DEFAULTS`. Pin only when you diverge; vendor choice lives under `images`, not driver ids:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // omit store.* to use defaults — pin only overrides
  },
  images: {
    store: {
      sql: "postgres:18-alpine",
      kv: "redis:8-alpine",
      files: "rustfs/rustfs:1.0.0-rc.5",
    },
  },
});
```

| Facet   | Protocol ids                                    | Best for                                   |
| ------- | ----------------------------------------------- | ------------------------------------------ |
| `sql`   | `postgres` · `pglite` · `memory`                | Domain tables, RLS, live queries, BM25     |
| `kv`    | `redis` · `memory` (+ durable SQL via `oke_kv`) | Sessions, caches, short-lived locks        |
| `files` | `s3` · `fs` · `memory`                          | Uploads, exports, image variants           |
| `index` | `memory` · `pgvector` · `meilisearch`           | Hosted FTS / ANN outside the primary table |

## The Capabilities of Store

<Cards>
  <Card
    title="SQL"
    description="Schema tables, field tags, store.resource CRUD, RLS, and list grammar."
    href="/docs/elements/store/sql"
  />
  <Card
    title="KV"
    description="Namespaced get/set with duration TTL — redis honors it; memory ignores it."
    href="/docs/elements/store/kv"
  />
  <Card
    title="Files"
    description="Object buckets, putImage variants, and chainable Bun.Image transforms."
    href="/docs/elements/store/files"
  />
  <Card
    title="Search"
    description="Built-in BM25 ± LSH on SQL columns, plus optional store.index engines."
    href="/docs/elements/store/search"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title="No sql/kv/files/index driver configured">
  Boot needs a driver for that facet. Check `drivers.store.*` (or rely on `DRIVER_DEFAULTS`) and
  that Compose / env URLs match the protocol.
</Accordion>

<Accordion title="Unknown store ref / Unknown kv ref">
  `fx.store` received a declaration that was never registered — import the module that calls
  `store.sql` / `store.kv` / … before the Flow runs (starters load `@/core`).
</Accordion>

<Accordion title='fx.store("sql:…"): no store runtime'>
  The app has not booted a store runtime. Run through `oke` / `oke dev` so drivers bind; do not call
  `fx.store` outside a Flow invoke.
</Accordion>

<Accordion title="OKE1110 — domain table not found">
  Cause: `domain table not found — migrations have not been applied.` Run `oke db push` (dev) or
  `oke db migrate` against that environment.
</Accordion>

</Accordions>

## Learn more

- [SQL](/docs/elements/store/sql) — `store.schema.table`, `store.resource`, RLS
- [HTTP · Resources](/docs/elements/flow/http#resources) — mount CRUD + live
- [fx](/docs/reference/fx) — `fx.store`, `fx.json.*`
- [Configuration](/docs/reference/configuration) — `drivers.store.*`
- [Errors](/docs/reference/errors) — OKE1110 and friends

## Next

<Cards>
  <Card
    title="SQL"
    description="Design tables, classify columns, and mount store.resource."
    href="/docs/elements/store/sql"
  />
  <Card
    title="Clock"
    description="Schedules, intervals, and durable sleep."
    href="/docs/elements/clock"
  />
  <Card
    title="HTTP"
    description="REST verbs, CRUD mounts, and live SSE on Flow."
    href="/docs/elements/flow/http"
  />
</Cards>


# KV (/docs/elements/store/kv)

The KV facet is a typed namespace for sessions, cache entries, OTPs, and other short-lived keys.
Declare with `store.kv(name)`, then read and write through `fx.store(decl)` inside Flows.

For developers caching or locking on okengine — one get/set surface; Redis in Docker, memory in tests.

<Callout title="The one rule">
  Pass the **declaration** into `fx.store(sessions)`. There is no `fx.store.kv`
  namespace. TTL is a duration **string** (`"15m"`), not `{ ttl: "15m" }`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a namespace

```typescript title="src/core.ts"
import { store } from "okengine";

export const sessions = store.kv("sessions");
```

</Step>

<Step>
### Set and get in a Flow

```typescript title="src/flows/sessions/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const create = on(
  http.post({
    in: z.object({ token: z.string(), userId: z.string() }),
  }),
  flow({
    do: async ({ token, userId }, fx) => {
      await fx.store(sessions).set(`session:${token}`, userId, "1h");
      const cached = await fx.store(sessions).get(`session:${token}`);
      return { userId: cached };
    },
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X POST http://localhost:6530/sessions \
  -H "content-type: application/json" \
  -d '{"token":"abc","userId":"u1"}'
```

Response:

```json
{
  "data": { "userId": "u1" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Import the declaration">
  `store.kv(…)` must run when the app loads — export it from a module Flows import (starters:
  `@/core`). A Flow that only types the name as a string never opens a namespace; the handle needs
  the decl object.
</Callout>

## Progressive Patterns

From a plain set to TTL, list/delete, and durable SQL-backed namespaces:

<Tabs items={["Set / get", "TTL", "List / delete", "Durable"]}>

<Tab value="Set / get">

Values may be strings, numbers, or JSON-serializable objects:

```typescript title="src/flows/prefs/[userId]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const get = on(
  http.get({
    in: z.object({ userId: z.string() }),
  }),
  flow({
    do: async ({ userId }, fx) => {
      const prefs = await fx.store(sessions).get(`user:${userId}:prefs`);
      return { prefs: prefs ?? null };
    },
  }),
);
```

Missing keys return `undefined` / driver nullish — treat as a cache miss.

</Tab>

<Tab value="TTL">

Third argument to `set` is optional. Format: `^(\d+)(ms|s|m|h|d)$`:

```typescript title="src/flows/otp/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const create = on(
  http.post({
    in: z.object({ userId: z.string() }),
  }),
  flow({
    do: async ({ userId }, fx) => {
      const code = "482193";
      await fx.store(sessions).set(`otp:${userId}`, code, "5m");
      const remaining = await fx.store(sessions).ttlMs(`otp:${userId}`);
      return { code, ttlMs: remaining };
    },
  }),
);
```

**Consequence:** redis honors TTL; the `memory` driver records expiry for
`ttlMs` but **does not delete** expired keys on `get` — see [TTL Physics](#ttl-physics).

</Tab>

<Tab value="List / delete">

```typescript title="src/flows/sessions/route.ts"
import { on, flow, http } from "okengine";
import { sessions } from "@/core";

export const clear = on(
  http.delete(),
  flow({
    do: async (_, fx) => {
      const keys = await fx.store(sessions).list("session:");
      for (const key of keys) {
        await fx.store(sessions).delete(key);
      }
      return { removed: keys.length };
    },
  }),
);
```

`list` is for Console browsing and admin Flows — not a substitute for a SQL
index when you need relational queries.

</Tab>

<Tab value="Durable">

`durable: true` persists the namespace in SQL (`oke_kv` on `DATABASE_URL`) —
not Redis. Distinct from Flow `durable: true`:

```typescript title="src/core.ts"
import { store } from "okengine";

export const featureFlags = store.kv("feature-flags", {
  durable: true,
  description: "Flags that must survive Redis flushes",
});
```

Requires a configured SQL driver. Durable namespaces do **not** support
driver-level `eval` (Gate rate Lua stays on Redis). See
[Durable Namespaces](#durable-namespaces).

</Tab>

</Tabs>

## Method Reference

`fx.store(kvDecl)`:

| Method   | Signature               | Purpose                         | Side effect |
| -------- | ----------------------- | ------------------------------- | ----------- |
| `get`    | `get(key)`              | Read value (or miss)            | Read        |
| `set`    | `set(key, value, ttl?)` | Write; optional duration string | Write       |
| `delete` | `delete(key)`           | Remove; returns whether deleted | Write       |
| `list`   | `list(prefix?)`         | List keys (optional prefix)     | Read        |
| `ttlMs`  | `ttlMs(key)`            | Remaining ms, or `null`         | Read        |

Handle also exposes `ref` (`kv:name`) and `driverId`
(`memory` · `redis` · `postgres` · `pglite` when durable).

There is **no** public `incr`, `setNx`, `del` alias, or `eval` on the fx handle.
Gate rate strategies use Redis `eval` internally — not application Flows.

## Namespaces

Every `store.kv(name)` registers a Manifest resource `kv:name`. The string you
pass is the namespace id — keys inside it are relative to that namespace.

**Cache namespace** (default) — opens on `drivers.store.kv` (`redis` / `memory`):

```typescript
export const sessions = store.kv("sessions");
// ref → "kv:sessions"
```

**Durable namespace** — opens on the shared SQL connection (`oke_kv` table):

```typescript
export const drafts = store.kv("drafts", {
  durable: true,
  description: "Compose drafts that survive Redis recreate",
});
```

**Global under tenancy** — skip the `{tenantId}:` prefix when `gate.auth.tenant`
is on:

```typescript
export const sharedFlags = store.kv("shared-flags", {
  tenantScoped: false,
});
```

## Methods

Each verb binds through `fx.store(decl)` inside `flow({ do })`:

<Tabs items={["get", "set", "delete", "list", "ttlMs"]}>

<Tab value="get">

Read a key. Misses return `undefined` (or driver nullish):

```typescript title="src/flows/sessions/[token]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const get = on(
  http.get({
    in: z.object({ token: z.string() }),
    errors: { NotFound: z.object({ token: z.string() }) },
  }),
  flow({
    do: async ({ token }, fx) => {
      const userId = await fx.store(sessions).get(`session:${token}`);
      if (userId === undefined || userId === null) {
        return fx.fail("NotFound", { token });
      }
      return { userId };
    },
  }),
);
```

</Tab>

<Tab value="set">

Write a value. Pass a duration string as the third argument for TTL:

```typescript title="src/flows/sessions/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const create = on(
  http.post({
    in: z.object({ token: z.string(), userId: z.string() }),
  }),
  flow({
    do: async ({ token, userId }, fx) => {
      await fx.store(sessions).set(`session:${token}`, userId, "1h");
      return { ok: true as const };
    },
  }),
);
```

Omit the third argument for a key with no expiry. Redis JSON-stringifies
objects; memory keeps the value as-is.

</Tab>

<Tab value="delete">

Remove a key. Returns `true` when a key was deleted:

```typescript title="src/flows/sessions/[token]/remove.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const remove = on(
  http.delete({
    in: z.object({ token: z.string() }),
  }),
  flow({
    do: async ({ token }, fx) => {
      const removed = await fx.store(sessions).delete(`session:${token}`);
      return { removed };
    },
  }),
);
```

</Tab>

<Tab value="list">

List keys, optionally filtered by prefix. Results are sorted; the returned
strings are the **logical** keys (no driver prefix, no tenant prefix):

```typescript title="src/flows/drafts/list.ts"
import { on, flow, http } from "okengine";
import { drafts } from "@/core";

export const list = on(
  http.get(),
  flow({
    do: async (_, fx) => {
      const keys = await fx.store(drafts).list();
      const items = [];
      for (const key of keys) {
        const value = await fx.store(drafts).get(key);
        items.push({ id: key, value });
      }
      return items;
    },
  }),
);
```

On Redis, `list` uses `SCAN` — never `KEYS *`. A client without SCAN throws
rather than scanning the whole instance.

</Tab>

<Tab value="ttlMs">

Remaining lifetime in milliseconds, or `null` when the key has no expiry
(or the backend cannot report one). Does not create or delete the key:

```typescript title="src/flows/otp/[userId]/ttl.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";

export const ttl = on(
  http.get({
    in: z.object({ userId: z.string() }),
  }),
  flow({
    do: async ({ userId }, fx) => {
      const ttlMs = await fx.store(sessions).ttlMs(`otp:${userId}`);
      return { ttlMs };
    },
  }),
);
```

</Tab>

</Tabs>

## Declare Options

Second argument to `store.kv(name, options)`:

| Option         | Type      | Default                   | Meaning                                                           |
| -------------- | --------- | ------------------------- | ----------------------------------------------------------------- |
| `description`  | `string`  | omitted                   | Console / Manifest label                                          |
| `durable`      | `true`    | omitted                   | Persist in SQL `oke_kv` (not Redis)                               |
| `tenantScoped` | `boolean` | `true` when tenancy is on | Prefix keys with `{tenantId}:`; set `false` for global namespaces |

## Durable Namespaces

<Callout title="Detailed section">
  If you only need a Redis/memory cache, skip this section. `durable: true` is a different backend —
  SQL table `oke_kv` on the shared store.sql connection — not a Redis persistence mode.
</Callout>

Use durable KV when values must survive Redis flushes or Compose recreate
(feature flags, compose drafts, webhook registrations). Cache namespaces stay
on `redis` / `memory`.

```typescript title="src/core.ts"
import { store } from "okengine";

export const drafts = store.kv("drafts", {
  durable: true,
  description: "Compose drafts",
});
```

```typescript title="src/flows/drafts/[id]/save.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { drafts } from "@/core";

export const save = on(
  http.put("/drafts/:id", {
    in: z.object({
      id: z.string().min(1),
      title: z.string().min(1),
      body: z.string().optional(),
    }),
  }),
  flow({
    do: async ({ id, title, body }, fx) => {
      await fx.store(drafts).set(id, { title, body: body ?? "" }, "7d");
      return { id };
    },
  }),
);
```

<Accordions>

<Accordion title="Storage model">
  Rows live in engine-owned table `oke_kv` (`namespace`, `key`, `value` JSONB,
  `expires_at`, `updated_at`). Schema is ensured at open — not part of
  `oke db push` domain migrations.

| Fact                     | Detail                                                                                |
| ------------------------ | ------------------------------------------------------------------------------------- |
| Connection               | Shared primary SQL URL (`DATABASE_URL` / project SQL)                                 |
| `driverId` on the handle | `postgres` or `pglite` (the SQL driver), not `redis`                                  |
| TTL                      | `expires_at` filtered on `get` / `list` / `ttlMs`; expired rows purged on write paths |
| Distinct from            | Flow `durable: true` (step journaling)                                                |

</Accordion>

<Accordion title="What durable does not do">
  Durable namespaces reject Lua/`eval`. Gate rate buckets and any Redis-only
  atomic scripts stay on a cache (`redis` / `memory`) namespace.

```text
oke store: durable store.kv does not support eval
```

Without a SQL driver configured:

```text
oke store: durable store.kv needs a configured sql driver
```

</Accordion>

<Accordion title="When to prefer durable vs redis">

| Need                                            | Prefer                                         |
| ----------------------------------------------- | ---------------------------------------------- |
| Sessions, OTP, short locks, Gate rates          | `redis` / `memory` cache namespace             |
| Flags / drafts that must outlive Redis recreate | `durable: true`                                |
| Relational queries / joins                      | [SQL](/docs/elements/store/sql), not KV `list` |

</Accordion>

</Accordions>

## TTL Physics

<Callout title="Detailed section">
  TTL is best-effort and driver-dependent. The same `set(key, value, "30m")` call does not mean
  identical expiry semantics on every backend.
</Callout>


> KV TTL physics: redis drains the TTL and expires the key; memory ignores TTL and the key stays.


| Driver                 | TTL on `set(…, "30m")`                                               |
| ---------------------- | -------------------------------------------------------------------- |
| `redis`                | Applied (`SET … EX`); key expires                                    |
| `memory`               | `ttlMs` may report remaining time; **`get` does not expire** the key |
| Durable SQL (`oke_kv`) | Applied on read (`expires_at` filter)                                |

Valid units match `^(\d+)(ms|s|m|h|d)$`. Invalid strings parse to `0` (no
useful expiry). Prefer explicit units: `"30s"`, `"15m"`, `"1h"`, `"1d"`.

Redis converts the duration to whole seconds (`EX`); sub-second `"ms"` values
ceil to at least `1` second when TTL is set.

## Tenant Scoping

When [`gate.auth.tenant`](/docs/elements/gate/tenancy) is on, KV namespaces
default to tenant-prefixed keys. Application code still uses logical keys —
the runtime rewrites:

| Op                                 | Physical key (when scoped)                                |
| ---------------------------------- | --------------------------------------------------------- |
| `get` / `set` / `delete` / `ttlMs` | `{tenantId}:{key}`                                        |
| `list(prefix?)`                    | scans `{tenantId}:{prefix…}`; strips the prefix on return |

```typescript
// With tenancy on and fx.tenant.id = "acme":
await fx.store(sessions).set("session:abc", "u1", "1h");
// Physical redis key: oke:kv:sessions:acme:session:abc
```

Opt out for genuinely global namespaces:

```typescript
export const catalog = store.kv("catalog", { tenantScoped: false });
```

Missing tenant on a scoped op throws **OKE1810** (`TENANT_REQUIRED`):

```text
This operation needs a tenant, but none is resolved for this request.
```

Fix: switch tenant (`fx.auth.switchTenant`), send a signed `tid` claim, or pass
the tenant header — see [Tenancy](/docs/elements/gate/tenancy).

## Key Prefixes & Effects

Two prefix layers sit under the logical key you pass to `fx.store`:

| Layer                | Shape                                                                  | Who sees it                  |
| -------------------- | ---------------------------------------------------------------------- | ---------------------------- |
| Tenant (when scoped) | `{tenantId}:`                                                          | Stripped from `list` results |
| Driver               | Redis `oke:kv:{ns}:` · memory `{ns}:` · durable SQL `namespace` column | Console / Redis tools        |

Compiler effects stamp `kv:name` on reads (`get` · `list` · `ttlMs`) and
writes (`set` · `delete`). Declare the same refs when you hand-write
`effects` on a Flow.

## Drivers

| Driver                                       | Runs as                          | Best for                           |
| -------------------------------------------- | -------------------------------- | ---------------------------------- |
| `redis`                                      | Docker Redis / Valkey-compatible | Dev + prod default                 |
| `memory`                                     | Process map                      | Test default                       |
| Durable (`postgres` / `pglite` via `oke_kv`) | Shared SQL URL                   | Namespaces that must outlive Redis |

Defaults: `redis` / `memory` / `redis` (dev / test / prod). Pin overrides in
`oke.config.ts`; image pins stay under `images.store.kv` — the driver id stays
`redis` for Valkey / Dragonfly / Upstash wire-compatible servers.

Gate rate strategies inherit `drivers.store.kv` (no separate `drivers.gate`).

## Troubleshooting

<Accordions>

<Accordion title="No kv driver configured">
  Boot needs `drivers.store.kv` (or `DRIVER_DEFAULTS`). Check `REDIS_URL` when the id is `redis`.
</Accordion>

<Accordion title="oke store: durable store.kv needs a configured sql driver">
  `durable: true` opens `oke_kv` on the SQL connection. Configure `drivers.store.sql` and
  `DATABASE_URL` (or the project SQL URL).
</Accordion>

<Accordion title="oke store: durable store.kv does not support eval">
  Durable namespaces reject Lua/`eval`. Keep rate-limit buckets on a Redis namespace; use durable KV
  for flags and similar durable maps.
</Accordion>

<Accordion title="Unknown kv ref">
  Cause: `Unknown kv ref: …`. The declaration was never imported. Export `store.kv(…)` from a module
  the app loads before Flows run (starters: `@/core`).
</Accordion>

<Accordion title="TTL set but key still readable (memory)">
  Expected on the `memory` driver — see [TTL Physics](#ttl-physics). Use `redis` in Docker when
  expiry must be enforced on read.
</Accordion>

<Accordion title='I passed { ttl: "15m" } and it did not typecheck'>
  Third argument is a bare string: `set(key, value, "15m")`. There is no options bag on `set`.
</Accordion>

<Accordion title="OKE1810 — TENANT_REQUIRED on kv ops">
  Cause: `This operation needs a tenant, but none is resolved for this request.` Tenancy is on and
  the namespace is tenant-scoped. Resolve `fx.tenant.id`, or set `tenantScoped: false` for global
  keys.
</Accordion>

<Accordion title="redis kv.list: client lacks SCAN">
  Browse refused rather than `KEYS *`. Use a Redis client that supports `SCAN` (Bun.RedisClient
  does). Prefer prefix filters in admin Flows.
</Accordion>

<Accordion title="I expected incr / setNx / fx.store.kv">
  Those helpers are not on the public handle. Use `get` / `set` / `delete` / `list` / `ttlMs` via
  `fx.store(decl)`. Gate rates use Redis Lua internally.
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — four facets; driver defaults
- [SQL](/docs/elements/store/sql) — durable KV shares the SQL driver
- [Gate · Tenancy](/docs/elements/gate/tenancy) — `tenantScoped` and `fx.tenant.id`
- [Gate](/docs/elements/gate) — rate buckets use Redis internally
- [fx](/docs/reference/fx) — `fx.store(decl)`
- [Configuration](/docs/reference/configuration) — `drivers.store.kv`
- [Errors](/docs/reference/errors) — OKE1810

## Next

<Cards>
  <Card
    title="Files"
    description="Object buckets, putImage variants, and image pipelines."
    href="/docs/elements/store/files"
  />
  <Card
    title="SQL"
    description="Relational tables and store.resource."
    href="/docs/elements/store/sql"
  />
  <Card
    title="Store Overview"
    description="SQL · KV · files · index — one handle."
    href="/docs/elements/store"
  />
</Cards>


# Search (/docs/elements/store/search)

Built-in hybrid search ranks ordinary SQL rows. Mark text columns with `.searchable()` for BM25, chain `.embed()` when you also want semantic LSH, then call `fx.store(db).search` from a Flow.

It runs on PostgreSQL 15+ (`postgres` / `pglite`) with GIN + B-tree — **no extra extensions**. `store.index` (Meilisearch / pgvector) stays for engines you host separately.

For developers ranking rows on okengine — mark columns, query with `fx.store(db).search`, read `data` + `meta`.

<Callout title="The one rule">
  `.searchable()` is free BM25 math on the table. `.embed()` is a separate chain that starts an
  async, costed AI pipeline — never a boolean next to `weight`. Bare `.searchable()` needs no `ai`
  element.
</Callout>

## Smallest Example

<Steps>

<Step>
### Mark columns and bind a route

```typescript title="src/db/schema.decl.ts"
import { field, store } from "okengine";

export const articles = store.schema.table("articles", {
  id: field.text().primaryKey(),
  title: field.text().searchable({ weight: 2 }).notNull(),
  body: field.text().searchable(),
});

export const db = store.sql("app", { schema: { articles } });
```

```typescript title="src/flows/articles/search.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { articles, db } from "@/schema";

export const search = on(
  http.get({
    in: z.object({ q: z.string() }),
  }),
  flow({
    do: async ({ q }, fx) => {
      const result = await fx.store(db).search(articles, {
        query: q,
        limit: 20,
      });
      return fx.json.ok(result.data, { meta: result.meta });
    },
  }),
);
```

</Step>

<Step>
### Push schema and call

```bash
oke db push
curl -X GET "http://localhost:6530/articles/search?q=refund" \
  -H "accept: application/json"
```

Response:

```json
{
  "data": [{ "id": "a1", "title": "Refund policy", "body": "…" }],
  "error": null,
  "meta": { "engine": ["bm25"], "limit": 20 }
}
```

</Step>

</Steps>

<Callout title="BM25 needs no AI">
  The smallest loop is full-text only. Chain `.embed()` when you want semantic neighbors — see
  [Progressive Patterns](#progressive-patterns). `.embed()` without a resolvable `model` + `dims`
  fails loud (`SearchConfigError`).
</Callout>

## Progressive Patterns

From BM25-only ranking to hybrid LSH, fusion, and opt-in rerank:

<Tabs items={["BM25", "Hybrid", "Fusion", "Rerank"]}>

<Tab value="BM25">

Title carries twice the BM25F field weight of body. No `ai` element, no `.embed()`:

```typescript title="src/db/schema.decl.ts"
import { field, store } from "okengine";

export const articles = store.schema.table("articles", {
  id: field.text().primaryKey(),
  title: field.text().searchable({ weight: 2 }).notNull(),
  body: field.text().searchable(),
});
```

`weight` must be a finite number **> 0** (default `1`). Only `text` / `varchar` / `char`.

</Tab>

<Tab value="Hybrid">

Set the project default once. Bare `.embed()` inherits; per-field `{ model?, dims? }` overrides:

```typescript
oke({
  store: {
    search: {
      embed: { model: embedder, dims: 768 },
    },
  },
});

export const articles = store.schema.table("articles", {
  id: field.text().primaryKey(),
  title: field.text().searchable({ weight: 2 }).notNull(),
  body: field.text().searchable().embed(),
  caption: field.text().searchable().embed({ model: captionEmbedder }),
});
```

**Consequence:** schema columns with `.embed()` stamp `lsh` on `meta.engine`. Fusion
runs only when query + stored vectors produce LSH hits (`meta.fusedBy`).

</Tab>

<Tab value="Fusion">

Default fusion is Reciprocal Rank Fusion with **k = 60**. Weighted fusion is opt-in:

```typescript
const result = await fx.store(db).search(articles, {
  query: q,
  fuse: { strategy: "rrf", k: 60 },
  // fuse: { strategy: "weighted", weights: { bm25: 0.4, vector: 0.6 } },
  limit: 20,
});
```

RRF is the robust default. Weighted scores are min-max normalized per list first.

</Tab>

<Tab value="Rerank">

Rerank is **off** until you pass a prompt. It never silently calls `fx.ask`:

```typescript
const result = await fx.store(db).search(articles, {
  query: q,
  rerank: { model: "search.rerank" },
  limit: 20,
});
```

The prompt receives `{ query, docs }` and should return `{ rankedIds }`. Missing or empty
`rankedIds` leaves the fused order unchanged.

</Tab>

</Tabs>

## Field Reference

| Chain           | Signature                   | Default                 | AI? | Meaning                                                                    |
| --------------- | --------------------------- | ----------------------- | --- | -------------------------------------------------------------------------- |
| `.searchable()` | `.searchable({ weight? })`  | `weight: 1`             | No  | BM25F field weight (applied to term frequency **before** saturation / IDF) |
| `.embed()`      | `.embed({ model?, dims? })` | inherit project default | Yes | Async embedding + LSH on that searchable column                            |

`.embed()` without a prior `.searchable()` throws:

```text
.embed() requires a prior .searchable() on the same field — weight is free SQL math; embed is an async AI pipeline
```

| `oke({ store: { search: { embed } } })` | Type                             | Meaning                            |
| --------------------------------------- | -------------------------------- | ---------------------------------- |
| `embed.model`                           | `ai.model` handle or name string | Required when the block is present |
| `embed.dims`                            | positive integer                 | Required when the block is present |

Per-field values win when set. Bare `.embed()` with neither a field option nor a project
default throws `SearchConfigError`:

```text
SearchConfigError: articles.body: .embed() needs model and dims — set oke({ store: { search: { embed: { model, dims } } } }) or pass them on .embed({ model, dims })
```

## Query Options

`fx.store(db).search(table, options)` — `query` / `fuse` / `rerank` are search-specific.
The rest is the same list grammar used by `store.resource` lists and `liveQuery`.

| Option        | Type                           | Default                           | Meaning                                                                |
| ------------- | ------------------------------ | --------------------------------- | ---------------------------------------------------------------------- |
| `query`       | `string`                       | _(required)_                      | Relevance string (BM25 ± LSH). **Not** list-grammar `?search=`         |
| `fuse`        | `{ strategy?, k?, weights? }`  | RRF, `k: 60`                      | Rank fusion when LSH hits exist                                        |
| `rerank`      | `false` \| `{ model }`         | `false`                           | Opt-in `fx.ask` after fusion                                           |
| `limit`       | `number`                       | `20`                              | Page size (capped by `maxLimit`)                                       |
| `maxLimit`    | `number`                       | `100`                             | Cap on `limit` / `?limit=`                                             |
| `filter`      | `"all"` \| columns \| `"none"` | `"none"`                          | Whitelist for `filterInput` column filters                             |
| `filterInput` | object                         | `{}`                              | PostgREST-shaped filters (`status: "eq.active"`, `limit`, `cursor`, …) |
| `mode`        | `"cursor"` \| `"offset"`       | `"offset"` unless `cursor` is set | Pagination                                                             |
| `cursor`      | columns                        | `[]`                              | Keyset columns                                                         |
| `order`       | column scope                   | cursor columns, else `"all"`      | `?order=`                                                              |
| `search`      | column scope                   | `"none"`                          | List-grammar `?search=` / `?q=` LIKE — unused by hybrid `query`        |

Result:

| Field          | Meaning                                                                                         |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `data`         | Ranked rows (PK + searchable text + stored embeddings when present)                             |
| `meta.engine`  | `["bm25"]` or `["bm25", "lsh"]` — from schema (`.embed()` columns), not from whether fusion ran |
| `meta.fusedBy` | `"rrf"` or `"weighted"` — omitted when there are no vector hits                                 |
| `meta.rrfK`    | RRF damping constant — omitted unless RRF ran                                                   |
| `meta.limit`   | Effective page size                                                                             |

```typescript title="src/flows/articles/search.ts"
const { data, meta } = await fx.store(db).search(articles, {
  query: "refund policy",
  filter: [articles.status],
  filterInput: { status: "eq.active" },
  limit: 20,
});
```

**Consequence:** `filter: "none"` (the default) rejects unknown column keys in
`filterInput` — `unknown list param "status"`. Pass `filter: [articles.status]` or
`filter: "all"` before sending column filters.

## Two Surfaces

These share English words and are **not** the same API. Mixing them up ranks the
wrong way (or does not rank at all).

| Surface              | Call                                                     | Parameter           | Physics                                                          |
| -------------------- | -------------------------------------------------------- | ------------------- | ---------------------------------------------------------------- |
| List / live grammar  | `store.resource` lists, `liveQuery`                      | `?search=` or `?q=` | Substring `LIKE %term%` on a column whitelist (default `"none"`) |
| Hybrid SQL search    | `fx.store(db).search(table, { query })`                  | `query`             | BM25 (± LSH) relevance ranking                                   |
| Index / embed helper | `fx.search(embed, query)` / `fx.store(indexDecl).search` | vector or text      | External `store.index` engine — not this table                   |

Side by side:

```typescript
// 1) List grammar — substring filter (NOT BM25)
// GET /articles?search=refund&status=eq.active
await liveQuery(fx, articles, input, {
  search: [articles.title],
  filter: [articles.status],
});

// 2) Hybrid search — BM25 / LSH relevance (NOT LIKE)
await fx.store(db).search(articles, {
  query: "refund policy",
  filter: [articles.status],
  filterInput: { status: "eq.active", limit: "20" },
});
```

`search()` reuses the list grammar for ordinary column filters, limit, and cursor
pagination. Only `query`, `fuse`, and `rerank` are search-specific.

## Declaring Columns

Each searchable column binds with `field.text().searchable(…)` (optionally `.embed()`):

<Tabs items={["Searchable", "Embed", "Project default", "Weights"]}>

<Tab value="Searchable">

Mark the text fields you want ranked. No AI, no shadow vector columns:

```typescript title="src/db/schema.decl.ts"
import { field, store } from "okengine";

export const articles = store.schema.table("articles", {
  id: field.id().primaryKey(),
  title: field.text().searchable({ weight: 2 }).notNull(),
  body: field.text().searchable().notNull(),
  status: field.text().notNull(),
  createdAt: field.timestamp().notNull().now(),
});

export const db = store.sql("app", { schema: { articles } });
```

After `oke db push`, the table gets a generated `tsvector` + GIN index for candidate
retrieval (`plainto_tsquery('english', …)`).

</Tab>

<Tab value="Embed">

Chain `.embed()` **after** `.searchable()`. Pass `{ model, dims }` on the field, or
inherit the project default:

```typescript title="src/db/schema.decl.ts"
import { field, store, ai } from "okengine";

const embedder = ai.model("embedder", {
  provider: "openai-compatible",
  model: "nomic-embed-text",
});

export const articles = store.schema.table("articles", {
  id: field.id().primaryKey(),
  title: field.text().searchable({ weight: 2 }).notNull(),
  body: field.text().searchable().embed({ model: embedder, dims: 768 }),
});
```

**Consequence:** writers never call `fx.embed` — a system CDC flow embeds after commit.
See [Embedding Pipeline](#embedding-pipeline).

</Tab>

<Tab value="Project default">

Stamp `oke({ store: { search: { embed } } })` once so bare `.embed()` inherits:

```typescript title="src/core.ts"
import { ai, oke } from "okengine";

const embedder = ai.model("embedder", {
  provider: "openai-compatible",
  model: "nomic-embed-text",
});

oke({
  store: {
    search: {
      embed: { model: embedder, dims: 768 },
    },
  },
});
```

```typescript title="src/db/schema.decl.ts"
body: field.text().searchable().embed(), // inherits model + dims
caption: field.text().searchable().embed({ model: captionEmbedder }), // dims still inherit
alt: field.text().searchable().embed({ model: captionEmbedder, dims: 384 }),
```

Project block without `dims` or `model` fails extract:

```text
extract: oke({ store: { search: { embed } } }) requires dims: positive integer
extract: oke({ store: { search: { embed } } }) requires model (ai.model handle or name string)
```

</Tab>

<Tab value="Weights">

`weight` multiplies term frequency **before** Robertson–Zaragoza saturation
(**k1 = 1.2**, **b = 0.75**). A `weight: 2` title is not “twice the final score”:

```typescript
title: field.text().searchable({ weight: 2 }).notNull(),
body: field.text().searchable(), // weight: 1
tags: field.text().searchable({ weight: 0.5 }),
```

Invalid weights throw at declare time:

```text
searchable({ weight }) must be a finite number > 0 (got …)
```

</Tab>

</Tabs>

## Running Search

Bind a Flow, pass `query`, return `data` + `meta`:

<Tabs items={["BM25 route", "Filtered", "Hybrid response", "Cursor"]}>

<Tab value="BM25 route">

Full HTTP loop with envelope `meta` from the search result:

```typescript title="src/flows/articles/search.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { articles, db } from "@/schema";

export const search = on(
  http.get({
    in: z.object({ q: z.string().min(1) }),
  }),
  flow({
    do: async ({ q }, fx) => {
      const result = await fx.store(db).search(articles, {
        query: q,
        limit: 20,
      });
      return fx.json.ok(result.data, { meta: result.meta });
    },
  }),
);
```

```bash
curl -X GET "http://localhost:6530/articles/search?q=refund+policy" \
  -H "accept: application/json"
```

</Tab>

<Tab value="Filtered">

Whitelist columns, then pass PostgREST-shaped filters in `filterInput`:

```typescript title="src/flows/articles/search.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { articles, db } from "@/schema";

export const search = on(
  http.get({
    in: z.object({
      q: z.string().min(1),
      status: z.string().optional(),
    }),
  }),
  flow({
    do: async ({ q, status }, fx) => {
      const result = await fx.store(db).search(articles, {
        query: q,
        filter: [articles.status],
        filterInput: {
          ...(status ? { status: `eq.${status}` } : {}),
          limit: "20",
        },
        limit: 20,
      });
      return fx.json.ok(result.data, { meta: result.meta });
    },
  }),
);
```

Filter ops match resource lists: `eq` · `neq` · `gt` · `gte` · `lt` · `lte` ·
`like` · `ilike` · `in` · `is` (+ `not.` prefix).

</Tab>

<Tab value="Hybrid response">

When `.embed()` columns exist and vectors score, `meta` gains fusion fields:

```json
{
  "data": [{ "id": "a1", "title": "Refund policy", "body": "…" }],
  "error": null,
  "meta": {
    "engine": ["bm25", "lsh"],
    "fusedBy": "rrf",
    "rrfK": 60,
    "limit": 20
  }
}
```

If query embedding is not wired at boot, ranking stays lexical — `fusedBy` is
omitted even when `meta.engine` lists `"lsh"` from the schema.

</Tab>

<Tab value="Cursor">

Keyset pagination reuses list-grammar `cursor` / `filterInput`:

```typescript
const result = await fx.store(db).search(articles, {
  query: q,
  mode: "cursor",
  cursor: [articles.createdAt, articles.id],
  filterInput: { cursor: lastCursor, limit: "20" },
  limit: 20,
});
```

**Consequence:** keyset pages stay stable under inserts the same way resource
lists do — prefer cursor when the corpus grows under concurrent writes.

</Tab>

</Tabs>

## Embedding Pipeline

<Callout title="Detailed section">
  If you only need BM25, skip this. Writer Flows **never** call `fx.embed` — a system-owned durable
  CDC flow embeds after commit. A just-written row may be missing from semantic results for a short
  interval.
</Callout>

`.embed()` starts an async pipeline: after the row commits, the runtime embeds
changed text, packs an LSH bucket (64 hyperplanes), and stores both beside the row.

```typescript
body: field.text().searchable().embed(), // inherits oke({ store: { search: { embed } } })
```

<Accordions>

<Accordion title="What your Flow does not do">
  App writers insert and update as usual. They do **not** gain `effects.embeds`.
  The operator-plane flow `_oke_search_embed_<table>` owns `fx.embed` + journaled
  `fx.step`. Deletes drop the row (and its shadow columns) — nothing extra to run.
</Accordion>

<Accordion title="Eventual consistency">
  BM25 candidates update with the generated `tsvector` on write. LSH neighbors
  wait on the embed step.

**Consequence:** lexical hits can appear before semantic ones. That window is
intentional. Do not poll `fx.embed` from the writer to “close” it.

</Accordion>

<Accordion title="What push creates">
  `oke db push` adds search DDL when columns are `.searchable()` / `.embed()`:

| Object                                | Role                                                       |
| ------------------------------------- | ---------------------------------------------------------- |
| Generated `tsvector` + GIN            | BM25 candidate retrieval (`plainto_tsquery('english', …)`) |
| `real[]` embedding column             | Stored vector per `.embed()` field                         |
| `bigint` LSH column + B-tree          | Stored SimHash pack (query ranks by Hamming, not equality) |
| Corpus stats / DF / hyperplane tables | IDF, average length, stable LSH planes                     |

Hyperplanes insert once (`ON CONFLICT DO NOTHING`) and are **never** regenerated.
Changing `dims` on a live column leaves the old planes in place — you will hit a
length `SearchConfigError` until those rows are rebuilt.

</Accordion>

<Accordion title="Missing AI / missing dims">
  `.embed()` without a configured `ai` element:

```text
SearchConfigError: articles.body: .embed() requires a configured ai element (ai.model / ai.embed). Remove .embed() for BM25-only search, or declare an embedding model.
```

Project default block without `dims` or `model`:

```text
extract: oke({ store: { search: { embed } } }) requires dims: positive integer
extract: oke({ store: { search: { embed } } }) requires model (ai.model handle or name string)
```

</Accordion>

</Accordions>

## Fusion

<Callout title="Detailed section">
  If you only need BM25, skip this. Fusion runs only when both BM25 and LSH hit lists exist — then
  ranks are fused and truncated to `limit`.
</Callout>

Candidates are oversampled (`max(limit × 5, 50)`, capped at 500) before fusion.

| `fuse.strategy`   | Formula                           | Default knobs                                                                |
| ----------------- | --------------------------------- | ---------------------------------------------------------------------------- |
| `"rrf"` (default) | Σ `1 / (k + rank)`                | `k: 60` (Cormack, Clarke, Büttcher — SIGIR 2009; MAP flat for k ∈ [20, 100]) |
| `"weighted"`      | min-max per list, then linear mix | `weights.bm25` / `weights.vector` default `0.5` each                         |

<Accordions>

<Accordion title="RRF (default)">
  Reciprocal Rank Fusion ignores raw score scales — only ranks matter:

```typescript
await fx.store(db).search(articles, {
  query: q,
  fuse: { strategy: "rrf", k: 60 },
  limit: 20,
});
```

`meta.fusedBy` is `"rrf"` and `meta.rrfK` echoes the damping constant when RRF ran.

</Accordion>

<Accordion title="Weighted">
  Opt-in linear mix after per-list min-max normalization:

```typescript
await fx.store(db).search(articles, {
  query: q,
  fuse: {
    strategy: "weighted",
    weights: { bm25: 0.4, vector: 0.6 },
  },
  limit: 20,
});
```

`meta.fusedBy` is `"weighted"`; `rrfK` is omitted.

</Accordion>

<Accordion title="BM25F constants">
  BM25F uses Robertson–Zaragoza saturation: **k1 = 1.2**, **b = 0.75**. Field
  `weight` multiplies term frequency *before* that saturation.

LSH uses **64** hyperplanes packed into a `bigint`. Query-time retrieval ranks
rows by Hamming distance (`bit_count` of XOR) and keeps the oversampled nearest
(`max(limit × 5, 50)`, cap 500), then cosine-reranks in process.

</Accordion>

<Accordion title="No vector hits">
  When LSH produces no scored neighbors (or query embedding is unwired), order is pure BM25.
  `fusedBy` / `rrfK` are omitted. `meta.engine` may still list `"lsh"` if the table declared
  `.embed()` columns.
</Accordion>

</Accordions>

## Rerank

Rerank is a second, optional pass after fusion. Declare a prompt, then pass its
name — never enabled by default:

```typescript title="src/ai/search-rerank.ts"
import { ai } from "okengine";
import { z } from "zod";

const reranker = ai.model("reranker", {
  provider: "openai-compatible",
  model: "llama3.1",
});

export const searchRerank = reranker.prompt("search.rerank", {
  in: z.object({
    query: z.string(),
    docs: z.array(z.object({ id: z.string(), text: z.string(), score: z.number() })),
  }),
  out: z.object({ rankedIds: z.array(z.string()) }),
  budget: { maxCostPerCall: 0.02 },
});
```

```typescript title="src/flows/articles/search.ts"
const result = await fx.store(db).search(articles, {
  query: q,
  rerank: { model: "search.rerank" },
  limit: 20,
});
```

The runtime calls `fx.ask` with `{ query, docs }` where each doc’s `text` is the
concatenated searchable fields. Return `{ rankedIds }` in preferred order.
Missing or empty `rankedIds` keeps the fused order.

**Consequence:** budgets on the prompt (`maxCostPerCall`) are the cost guardrail —
search itself does not invent a second limit.

## Backfill

`oke db push` applies shadow columns and indexes. It **never** silently backfills
a large table. Run the rebuild yourself:

```bash
oke db search-backfill <table> [--batch=32]
```

| Flag      | Default      | Meaning                                                                    |
| --------- | ------------ | -------------------------------------------------------------------------- |
| `<table>` | _(required)_ | SQL table name in the Manifest                                             |
| `--batch` | `32`         | Rows per page (embed batches pause between pages for provider rate limits) |
| `--env`   | config env   | `dev` \| `test` \| `prod`                                                  |

Doctor warns when searchable columns land on existing rows:

```text
table "articles" has searchable/embed columns on existing data — run `oke db search-backfill articles` (never auto on push)
```

<Accordions>

<Accordion title="Low corpus warning">
  Corpus stats below **100** rows print:

```text
[oke db search-backfill] warn: table "articles" has only 12 rows — IDF/BM25 corpus statistics are not meaningful yet (threshold 100)
```

Ranking still runs; IDF is unstable until the corpus grows past 100 rows.

</Accordion>

<Accordion title="Unknown table">
  Cause:

```text
search-backfill: table "articles" not found in Manifest
```

Use the Manifest SQL table name (the string passed to `store.schema.table`), not
a Flow name.

</Accordion>

<Accordion title="search-backfill needs a live SQL URL">
  Cause: `oke db search-backfill: no DATABASE_URL / OKE_STORE_SQL_URL / OKE_PGLITE_URL — cannot open SQL`.
  Set a connection URL (compose `.env.local` or process env), then:

```bash
oke db search-backfill articles --batch 500
```

The CLI opens SQL, extracts the Manifest, and calls `runSearchBackfill`. Never auto-runs on push.

</Accordion>

</Accordions>

## External Indexes

<Callout title="Not this capability">
  `store.index` is a separate facet with its own drivers. Use it when you need typo-tolerant HTTP
  search or a hosted vector engine — not as a substitute for `.searchable()` on the primary table.
</Callout>


> Index modes: vector drivers take embedding vectors and return cosine scores; meilisearch takes a text query and returns relevance scores. TypeScript keeps the two apart.


Index stays `memory` until you set `drivers.store.index` explicitly — there is no
silent fallback to Meilisearch or pgvector.

<Tabs items={["Meilisearch", "pgvector"]}>

<Tab value="Meilisearch">

Omit `{ dims }` — dimensions select a vector driver. Search takes a **string**:

```typescript title="src/db/indexes.ts"
import { store } from "okengine";

export const articlesIndex = store.index("articles");
```

```typescript
const idx = fx.store(articlesIndex);
if (idx.driverId === "meilisearch") {
  const { hits } = await idx.search(q, { topK: 20 });
  return hits;
}
```

See [Meilisearch](/docs/recipes/meilisearch) for `oke.config.ts` pins and keys.

</Tab>

<Tab value="pgvector">

Pass `{ dims }`. Search takes a **vector** (usually from `fx.embed`):

```typescript title="src/db/indexes.ts"
import { store } from "okengine";

export const articlesIndex = store.index("articles", { dims: 768 });
```

```typescript
const idx = fx.store(articlesIndex);
if (idx.driverId === "pgvector" || idx.driverId === "memory") {
  const vector = await fx.embed(embedder, q);
  return await idx.search(vector, 20);
}
```

`memory` is the same vector shape (cosine) for tests. Driver ids:
`memory` · `pgvector` · `meilisearch`.

</Tab>

</Tabs>

## Measured latency & recall (G17)

Headline numbers from the live-Postgres G17 gate (`OKE_TEST_POSTGRES=1`, Bun 1.4.2, Apple M4, Postgres 16). Trend-analysis only — not an SLA. Full tables and EXPLAIN live in the repo load-test report: `src/bench/REPORT.md` (G17).

### When to stay on BM25 vs add LSH vs use an external index

| Corpus size | BM25 (text) p50 | LSH/hybrid p50 | LSH precision@10 vs exact cosine | Guidance                                                                                 |
| ----------- | --------------- | -------------- | -------------------------------- | ---------------------------------------------------------------------------------------- |
| ≤10k        | ~1–5 ms         | ~1–9 ms        | 0.10–0.17 vector                 | Built-in hybrid is fine for ranking UX; LSH is not HNSW                                  |
| ~100k       | ~21 ms          | ~31–34 ms      | 0.017 vector                     | Expect tens of ms; re-`EXPLAIN` after `ANALYZE`                                          |
| ~1M         | ~0.3 s          | ~0.4 s         | 0.017 vector / 0 hybrid          | Prefer external `store.index` (pgvector / Meilisearch) for semantic recall at this scale |

**Honest LSH note:** after the Hamming-rank fix (2026-09-11), vector precision@10 vs exact cosine is 0.17 at 1k, 0.10 at 10k, ~0.02 at 100k–1M on the G17 hash-bag corpus — a smooth drop, not the v0.19.0 zero collapse. Still **not** HNSW. Prefer BM25-only or `store.index` when semantic recall matters.

**Query plan:** at N=100k, `EXPLAIN (ANALYZE, BUFFERS)` is a UNION of GIN **Bitmap Index Scan** and a **Parallel Seq Scan** + top-N heapsort on Hamming distance (~15 ms). Hamming-rank cannot use the LSH B-tree. Capture your own plan on production data.

**Backfill:** `oke db search-backfill` is interrupt-safe to re-run (G17 killed at 2k/50k embeds, resumed to completion in ~29 s on that table).

## Requirements

Built-in hybrid search is a SQL-facet capability — not a fourth store facet.

| Need       | Requirement                                            |
| ---------- | ------------------------------------------------------ |
| Driver     | `postgres` or `pglite` (PostgreSQL 15+)                |
| Extensions | **None** — GIN + B-tree only                           |
| BM25       | At least one `.searchable()` text column               |
| LSH        | `.embed()` + configured `ai` + `model` / `dims`        |
| Backfill   | Explicit `oke db search-backfill` (never auto on push) |

## Troubleshooting

<Accordions>

<Accordion title="SearchConfigError — .embed() needs model and dims">
  Cause: `SearchConfigError: {table}.{column}: .embed() needs model and dims — set oke({ store: { search: { embed: { model, dims } } } }) or pass them on .embed({ model, dims })`.
  Set the project default, or pass `{ model, dims }` on that field.
</Accordion>

<Accordion title="SearchConfigError — .embed() requires a configured ai element">
  Cause: `.embed() requires a configured ai element (ai.model / ai.embed). Remove .embed() for
  BM25-only search, or declare an embedding model.` Drop `.embed()` for BM25-only, or declare
  `ai.model` / `ai.embed`.
</Accordion>

<Accordion title=".embed() requires a prior .searchable()">
  Cause: `.embed() requires a prior .searchable() on the same field — weight is free SQL math; embed
  is an async AI pipeline`. Chain `.searchable()` first: `field.text().searchable().embed()`.
</Accordion>

<Accordion title="searchable() is only valid on text / varchar / char">
  Cause: `field.{type}().searchable() is only valid on text / varchar / char columns`. Hybrid search
  is a text pipeline — do not mark integers or timestamps.
</Accordion>

<Accordion title="searchable({ weight }) must be a finite number > 0">
  Cause: `searchable({ weight }) must be a finite number > 0 (got …)`.
  Omit `weight` for `1`, or pass a positive finite number.
</Accordion>

<Accordion title="search(): table must be a store.schema.table()">
  Cause: `search(): table must be a store.schema.table() declaration with .searchable() columns`.
  Pass the schema table, not a string name. At least one column needs `.searchable()`.
</Accordion>

<Accordion title="SearchConfigError — no .searchable() columns">
  Cause: `SearchConfigError: {table}.*: no .searchable() columns on this table`. Mark the text
  fields you want ranked before calling `.search()`.
</Accordion>

<Accordion title="unknown list param / unfilterable column">
  Default `filter: "none"` rejects extra keys (`unknown list param "status"`). Whitelist with
  `filter: […]` or `filter: "all"`. `limit` / `cursor` / `order` are always parsed.
</Accordion>

<Accordion title="missing hyperplanes">
  Cause: `missing hyperplanes — run oke db search-backfill or ensure push applied search DDL`. Push
  (or backfill) must run after `.embed()` is declared so LSH planes exist.
</Accordion>

<Accordion title="embedding length !== declared dims">
  Cause: `query embedding length {n} !== declared dims {d}` / `stored embedding length {n} !==
  declared dims {d}`. Model output, field `dims`, and stored planes must match. Changing `dims` on a
  live column does not regenerate planes.
</Accordion>

<Accordion title="Just-written row missing from semantic results">
  Expected. Writer Flows do not embed. Wait for the CDC embed step, or rank with BM25 (`meta.engine`
  includes `"bm25"` immediately after the `tsvector` write).
</Accordion>

<Accordion title="meta.engine lists lsh but fusedBy is missing">
  Schema has `.embed()` columns, so `meta.engine` includes `"lsh"`. Fusion only runs when query +
  stored vectors produce scored neighbors. Check that `embedQuery` is wired at boot and that
  backfill / CDC wrote embeddings.
</Accordion>

<Accordion title="Doctor says run search-backfill">
  Cause: `table "{name}" has searchable/embed columns on existing data — run oke db search-backfill{" "}
  {name} (never auto on push)`. Push created shadow columns; corpus stats / embeddings still need an
  explicit rebuild.
</Accordion>

<Accordion title="IDF/BM25 corpus statistics are not meaningful yet">
  Cause: `[oke db search-backfill] warn: table "{name}" has only {n} rows — IDF/BM25 corpus
  statistics are not meaningful yet (threshold 100)`. Ranking still runs; IDF is unstable until the
  corpus grows past 100 rows.
</Accordion>

<Accordion title="CLI prints programmatic API / live SQL not wired">
  Cause: `oke db search-backfill: use the programmatic runSearchBackfill(conn, manifest, {table})
  API, or pass --table via CLI once a live SQL connection is wired for this project.` The subcommand
  is registered (`--batch` default 32) and never auto-runs on push. Wire a live SQL connection, then
  rerun.
</Accordion>

<Accordion title="I passed ?search= and got LIKE, not BM25">
  Resource lists and `liveQuery` treat `?search=` / `?q=` as substring `LIKE`. Hybrid ranking is
  `fx.store(db).search(table, {query})` — see [Two Surfaces](#two-surfaces).
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — four facets; `fx.store` handles
- [SQL](/docs/elements/store/sql) — `store.schema.table`, `field.*`, list grammar
- [HTTP · Resources](/docs/elements/flow/http#resources) — list grammar (`?search=` LIKE, filters, cursor)
- [AI](/docs/elements/ai) — `ai.model`, `ai.prompt`, `fx.ask` / `fx.embed`
- [fx](/docs/reference/fx) — `fx.store(db).search` is a SQL read; `fx.search(embed, query)` is the index helper
- [Meilisearch](/docs/recipes/meilisearch) — `store.index` full-text driver
- [Configuration](/docs/reference/configuration) — `drivers.store.index` (`memory` · `pgvector` · `meilisearch`)

## Next

<Cards>
  <Card
    title="SQL"
    description="Schema tables, field helpers, and store.resource CRUD."
    href="/docs/elements/store/sql"
  />
  <Card
    title="AI"
    description="Embedding models, prompts, and fx.embed."
    href="/docs/elements/ai"
  />
  <Card
    title="Store Overview"
    description="SQL · KV · files · index — one handle."
    href="/docs/elements/store"
  />
</Cards>


# SQL (/docs/elements/store/sql)

The SQL facet is how you declare relational tables and read or write them from Flows. Schemas use `store.schema.table` + `field.*`; I/O goes through `fx.store(db)`.

For developers modeling domain data on okengine — mark columns, push schema, query with the session handle (or mount `store.resource`).

<Callout title="The one rule">
  Declare tables with `store.schema.table`. Touch them only via `fx.store(db)` — `select` / `insert`
  / `update` / `delete` / `page` / `search`. There is no `fx.store(db).query.…` Relational Query
  Builder on the handle.
</Callout>

## Smallest Example

<Steps>

<Step>
### Define a table and bind the store

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";

export const posts = store.schema.table("posts", {
  id: field.id().primaryKey(),
  title: field.text().notNull(),
  authorId: field.text().notNull(),
  publishedAt: field.timestamp(),
  createdAt: field.timestamp().notNull().now(),
});

export const db = store.sql("app", { schema: { posts } });
```

</Step>

<Step>
### Query in a Flow

```typescript title="src/flows/posts/list.ts"
import { on, flow, http } from "okengine";
import { db, posts } from "@/schema";

export const list = on(
  http.get(),
  flow({
    do: async (_, fx) => {
      return await fx.store(db).page(posts, {
        orderBy: [posts.createdAt],
        limit: 20,
      });
    },
  }),
);
```

</Step>

<Step>
### Push schema and call

```bash
oke db push
curl -X GET http://localhost:6530/posts -H "accept: application/json"
```

Response:

```json
{
  "data": [{ "id": "…", "title": "…", "authorId": "…", "publishedAt": null, "createdAt": "…" }],
  "error": null
}
```

</Step>

</Steps>

<Callout title="Pathless declare">
  Starters often pass the whole decl module: `store.sql("app", {schema})` where `schema` is `import
  * as schema from "@/db/schema.decl"`. Named keys and the exported table handles are equivalent.
</Callout>

## Progressive Patterns

Explore SQL from a bare table to classified columns, CRUD factory, and RLS:

<Tabs items={["Schema", "Classify", "Resource", "RLS"]}>

<Tab value="Schema">

`field.id()` is `text` + auto id on insert. Use `.okid()` on an existing text
column when you want the same default without the sugar factory:

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";

export const notes = store.schema.table("notes", {
  id: field.id().primaryKey(),
  title: field.text().notNull(),
  body: field.text().notNull(),
  archivedAt: field.timestamp(),
  createdAt: field.timestamp().notNull().now(),
});
```

</Tab>

<Tab value="Classify">

`.pii()` / `.sensitive()` / `.retain(duration)` tag columns for posture audits
and AI egress masking:

```typescript
export const users = store.schema.table("users", {
  id: field.id().primaryKey(),
  email: field.text().unique().pii().notNull(),
  passwordHash: field.text().sensitive().notNull(),
  lastLoginAt: field.timestamp().retain("90d"),
});
```

**Consequence:** AI prompts and Console redaction read these tags — they are not
decorative comments.

</Tab>

<Tab value="Resource">

`store.resource` builds five Flows. Mount with
[`http.resource`](/docs/elements/flow/http#resources) — the factory registers no routes:

```typescript title="src/flows/notes/resource.ts"
import { store } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const notesResource = store.resource(db, notes, {
  in: z.object({ title: z.string().min(1), body: z.string() }),
  out: z.object({
    id: z.string(),
    title: z.string(),
    body: z.string(),
  }),
  list: {
    search: [notes.title],
    filter: [notes.archivedAt],
    cursor: [notes.createdAt, notes.id],
  },
});
```

```typescript title="src/flows/notes/index.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { notesResource } from "./resource";

export const notesApi = on(http.resource("/notes", notesResource.all()).gate(member));
```

</Tab>

<Tab value="RLS">

Pass extras as the **third argument array**. Helpers take SQL/JS column
**names** (strings) and stamp `oke.gate()` / `oke.user()` / `oke.has_scope()`
predicates:

```typescript title="src/db/schema.decl.ts"
import { store, field } from "okengine";

export const tasks = store.schema.table(
  "tasks",
  {
    id: field.id().primaryKey(),
    owner: field.text().notNull(),
    title: field.text().notNull(),
  },
  [
    store.schema.policy.gate("member", { for: "select" }),
    store.schema.policy.owner("owner", { for: "all" }),
  ],
);
```

When `gate.auth.tenant` is on, every table needs
`store.schema.policy.tenant(…)` or `store.schema.unscoped()` — extract fails
otherwise.

</Tab>

</Tabs>

## Field Reference

Factories mirror Drizzle pg-core names. Chain modifiers after the factory:

| Factory                                                  | Infers             | Notes                                                                                                                                                         |
| -------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `field.id()`                                             | `string`           | `text` + auto id on insert (`≡ field.text().okid()`)                                                                                                          |
| `field.okid()`                                           | `string`           | Pins OK ID on a text column                                                                                                                                   |
| `field.text()` / `varchar` / `char`                      | `string`           | Optional `{ length?, enum? }`                                                                                                                                 |
| `field.boolean()`                                        | `boolean`          |                                                                                                                                                               |
| `field.smallint()` / `integer()`                         | `number`           |                                                                                                                                                               |
| `field.bigint({ mode? })`                                | `number` (default) | `mode`: `"number"` · `"bigint"` · `"string"`                                                                                                                  |
| `field.serial()` / `smallserial()` / `bigserial()`       | `number`           | NOT NULL by SQL physics                                                                                                                                       |
| `field.numeric()` / `decimal()`                          | `string` (default) | Exact decimal; `{ precision?, scale?, mode? }`                                                                                                                |
| `field.real()` / `doublePrecision()`                     | `number`           | Float4 / float8                                                                                                                                               |
| `field.json()` / `jsonb()`                               | generic            | Narrow with `field.json<MyShape>()`                                                                                                                           |
| `field.uuid()`                                           | `string`           |                                                                                                                                                               |
| `field.time()` / `timestamp()` / `date()` / `interval()` | see type           | `timestamp` / `date` default to `Date`; `{ mode: "string" }` for ISO. On write, finite epoch-ms numbers (e.g. `fx.clock.now()`) coerce to `Date` for Postgres |
| `field.point()` / `line()`                               | tuple              | `{ mode: "xy" }` / `"abc"` for objects                                                                                                                        |
| `field.bytea()`                                          | `Buffer`           |                                                                                                                                                               |
| `field.inet()` / `cidr()` / `macaddr()` / `macaddr8()`   | `string`           |                                                                                                                                                               |

| Chain                                                    | Meaning                                                                                         |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `.primaryKey()` · `.notNull()` · `.unique()`             | Constraints                                                                                     |
| `.default(v)` · `.defaultFn(fn)` · `.now()` · `.okid()`  | Defaults                                                                                        |
| `.pii()` · `.sensitive()` · `.retain(duration)`          | Classification tags                                                                             |
| `.searchable({ weight? })` · `.embed({ model?, dims? })` | Hybrid search — see [Search](/docs/elements/store/search)                                       |
| `.as(sqlName)` · `.describe(text)` · `.type<T>()`        | SQL name, docs, TS override                                                                     |
| `.references(() => col, actions?)`                       | FK (`onDelete` / `onUpdate`: `cascade` · `restrict` · `no action` · `set null` · `set default`) |

## Declaring Stores

Bind tables once with `store.sql(name, options)`. The name becomes the resource
suffix (`sql:app`).

| Option        | Type                    | Default    | Meaning                                             |
| ------------- | ----------------------- | ---------- | --------------------------------------------------- |
| `schema`      | table map or module     | _(omit)_   | Tables for push / migrate / `fx.store`              |
| `classify`    | `table → column → tags` | `{}`       | Explicit tags; wins over schema-derived on conflict |
| `description` | `string`                | store name | Console / docs label                                |

**Named keys** — pass the tables you care about:

```typescript
export const db = store.sql("app", { schema: { posts, notes } });
```

**Module star** — starters re-export everything from the decl file:

```typescript title="src/core.ts"
import { store } from "okengine";
import * as schema from "@/db/schema.decl";

export const db = store.sql("app", { schema });
```

**Consequence:** every `fx.store(db)` touch stamps `reads` / `writes` as
`sql:app` on the Flow — Manifest, Console, and least privilege follow from that.

## Session Handle

`fx.store(db)` returns a `SqlStoreHandle`. Every method goes through the same
masking / RLS / CDC boundary.

| Method                                                              | Purpose                                            |
| ------------------------------------------------------------------- | -------------------------------------------------- |
| `select()` / `select(cols).from(t).where?.().orderBy?.().limit?.()` | Fluent select                                      |
| `insert(t).values(row)`                                             | Insert (`returning()` optional)                    |
| `update(t).set(row).where(cond)`                                    | Update — `where` required                          |
| `findById(t, id)`                                                   | PK lookup                                          |
| `delete(t, id)` / `delete(t).where(cond)`                           | Delete by PK or predicate                          |
| `exists(t, idOrWhere)`                                              | Presence check                                     |
| `upsert(t, matchOn, values, { onExisting? })`                       | Insert-or-skip; `"update"` opts into overwrite     |
| `increment(t, id, column, by?)`                                     | Atomic numeric bump (`by` default `1`)             |
| `count(t, where?)`                                                  | `COUNT(*)`                                         |
| `page(t, options)`                                                  | Offset or keyset page                              |
| `search(t, options)`                                                | BM25 ± LSH — [Search](/docs/elements/store/search) |
| `raw(sql, params?)`                                                 | Parameterized SQL (`?` placeholders)               |

Each verb binds with `await fx.store(db).<method>(…)` inside `do`:

<Tabs items={["Select", "Insert", "Update", "Delete", "Page", "Upsert"]}>

<Tab value="Select">

Fluent select, or `findById` for a PK:

```typescript title="src/flows/notes/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), title: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

`findById(notes, id)` is the same PK path without a fluent chain.

</Tab>

<Tab value="Insert">

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      const [row] = await fx.store(db).insert(notes).values({ id, title }).returning();
      return fx.json.create(row);
    },
  }),
);
```

</Tab>

<Tab value="Update">

`where` is required — bare updates throw
`update().set().where(): condition required`:

```typescript title="src/flows/notes/[id]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const update = on(
  http.patch({
    in: z.object({
      id: z.string(),
      title: z.string().min(1).optional(),
    }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id, title }, fx) => {
      if (title !== undefined) {
        await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
      }
      const row = await fx.store(db).findById(notes, id);
      if (!row) return fx.fail("NotFound", { id });
      return row;
    },
  }),
);
```

</Tab>

<Tab value="Delete">

Two-arg form deletes by PK. Fluent `.where(cond)` also requires a condition
(`delete().where(): condition required`):

```typescript title="src/flows/notes/[id]/remove.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const remove = on(
  http.delete({
    in: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }, fx) => {
      await fx.store(db).delete(notes, id);
      return fx.json.empty();
    },
  }),
);
```

</Tab>

<Tab value="Page">

`page` options: `where` · `orderBy` · `limit` · `offset` · `after` · `before`.
`after` / `before` cannot combine with each other or with `offset`.

```typescript title="src/flows/notes/list.ts"
import { on, flow, http } from "okengine";
import { db, notes } from "@/schema";

export const list = on(
  http.get(),
  flow({
    do: async (_, fx) => {
      return await fx.store(db).page(notes, {
        where: { archivedAt: null },
        orderBy: [notes.createdAt],
        limit: 20,
      });
    },
  }),
);
```

**Consequence:** keyset pages (`after` / `before`) stay stable under inserts —
prefer them for live feeds. Offset is fine for admin tables.

</Tab>

<Tab value="Upsert">

Default is insert-once. Pass `{ onExisting: "update" }` to overwrite a match:

```typescript title="src/flows/notes/ensure.ts"
import { call } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const ensureWelcome = call("notes.ensureWelcome", {
  in: z.object({ title: z.string() }),
  do: async ({ title }, fx) => {
    const result = await fx.store(db).upsert(
      notes,
      { id: "welcome" },
      {
        id: "welcome",
        title,
        body: "Your Notes API is ready.",
        archivedAt: null,
        createdAt: new Date("2026-01-15T10:00:00.000Z"),
      },
      // { onExisting: "update" },
    );
    return result; // { status: "upserted" | "changed" | "already-existed" }
  },
});
```

Empty `matchOn` throws `upsert() requires at least one matchOn predicate`.

</Tab>

</Tabs>

Also on the handle: `exists` · `increment` · `count` · `raw` · `search`. See
[Search](/docs/elements/store/search) for hybrid ranking.

## Resources

<Callout title="Detailed section">
  Mount details, live SSE, and verb tables live on [HTTP ·
  Resources](/docs/elements/flow/http#resources). This section is the factory contract —
  `store.resource(db, table, options)`.
</Callout>

`store.resource(db, table, options)` builds five Flows. Pass
`notesResource.all()` (or any bag with `list` · `create` · `get` · `update` ·
`remove`) to `on(http.resource(path, ops))`.

<Tabs items={["Define", "Mount"]}>

<Tab value="Define">

The factory registers no routes:

```typescript title="src/flows/notes/resource.ts"
import { store } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const notesResource = store.resource(db, notes, {
  in: z.object({ title: z.string().min(1), body: z.string() }),
  out: z.object({
    id: z.string(),
    title: z.string(),
    body: z.string(),
  }),
  list: {
    search: [notes.title],
    filter: [notes.archivedAt],
    cursor: [notes.createdAt, notes.id],
  },
});
```

</Tab>

<Tab value="Mount">

`.gate(member)` stamps every verb. The client sees `api.notes.list` / `.create` /
`.get` / `.update` / `.remove` after `oke({ name: "app" }).adopt({ notes })`.

```typescript title="src/flows/notes/index.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { notesResource } from "./resource";

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

</Tab>

</Tabs>

The URL id segment is always `:id`. Update is **PATCH**, not PUT.

| Op       | Method   | Path          | Typical status                     |
| -------- | -------- | ------------- | ---------------------------------- |
| `list`   | `GET`    | `/notes`      | `200` + `{ data, error, meta }`    |
| `create` | `POST`   | `/notes`      | `201 Created` (`fx.json.create`)   |
| `get`    | `GET`    | `/notes/:id`  | `200`, or `NotFound`               |
| `update` | `PATCH`  | `/notes/:id`  | `200`, or `NotFound`               |
| `remove` | `DELETE` | `/notes/:id`  | `204 No Content` (`fx.json.empty`) |
| `live`   | `GET`    | `/notes/live` | SSE — only when live is on         |

<Accordions>

<Accordion title="Resource Options">
  Third argument to `store.resource(db, table, options)`:

| Option     | Type      | Default                          | Meaning                                                                  |
| ---------- | --------- | -------------------------------- | ------------------------------------------------------------------------ |
| `in`       | Schema    | _(required)_                     | Create body (`POST`)                                                     |
| `out`      | Schema    | _(required)_                     | Item shape (get / list / update return)                                  |
| `update`   | Schema    | `in`                             | Patch fields. Wire body is `{ id, …patch }`                              |
| `idSchema` | Schema    | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key)              |
| `errors`   | error map | `{ NotFound }`                   | Typed failures on get / update / remove                                  |
| `id`       | column    | table PK                         | Column bound to `:id`                                                    |
| `list`     | object    | see List Options                 | List query grammar (`GET /notes`)                                        |
| `breaking` | `boolean` | `false`                          | Marks the five Flows `breaking: true` (handwritten → resource migration) |
| `live`     | `boolean` | omitted                          | Live query surface — see Resource Live                                   |

</Accordion>

<Accordion title="List Options">
  Nested on `store.resource(…, { list: { … } })`. Search / filter / order / select
  use a column scope: `"all"` · column array · `"none"`.

| Option      | Type                     | Default                                          | Meaning                             |
| ----------- | ------------------------ | ------------------------------------------------ | ----------------------------------- |
| `mode`      | `"cursor"` \| `"offset"` | `"cursor"` when `cursor` is set, else `"offset"` | Pagination                          |
| `cursor`    | columns                  | `[]`                                             | Keyset columns                      |
| `direction` | `"asc"` \| `"desc"`      | `"desc"`                                         | Default sort when no `?order=`      |
| `limit`     | `number`                 | `20`                                             | Default page size                   |
| `maxLimit`  | `number`                 | `100`                                            | Cap on `?limit=`                    |
| `count`     | `"exact"` \| `"none"`    | `"exact"`                                        | Offset-only `COUNT(*)`              |
| `search`    | column scope             | `"none"`                                         | `?search=` / `?q=` substring `LIKE` |
| `filter`    | column scope             | `"none"`                                         | `?col=eq.x` grammar                 |
| `order`     | column scope             | cursor columns, else `"all"`                     | `?order=`                           |
| `select`    | column scope             | `"all"`                                          | `?select=` projection               |

</Accordion>

<Accordion title="List URL grammar">
  Shared by resource lists, `liveQuery`, and handwritten pages that call
  `parseListQuery`:

| Param                         | Example                 | Meaning                                                                                       |
| ----------------------------- | ----------------------- | --------------------------------------------------------------------------------------------- |
| `limit` / `offset` / `cursor` | `?limit=20`             | Pagination                                                                                    |
| `search` / `q`                | `?search=refund`        | Substring `LIKE` (not BM25)                                                                   |
| `order`                       | `?order=createdAt.desc` | Sort                                                                                          |
| `select`                      | `?select=id,title`      | Projection                                                                                    |
| column filter                 | `?status=eq.active`     | `eq` · `neq` · `gt` · `gte` · `lt` · `lte` · `like` · `ilike` · `in` · `is` (+ `not.` prefix) |
| `or` / `and`                  | `?or=(…)`               | Nested boolean groups                                                                         |

**Consequence:** list `?search=` is substring filter. Hybrid BM25 ranking is
`fx.store(db).search(table, { query })` — see [Two Surfaces](/docs/elements/store/search#two-surfaces).

</Accordion>

<Accordion title="Resource Members">

| Member                                          | Kind                | Meaning                                                                                   |
| ----------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `all()`                                         | method, no args     | Bag for `http.resource(path, notesResource.all())` — five Flows, plus `live` when enabled |
| `list` · `create` · `get` · `update` · `remove` | Flow                | One verb. Bind with `http.get` / `http.post` / `http.patch` / `http.delete`               |
| `page(input)`                                   | method              | Compile list-query input for a handwritten `fx.store(db).page`                            |
| `live`                                          | `{ signal, flow }?` | Live surface when `live: true` (or the project default drained on)                        |

</Accordion>

<Accordion title="Resource Live">
  A sixth route appears only when the resource is live. It is **not** a signal
  firehose — each subscriber gets classified row events (RLS + list filters).

| `live` on the resource           | Result                                                                                   |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| `{ live: true }`                 | Mount `GET <path>/live` now                                                              |
| omitted                          | Mount only if `oke({ store: { live: true } })`                                           |
| `{ live: false }`                | Never mount live for this resource                                                       |
| table `store.schema.live(false)` | Opts that table out of the project default. `{ live: true }` on the resource still wins. |

```typescript title="src/flows/notes/resource.ts"
const notesResource = store.resource(db, notes, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
  live: true,
});

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

**Consequence:** `GET /notes/live` rides the same `.gate(...)` chain as list/get.

Wire events (consumed with `useLiveQuery` on the [typed client](/docs/client/react)):

| `kind`    | Meaning                                                     |
| --------- | ----------------------------------------------------------- |
| `upsert`  | Row visible under stamp + query — merge by primary key      |
| `revoked` | Row left visibility (`reason: "rls"` or `"query"`) — remove |
| `delete`  | Row deleted — remove                                        |

Live queries need an RLS-capable SQL driver (`postgres` / `pglite`) and a gated
identity on the request. Extract fails without a primary key:

```text
extract: live: true on table "notes" requires a primary key column (upsert/revoked/delete address rows by PK)
```

Missing `updatedAt` / `updated_at`, or no RLS policies, warn at extract — they
do not fail the build. Full mount physics: [HTTP · Resource Live](/docs/elements/flow/http#resources).

</Accordion>

<Accordion title="Subset & Override">
  `http.resource` always mounts all five CRUD keys. Bind individual Flows for a subset; spread
  `.all()` and override one key to replace a verb. Collision errors (**OKE1041**): [HTTP ·
  Resources](/docs/elements/flow/http#resources).
</Accordion>

</Accordions>

## Schema Extras & RLS

<Callout title="Detailed section">
  If you only need a gate + owner policy, jump to Progressive Patterns → RLS. Extras are the **third
  argument array** on `store.schema.table(name, cols, extras)`.
</Callout>

Pass extras after the column map:

| Extra                              | Meaning                                              |
| ---------------------------------- | ---------------------------------------------------- |
| `store.schema.rls()`               | Enable RLS on the table (no policies yet)            |
| `store.schema.policy(name, opts?)` | Named policy (`for`, `as`, `using`, `withCheck`)     |
| `store.schema.policy.gate(name)`   | Default `for: "select"` — `oke.gate() = '…'`         |
| `store.schema.policy.owner(col)`   | Default `for: "all"` — `col = oke.user()`            |
| `store.schema.policy.scope(scope)` | Default `for: "insert"` — `oke.has_scope('…')`       |
| `store.schema.policy.tenant(col)`  | Tenant predicate when tenancy is on                  |
| `store.schema.unscoped()`          | Opt out of tenant requirement                        |
| `store.schema.live(false)`         | Opt this table out of project live default           |
| `store.schema.relations(…)`        | Relation metadata for Drizzle emit — not a query API |

<Accordions>

<Accordion title="Policy helpers">
  Helpers take **string** column / gate / scope names and stamp predicates:

```typescript title="src/db/schema.decl.ts"
export const bookings = store.schema.table(
  "bookings",
  {
    id: field.id().primaryKey(),
    owner: field.text().notNull(),
    tenantId: field.text().notNull(),
  },
  [
    store.schema.policy.gate("member", { for: "select" }),
    store.schema.policy.owner("owner", { for: "all" }),
    store.schema.policy.scope("booking:create", { for: "insert" }),
    store.schema.policy.tenant("tenantId"),
  ],
);
```

`for` accepts SQL commands (`select` · `insert` · `update` · `delete` · `all`).
Raw `store.schema.policy(name, { using, withCheck })` is available when helpers
are not enough.

</Accordion>

<Accordion title="Tenant requirement">
  When `gate.auth.tenant` is on, every table needs a tenant policy or an
  explicit opt-out. Extract fails otherwise:

```text
extract: table "{store}.{table}" needs store.schema.policy.tenant(...) or store.schema.unscoped() when gate.auth.tenant is on
```

```typescript
export const shared = store.schema.table(
  "shared_flags",
  { id: field.id().primaryKey(), key: field.text().notNull() },
  [store.schema.unscoped()],
);
```

</Accordion>

<Accordion title="Foreign keys & relations">
  Column FKs use `.references(() => other.col, { onDelete?, onUpdate? })`.
  `store.schema.relations` records one/many links for Drizzle emit — it does
  **not** add `fx.store(db).query.…`:

```typescript title="src/db/schema.decl.ts"
export const links = store.schema.table("links", {
  id: field.id().primaryKey(),
  code: field.text().notNull().unique(),
});

export const daily = store.schema.table("daily", {
  id: field.id().primaryKey(),
  code: field
    .text()
    .notNull()
    .references(() => links.code, { onDelete: "cascade" }),
  day: field.text().notNull(),
});

export const relations = store.schema.relations({ links, daily }, (r) => ({
  links: {
    daily: r.many.daily({ from: r.links.code, to: r.daily.code }),
  },
  daily: {
    link: r.one.links({
      from: r.daily.code,
      to: r.links.code,
      optional: false,
    }),
  },
}));
```

Join in Flows with fluent `select` / `where`, or mount related tables as their
own resources.

</Accordion>

</Accordions>

## Seeding


> oke db seed: essential always runs; dev lights the dev block; prod lights prod; test runs essential only. Upsert inserts once, then already-existed unless onExisting update.


Seed never runs at boot without confirmation — only via `oke db seed`, or the
first-boot confirm when a seed module exists and `.oke/state.json` has not
recorded that identity yet. Categories:

| Block          | Runs when               |
| -------------- | ----------------------- |
| `essential`    | Every env               |
| `dev`          | `dev` ConfigEnv only    |
| `prod`         | `prod` only             |
| _(none extra)_ | `test` — essential only |

```typescript title="src/db/seed/index.ts"
import { defineSeed, type Fx } from "okengine";
import { db } from "@/core";
import { notes } from "@/db/schema.decl";

async function welcomeNote(fx: Fx) {
  await fx.store(db).upsert(
    notes,
    { id: "welcome" },
    {
      id: "welcome",
      title: "Welcome",
      body: "Your Notes API is ready.",
      archivedAt: null,
      createdAt: new Date("2026-01-15T10:00:00.000Z"),
    },
  );
}

export default defineSeed({
  name: "notes",
  description: "Welcome + sample notes",
  essential: welcomeNote,
  // dev: sampleNotes,
  // prod: async (fx) => { /* prod-only rows */ },
});
```

`upsert` returns `{ status: "upserted" | "changed" | "already-existed" }`.
Default is insert-once; pass `{ onExisting: "update" }` to overwrite.

## CDC

<Callout title="Detailed section">
  Full consumer patterns live on [Consumers · CDC](/docs/elements/flow/consumers#cdc). Here is the
  SQL-side trigger: `db.table(handle).changed(column?)`.
</Callout>

`db.table(orders).changed(column?)` builds a CDC trigger for `on(…)`. Input is
`{ before, after }` plus `table` / `action` / `id`. `changed("status")` stamps a
**column** name on the Manifest — it is not an op filter.

```typescript title="src/flows/orders/on-status.ts"
import { on, flow } from "okengine";
import { db, orders } from "@/schema";

export const onStatus = on(
  db.table(orders).changed("status"),
  flow("orders.statusChanged", {
    do: async ({ before, after }, fx) => {
      if (before?.status === after?.status) return;
      /* committed write */
    },
  }),
);
```

**Consequence:** writes must go through `fx.store`. A raw SQL client bypasses
the CDC sink, so no consumer runs.

## Drivers

| Driver     | Runs as                   | Best for                       |
| ---------- | ------------------------- | ------------------------------ |
| `postgres` | Docker / managed Postgres | Dev + prod default; RLS + live |
| `pglite`   | In-process WASM           | Test default; RLS-capable      |
| `memory`   | Process map               | Tiny ephemeral tests           |

Standard starters inherit `DRIVER_DEFAULTS` (`postgres` / `pglite` /
`postgres`). Pin only when you diverge; vendor choice lives under `images`, not
driver ids:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // omit store.sql to use defaults — pin only overrides
  },
  images: {
    store: {
      sql: "postgres:18-alpine",
    },
  },
});
```

## Troubleshooting

<Accordions>

<Accordion title="OKE1110 — domain table not found">
  Cause: `domain table not found — migrations have not been applied.` Run `oke db push` locally or
  `oke db migrate` in that environment.
</Accordion>

<Accordion title="update().set().where(): condition required">
  Updates and deletes without a `where` are rejected (`delete().where(): condition required`). Pass
  a Drizzle condition or equality map — or use `delete(table, id)` for PK deletes.
</Accordion>

<Accordion title="page(): after and before / offset and keyset cannot combine">
  Keyset (`after` / `before`) and `offset` are mutually exclusive. Pick one pagination mode per
  call.
</Accordion>

<Accordion title="upsert() requires at least one matchOn predicate">
  Pass a non-empty equality map or Drizzle condition that identifies the row.
</Accordion>

<Accordion title="increment(): no row with …">
  The PK was missing. Insert first, or guard with `exists` / `findById`.
</Accordion>

<Accordion title="extract: table needs tenant or unscoped">
  Cause: `extract: table "{store}.{table}" needs store.schema.policy.tenant(...) or
  store.schema.unscoped() when gate.auth.tenant is on`. Add a tenant policy or mark the table
  unscoped.
</Accordion>

<Accordion title="live: true requires a primary key / RLS driver">
  Extract: `live: true on table "…" requires a primary key column`. Runtime needs `postgres` /
  `pglite` and a gated identity on the request.
</Accordion>

<Accordion title="unknown list param / search is not enabled">
  Default `filter: "none"` / `search: "none"` rejects extra keys. Whitelist
  columns on `store.resource(…, { list: { … } })` or your `liveQuery` options.
</Accordion>

<Accordion title="fx.store(db).query is undefined">
  There is no Relational Query Builder on the session handle. Use fluent `select` / `insert` /
  `update` / `page`, or `store.resource` for CRUD. `store.schema.relations` is emit metadata only.
</Accordion>

<Accordion title="OKE1041 — method + path bound twice">
  A resource mount plus a handwritten `http.get("/notes")` (or two mounts on the same base path)
  collide. Drop one binding — see [HTTP ·
  Troubleshooting](/docs/elements/flow/http#troubleshooting).
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — four facets; driver defaults
- [Search](/docs/elements/store/search) — `.searchable()` / `.embed()` / `fx.store(db).search`
- [HTTP · Resources](/docs/elements/flow/http#resources) — mount, live SSE, verb table
- [Consumers · CDC](/docs/elements/flow/consumers#cdc) — `db.table(…).changed()`
- [fx](/docs/reference/fx) — `fx.store` session
- [Errors](/docs/reference/errors) — OKE1110 · OKE1041

## Next

<Cards>
  <Card
    title="KV"
    description="Namespaced get/set with duration TTL."
    href="/docs/elements/store/kv"
  />
  <Card
    title="Search"
    description="BM25 ± LSH on SQL columns."
    href="/docs/elements/store/search"
  />
  <Card
    title="HTTP · Resources"
    description="Mount store.resource as five CRUD verbs + live."
    href="/docs/elements/flow/http#resources"
  />
</Cards>


# Config (/docs/elements/vault/config)

Config contracts (`vault.config`) hold **non-secret** operational settings — public origins,
feature flags, workspace labels. They resolve through the same boot chain as secrets, but Console
may show them in the clear.

For developers who need validated settings without fingerprinting — declare `vault.config`, read
with `fx.vault.get`, keep credentials on `vault.secret` instead.

<Callout title="The one rule">
  Use `vault.config` only when cleartext in Console and logs is acceptable. API keys, tokens, and
  connection passwords belong on `vault.secret`.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare a config contract

```typescript title="src/core/vault.ts"
import { vault } from "okengine";
import { z } from "zod";

export const publicAppUrl = vault.config("PUBLIC_APP_URL", {
  description: "Public origin for email links and redirects",
  schema: z.string().url(),
  // Dev-only fallback — never used in prod boot
  dev: "http://localhost:6530",
});
```

</Step>

<Step>
### Read it in a Flow

```typescript title="src/flows/invites/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { publicAppUrl, noteCreatedMail } from "@/core";

export const create = on(
  http
    .post({
      in: z.object({ email: z.string().email() }),
    })
    .gate(member),
  flow({
    do: async ({ email }, fx) => {
      const origin = await fx.vault.get(publicAppUrl);
      await fx.send(noteCreatedMail, {
        to: email,
        // Config may be revealed for URL building — still prefer holding Redacted
        // until the send boundary when mixed with secrets
        link: `${origin.reveal()}/accept`,
      });
      return fx.json.empty();
    },
  }),
);
```

</Step>

<Step>
### See it in Console

The Config band shows `PUBLIC_APP_URL` in the clear after boot (secrets stay fingerprinted). Update
via Console write or `oke vault set PUBLIC_APP_URL`.

</Step>

</Steps>

## Progressive Patterns

From a public URL to flags, registration, and the env escape hatch:

<Tabs items={["URL", "Flag", "Register", "Env"]}>

<Tab value="URL">

```typescript
export const publicApiUrl = vault.config("PUBLIC_API_URL", {
  description: "Browser-facing API origin",
  schema: z.string().url(),
  dev: "http://localhost:6530",
});
```

</Tab>

<Tab value="Flag">

Feature toggles as config (cleartext by design):

```typescript
export const newDashboard = vault.config("FEATURE_NEW_DASHBOARD", {
  description: "Enable the redesigned dashboard",
  schema: z.enum(["on", "off"]),
  dev: "off",
});
```

</Tab>

<Tab value="Register">

`vault.config` is **not** auto-drained into `oke({ secrets })` (unlike `vault.secret`). Pass
handles explicitly when the Manifest must list them:

```typescript title="src/app.ts"
import { oke } from "okengine";
import { publicAppUrl, publicApiUrl } from "@/core/vault";

export const app = oke({
  name: "notes",
  env: "dev",
  secrets: [publicAppUrl, publicApiUrl],
});
```

</Tab>

<Tab value="Env">

Plain process configuration — no contract, no fingerprint, no driver bag:

```typescript
import { vault } from "okengine";

const port = vault.env.int("PORT", 6530);
const verbose = vault.env.bool("VERBOSE", false);
const bag = vault.env.json<{ region: string }>("DEPLOY_META");
```

`vault.env.required("NAME")` registers the name so a missing value joins `VaultBootError` gaps
alongside secret contracts.

</Tab>

</Tabs>

## Options Reference

Same option bag as secrets (`VaultSecretOptions`), with different defaults:

| Option        | Type      | Default | Meaning                                                   |
| ------------- | --------- | ------- | --------------------------------------------------------- |
| `description` | `string`  | —       | Boot-gap and Console label                                |
| `rotate`      | `string`  | omit    | Optional cadence hint (rarely used for config)            |
| `schema`      | Schema    | —       | Declared shape for docs / tooling                         |
| `dev`         | `string`  | —       | Dev-only fallback                                         |
| `sensitive`   | `boolean` | `false` | Set `true` only if this config must never show in Console |

Empty name throws `TypeError: vault.config: name is required`.

**Consequence:** `sensitive: true` on a config behaves like a secret for Console cleartext —
prefer `vault.secret` when that is the intent.

## `vault.env` helpers

Synchronous reads of process environment. Empty strings count as unset.

| Helper                       | Returns               | Throws when                                  |
| ---------------------------- | --------------------- | -------------------------------------------- |
| `vault.env(name)`            | `string \| undefined` | —                                            |
| `vault.env.required(name)`   | `string`              | unset — also registers for boot gaps         |
| `vault.env.int(name, def?)`  | `number`              | unset without default, or not an integer     |
| `vault.env.bool(name, def?)` | `boolean`             | unset without default, or not boolean-shaped |
| `vault.env.json(name)`       | `T \| undefined`      | value present but not valid JSON             |

Boolean true: `1` · `true` · `yes` · `on`. False: `0` · `false` · `no` · `off` (case-insensitive).

```typescript
// TypeError: vault.env.required: CI_TOKEN is not set
vault.env.required("CI_TOKEN");

// TypeError: vault.env.int: PORT is not an integer
process.env.PORT = "abc";
vault.env.int("PORT");
```

Reach for `vault.secret` / `vault.config` when the value must participate in the resolution chain,
fingerprinting, or Console Vault.

## Cleartext vs fingerprint

| Kind     | Console list     | `runtime.cleartext(name)` | `runtime.fingerprint(name)` |
| -------- | ---------------- | ------------------------- | --------------------------- |
| `secret` | Fingerprint only | always `undefined`        | defined when loaded         |
| `config` | Shown in clear   | loaded value              | always `undefined`          |

Both still return `Redacted` from `fx.vault.get` — reveal when you need a plain string.

## Troubleshooting

<Accordions>

<Accordion title="Config missing from Manifest / Console">
  `vault.config` is not auto-registered. Pass `oke({ secrets: [publicAppUrl] })` or ensure the
  declaring module is adopted the same way your app wires secrets.
</Accordion>

<Accordion title="VaultBootError includes a PUBLIC_* name">
  Same resolution chain as secrets — set the value or provide `dev:` for local boot. Config gaps
  fail boot just like secrets when registered.
</Accordion>

<Accordion title="TypeError: vault.env.required: NAME is not set">
  Call site threw immediately. For boot-time collection, keep the `required` call so the name is
  registered; boot then lists it with other gaps.
</Accordion>

<Accordion title="TypeError: vault.env.bool / int / json">
  Value is present but malformed. Use the documented boolean tokens, an integer string, or valid
  JSON — or supply a default for `int` / `bool` when unset is allowed.
</Accordion>

<Accordion title="Secret shown in the Config band">
  You declared `vault.config` (or `sensitive: false`). Move credentials to `vault.secret`.
</Accordion>

</Accordions>

## Learn more

- [Secrets](/docs/elements/vault/secrets) — fingerprinted contracts and Redacted
- [Vault overview](/docs/elements/vault) — resolution order and drivers
- [Environment variables](/docs/reference/environment-variables) — `OKE_*` knobs
- [fx](/docs/reference/fx) — `fx.vault.get` on any contract

## Next

<Cards>
  <Card
    title="Key Rotation"
    description="Rotate versions and the builtin master key."
    href="/docs/elements/vault/rotation"
  />
  <Card
    title="Secrets"
    description="Fingerprinted vault.secret contracts."
    href="/docs/elements/vault/secrets"
  />
  <Card
    title="Vault Overview"
    description="Resolution chain and fx.vault surface."
    href="/docs/elements/vault"
  />
</Cards>


# Overview (/docs/elements/vault)

Vault is how your backend **holds secrets and configuration safely**. Declare a contract
(`vault.secret`, `vault.config`), resolve the value through the boot chain, and read it inside
Flows as `Redacted` — never as a plain string in logs or responses.

For developers wiring Stripe keys, SMTP URLs, and feature flags on okengine — contracts first,
values only at the provider edge.

<Callout title="The one rule">
  **Contracts, never values.** Application code declares `vault.secret("STRIPE_KEY")` (or
  `vault.config`). Cleartext crosses only at `.reveal()` on the credential boundary — never in
  `console.log`, JSON, or the HTTP envelope.
</Callout>


> Vault resolution chain: driver, process.env, .env.local, then dev-fallback. First hit wins; if every layer misses, boot fails with VaultBootError.


## Smallest Example

<Steps>

<Step>
### Declare a secret contract

```typescript title="src/core/vault.ts"
import { vault } from "okengine";

export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
  description: "HMAC secret for outbound note webhooks",
  // Dev-only fallback — never used in prod boot
  dev: "dev-webhook-secret-change-me",
});
```

</Step>

<Step>
### Read it in a Flow

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { webhookSecret } from "@/core/vault";
import { notesMutate } from "@/core/gate";

export const create = on(
  http.post().gate(notesMutate),
  flow({
    do: async (input, fx) => {
      // Touches the contract → effects.secrets; reveal only at an SDK edge
      await fx.vault.get(webhookSecret);
      return fx.json.create({ id: fx.id(), title: input.title });
    },
  }),
);
```

The compiler stamps `secrets: ["APP_WEBHOOK_SECRET"]` on the Flow’s effects.

</Step>

<Step>
### Boot and call

```bash
# Missing value → VaultBootError lists every gap at once
oke vault set APP_WEBHOOK_SECRET

curl -X POST http://localhost:6530/notes \
  -H "accept: application/json" \
  -H "content-type: application/json" \
  -d '{"title":"hello"}'
```

`fx.log` / `String(key)` / `JSON.stringify(key)` all show `[redacted]` — never the HMAC material.

</Step>

</Steps>

## Progressive Patterns

From a fingerprinted secret to cleartext config, plain env helpers, and Docker-local fallbacks:

<Tabs items={["Secret", "Config", "Env", "fromDocker"]}>

<Tab value="Secret">

Fingerprinted contract — Console shows a fingerprint, never the value:

```typescript title="src/core/vault.ts"
import { vault } from "okengine";
import { z } from "zod";

export const stripeKey = vault.secret("STRIPE_KEY", {
  description: "Stripe secret API key",
  schema: z.string().startsWith("sk_"),
  rotate: "90d",
  // Optional local fallback when drivers.vault.dev allows it
  dev: "sk_test_local",
});
```

`vault("STRIPE_KEY", opts)` is the same as `vault.secret`.

</Tab>

<Tab value="Config">

Non-sensitive settings — Console may show them in the clear:

```typescript title="src/core/vault.ts"
import { vault } from "okengine";
import { z } from "zod";

export const publicAppUrl = vault.config("PUBLIC_APP_URL", {
  description: "Public origin for email links",
  schema: z.string().url(),
  dev: "http://localhost:6530",
});
```

**Consequence:** `vault.config` is **not** auto-registered into `oke({ secrets })` — pass the
handle (or import the declaring module before `oke()` when you need it on the Manifest).

</Tab>

<Tab value="Env">

Plain process configuration — no boot chain, no redaction. Prefer contracts when the value must
fingerprint or fail boot:

```typescript
import { vault } from "okengine";

const port = vault.env.int("PORT", 6530);
const debug = vault.env.bool("DEBUG", false);
const raw = vault.env("FEATURE_FLAG"); // string | undefined
const must = vault.env.required("CI_TOKEN"); // joins VaultBootError gaps when missing
```

</Tab>

<Tab value="fromDocker">

Local fallback that reads the URL the image recipe built for a role — kernel never sees the
underlying env-var names:

```typescript
export const databaseUrl = vault.secret("DATABASE_URL", {
  description: "Primary SQL URL",
  dev: vault.fromDocker("store.sql"),
});
```

Resolves from `OKE_STORE_SQL_URL` (and peers) when `allowDevFallbacks` is on.

</Tab>

</Tabs>

## Declaration Reference

| Declaration        | Signature                                              | Purpose                               |
| ------------------ | ------------------------------------------------------ | ------------------------------------- |
| `vault.secret`     | `vault.secret(name, options?)`                         | Fingerprinted secret contract         |
| `vault`            | `vault(name, options?)`                                | Alias for `vault.secret`              |
| `vault.config`     | `vault.config(name, options?)`                         | Cleartext config contract             |
| `vault.fromDocker` | `vault.fromDocker(role)`                               | Dev fallback marker for an image role |
| `vault.env`        | `vault.env` / `.required` / `.int` / `.bool` / `.json` | Sync process env — no contract        |

| Option        | Type      | Default                        | Meaning                                                                |
| ------------- | --------- | ------------------------------ | ---------------------------------------------------------------------- |
| `description` | `string`  | —                              | Shown in boot-gap listings and Console                                 |
| `rotate`      | `string`  | omit ≈ `"never"`               | Cadence hint (`"90d"`) or `"never"` — Console posture, not auto-rotate |
| `schema`      | Schema    | —                              | Declared shape (Zod / Standard Schema) for docs and tooling            |
| `dev`         | `string`  | —                              | Dev-only fallback; never used in prod boot                             |
| `sensitive`   | `boolean` | `true` secret · `false` config | Whether cleartext must never leave the runtime                         |
| `perTenant`   | `boolean` | `true` when tenancy on         | Resolve `{tenantId}/{name}` at request time; not boot-gap fatal        |

## Resolution Chain

<Callout title="Detailed section">
  If you only need “declare and `fx.vault.get`”, the Smallest Example is enough. This section is how
  boot finds a value — first hit wins.
</Callout>

Order (Console labels match these source ids):

1. **driver** — built-in encrypted store, managed bag, or `env` / `memory` driver
2. **process.env** — real environment (CI, hosting)
3. **`.env.local`** — local overrides (gitignored)
4. **dev-fallback** — `dev:` on the contract (dev boot only)

Miss every layer → `VaultBootError` listing **all** gaps in one pass (including
`vault.env.required` names):

```text
vault boot failed — 2 missing secret(s):
  - STRIPE_KEY: Payments gateway key
  - DATABASE_URL: Primary SQL URL
```

**Consequence:** fix every listed name before traffic — boot does not take a half-configured app.

See [Secrets](/docs/elements/vault/secrets) for contracts and [Config](/docs/elements/vault/config)
for cleartext + `vault.env`.

## Redacted until reveal


> Vault Redacted physics: fx.vault.get returns a Redacted wrapper so fx.log, String, and JSON show [redacted]; only .reveal() yields cleartext at the provider boundary.


`fx.vault.get(contract)` returns `Promise<Redacted<string>>`. Printing, logging, and JSON all
yield `[redacted]`. Call `.reveal()` only at the Stripe / SMTP / SDK edge.

Loaded secret substrings are also scrubbed from `fx.log` even when you pass cleartext by mistake
(mask token `[redacted:secret]`).

## `fx.vault` surface

| Method                    | Needs backend | Meaning                                     |
| ------------------------- | ------------- | ------------------------------------------- |
| `get(contract)`           | No            | `Redacted<string>` — every app has this     |
| `set(path, value, opts?)` | Yes (`vault`) | New version; optional `ttlMs` / `metadata`  |
| `rotate(path, value)`     | Yes (`vault`) | New version under a fresh data key          |
| `delete(path)`            | Yes (`vault`) | Crypto-shred; returns whether anything went |
| `list(prefix?)`           | Yes (`vault`) | Paths only — never values                   |
| `status()`                | Yes (`vault`) | `{ sealed, initialized, backend }`          |

Without a bound encrypted backend, mutations throw:

```text
fx.vault.set needs a bound Vault backend — configure the vault element (drivers.vault = "vault") …
```

Dry-run refuses writes rather than mutate a live secret (`DryRunWriteIsolationError`).

## Per-environment drivers

Defaults from `DRIVER_DEFAULTS.vault` — pin overrides in `oke.config.ts`:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // Default vault.dev is "env"; Docker-first starters pin built-in:
    vault: {
      dev: "vault",
      // test: "memory" (default)
      // prod: "vault" (default)
    },
  },
});
```

| Driver    | Runs as                          | Best for                                     |
| --------- | -------------------------------- | -------------------------------------------- |
| `env`     | Process / dotenv bag             | Simple local / CI without SQL vault tables   |
| `vault`   | Built-in AES-256-GCM in Postgres | Docker-first apps; `oke vault init` / unseal |
| `memory`  | In-process map                   | Tests                                        |
| `managed` | Remote provider bag              | AWS / Azure / GCP / Doppler / 1Password      |

create-oke Notes starters pin `vault.dev: "vault"` and declare stack + app contracts in
`src/vault.ts` (`vault.secret` / `vault.config`). Console Vault lists those contracts —
values still resolve from `.env.local`, process env, `oke vault set`, or `dev:` /
`vault.fromDocker` fallbacks. Pass `oke({ secrets: NOTES_VAULT })` so configs resolve
(only `vault.secret` auto-registers).

Managed provider ids: `aws-secrets-manager` · `azure-key-vault` · `gcp-secret-manager` ·
`doppler` · `1password`. Env knobs: [Environment variables](/docs/reference/environment-variables).

## The Capabilities of Vault

<Cards>
  <Card
    title="Secrets"
    description="Fingerprinted vault.secret contracts, Redacted reads, boot gaps."
    href="/docs/elements/vault/secrets"
  />
  <Card
    title="Config"
    description="vault.config cleartext settings and vault.env helpers."
    href="/docs/elements/vault/config"
  />
  <Card
    title="Key Rotation"
    description="Version rotate, master-key rewrap, seal / unseal, CLI."
    href="/docs/elements/vault/rotation"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title="VaultBootError — N missing secret(s)">
  Cause: `vault boot failed — N missing secret(s):` with every gap listed. On a TTY, `oke dev`
  prompts each into `.env.local`; otherwise use `oke vault set` / env / managed, or a `dev:`
  fallback. `vault.env.required` joins the same list.
</Accordion>

<Accordion title='vault: secret "…" is not loaded'>
  `fx.vault.get` ran after boot without that name in the merged bag. Declare the contract, ensure a
  resolution layer supplies it, and import the declaring module before `oke()`.
</Accordion>

<Accordion title="fx.vault.set / rotate needs a bound Vault backend">
  Mutations require `drivers.vault = "vault"` (encrypted-at-rest adapter). `get` works on `env` /
  `memory` / `managed` bags; writes need the builtin backend.
</Accordion>

<Accordion title="Logs still show a secret substring">
  Prefer holding `Redacted` until `.reveal()` at the edge. Revealed cleartext is scrubbed from
  `fx.log` when the value is registered — but third-party loggers bypass Vault redaction.
</Accordion>

<Accordion title="VaultSealed / cannot rotate while sealed">
  Export `OKE_VAULT_MASTER_KEY` or run `oke vault unseal`. See [Key
  Rotation](/docs/elements/vault/rotation).
</Accordion>

</Accordions>

## Learn more

- [Secrets](/docs/elements/vault/secrets) — options, Redacted, effects, tenancy paths
- [Config](/docs/elements/vault/config) — cleartext contracts and `vault.env`
- [Key Rotation](/docs/elements/vault/rotation) — `oke vault rotate` / `rotate-master`
- [fx](/docs/reference/fx) — `fx.vault.get` · `set` · `rotate` · `delete` · `list` · `status`
- [Errors](/docs/reference/errors) — `VaultBootError` · `VaultError` · `VaultSealed`
- [Environment variables](/docs/reference/environment-variables) — `OKE_VAULT_*`

## Next

<Cards>
  <Card
    title="Secrets"
    description="Declare fingerprinted contracts and read Redacted values."
    href="/docs/elements/vault/secrets"
  />
  <Card
    title="Channel Element"
    description="Send email, SMS, and other human reach through templates."
    href="/docs/elements/channel"
  />
  <Card
    title="Gate Element"
    description="Attach policies and rates before do runs."
    href="/docs/elements/gate"
  />
</Cards>


# Key Rotation (/docs/elements/vault/rotation)

Key rotation keeps credentials and encryption keys moving without dropping the app. On the builtin
`vault` driver you rotate a **secret version** (fresh data key) or the **master key** (KEK rewrap).
Contract `rotate: "90d"` is a Console cadence hint — it does not rotate by itself.

For operators and Flows that must cut over keys safely — prefer CLI for master material; use
`fx.vault.rotate` for path versions inside privileged Flows.

<Callout title="The one rule">
  Never pass master keys as CLI argv in shared shells — they land in history. Prefer `oke vault
  unseal --key -`, the env `OKE_VAULT_MASTER_KEY`, or the hidden prompt.
</Callout>

## Smallest Example

<Steps>

<Step>
### Initialize and set a secret (builtin driver)

```bash
# drivers.vault = "vault" — SQL-backed AES-256-GCM
oke vault init          # prints master key once — store out of band
export OKE_VAULT_MASTER_KEY=…   # or --key - from stdin

oke vault set STRIPE_KEY
# prompts for value, or: oke vault set STRIPE_KEY sk_live_…
```

</Step>

<Step>
### Rotate to a new version

```bash
oke vault rotate STRIPE_KEY sk_live_new_key
# → oke vault: rotated STRIPE_KEY → v2 (fresh data key)
```

Omit the value to re-encrypt the current cleartext under a new data key (version bumps, readers
still see the same string until you change it).

</Step>

<Step>
### Confirm status

```bash
oke vault status
# initialized, unsealed, kek version, secret count
```

</Step>

</Steps>

## Progressive Patterns

From CLI path rotate to Flow mutations, cadence hints, and master rewrap:

<Tabs items={["CLI rotate", "fx.vault.rotate", "Cadence", "Master"]}>

<Tab value="CLI rotate">

```bash
oke vault rotate prod/api/stripe sk_live_new
oke vault rotate prod/api/stripe          # same cleartext, fresh DEK
```

Needs an unsealed builtin backend. Missing path:

```text
oke vault: no such secret: prod/api/stripe
```

</Tab>

<Tab value="fx.vault.rotate">

Privileged Flow — requires `drivers.vault = "vault"` (bound adapter):

```typescript title="src/flows/ops/rotate-stripe.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { gate } from "okengine";

const operator = gate.policy("operator", ({ operator: op }) => !!op);

export const rotateStripe = on(
  http
    .post({
      in: z.object({ value: z.string().min(1) }),
    })
    .gate(operator),
  flow({
    plane: "operator",
    effects: { secrets: ["STRIPE_KEY"] },
    do: async ({ value }, fx) => {
      const result = await fx.vault.rotate("STRIPE_KEY", value);
      return { path: result.path, version: result.version };
    },
  }),
);
```

`fx.vault.set` writes a new version without forcing a fresh DEK policy the same way; prefer
`rotate` when retiring the previous data key is the point.

Dry-run refuses both (`DryRunWriteIsolationError`).

</Tab>

<Tab value="Cadence">

Declare intent for Console posture — operators still run rotate:

```typescript
export const stripeKey = vault.secret("STRIPE_KEY", {
  description: "Stripe secret API key",
  rotate: "90d",
});
```

Omit or `"never"` when the secret must not rotate on a schedule.

</Tab>

<Tab value="Master">

Rewrap every DEK under a new KEK generation:

```bash
oke vault rotate-master
# prints the new master key once — store it, then update OKE_VAULT_MASTER_KEY

# Resume an interrupted rewrap:
oke vault rotate-master --new-key -
```

Overlapping batches surface `VaultRotateBusy`. Console rotate-master while sealed → `VaultSealed`.

</Tab>

</Tabs>

## Version physics


> Vault rotate contrast: version rotate re-wraps DEKs under a new data-key version; master-key rotate re-wraps the KEK that protects those DEKs — different blast radius, same fx.vault.rotate entry.


Builtin storage encrypts each version with its own data key (DEK), wrapped by a KEK derived from
the master key:

| Operation                | What changes                     | Readers see                          |
| ------------------------ | -------------------------------- | ------------------------------------ |
| `set` / `rotate` + value | New version + (rotate) fresh DEK | New cleartext on next `get`          |
| `rotate` without value   | New version + fresh DEK          | Same cleartext                       |
| `rotate-master`          | New KEK; DEKs re-wrapped         | Same cleartexts; new master required |
| `delete`                 | Crypto-shred path                | Subsequent `get` misses              |

Paths are slash-separated with no leading slash (`prod/api/stripe`). Invalid paths throw
`VaultError` `INVALID_PATH`.

**Consequence:** secret access is not journaled — durable Flow replay re-reads live vault state
after a rotate instead of replaying a stale credential from the journal.

## Seal & unseal

| Command / state    | Meaning                                                 |
| ------------------ | ------------------------------------------------------- |
| `oke vault init`   | Create backend state; print master key **once**         |
| `oke vault seal`   | Drop in-memory master; reads fail with `SEALED`         |
| `oke vault unseal` | Restore master from `--key` / env / prompt              |
| `oke vault status` | `initialized`, sealed flag, `kekVersion`, `secretCount` |

```bash
oke vault unseal --key -          # read base64 master from stdin
oke vault status --json
```

## CLI reference

Env / dotenv bag loop:

| Command                        | Purpose                   |
| ------------------------------ | ------------------------- |
| `oke vault set <NAME> [value]` | Write / overwrite a name  |
| `oke vault list`               | List names (never values) |
| `oke vault import <file>`      | Bulk import               |
| `oke vault key rotate`         | Env-loop key helper       |

Builtin encrypted store:

| Command                           | Purpose                      |
| --------------------------------- | ---------------------------- |
| `oke vault init`                  | First-time initialize        |
| `oke vault status [--json]`       | Seal / KEK / counts          |
| `oke vault seal` / `unseal`       | Master lifecycle             |
| `oke vault rotate <path> [value]` | Version + fresh DEK          |
| `oke vault rotate-master`         | KEK rewrap                   |
| `oke vault audit` …               | Audit trail / verify / purge |
| `oke vault purge-expired`         | Drop expired rows            |
| `oke vault backup` / `restore`    | File snapshot                |

`--url` overrides the SQL URL (`DATABASE_URL` / `OKE_STORE_SQL_URL`).

## Troubleshooting

<Accordions>

<Accordion title="oke vault: no such secret">
  Rotate/get targeted a path that was never set. `oke vault list` (or Console) for live paths;
  remember per-tenant storage uses `{tenantId}/{name}`.
</Accordion>

<Accordion title="VaultSealed">
  Process holds no master key. Export `OKE_VAULT_MASTER_KEY` or `oke vault unseal` before
  rotate-master / reads that need the adapter.
</Accordion>

<Accordion title="VaultRotateBusy">
  Another master-rotation lease or batch is in flight. Wait, or resume with `oke vault rotate-master
  --new-key` (stdin `-` preferred).
</Accordion>

<Accordion title="VaultUnsupported / needs drivers.vault = vault">
  Console or `fx.vault.rotate` hit a non-builtin bag (`env` / `memory` / `managed`). Pin
  `drivers.vault` to `"vault"` and ensure SQL is available.
</Accordion>

<Accordion title="fx.vault.rotate needs a bound Vault backend">
  Same as above — mutations need the encrypted adapter. `fx.vault.get` alone works on any driver.
</Accordion>

<Accordion title="DryRunWriteIsolationError on set / rotate">
  Dry-run refuses vault writes so a live secret is never mutated. Use a real run for rotation.
</Accordion>

<Accordion title="VaultError EXPIRED">
  A version’s absolute expiry passed on the builtin adapter. Rotate or set a new value; purge
  expired rows with `oke vault purge-expired` when cleaning storage.
</Accordion>

</Accordions>

## Learn more

- [Secrets](/docs/elements/vault/secrets) — contracts and `fx.vault.get`
- [Vault overview](/docs/elements/vault) — drivers and resolution
- [Errors](/docs/reference/errors) — `VaultSealed` · `VaultRotateBusy` · `VaultUnsupported`
- [Environment variables](/docs/reference/environment-variables) — `OKE_VAULT_MASTER_KEY`

## Next

<Cards>
  <Card
    title="Channel Element"
    description="Send email and other human reach through templates."
    href="/docs/elements/channel"
  />
  <Card
    title="Secrets"
    description="Declare contracts readers will rotate."
    href="/docs/elements/vault/secrets"
  />
  <Card
    title="Vault Overview"
    description="Resolution chain, Redacted, and drivers."
    href="/docs/elements/vault"
  />
</Cards>


# Secrets (/docs/elements/vault/secrets)

Secret contracts (`vault.secret`) declare the credentials your backend requires — Stripe keys,
webhook HMACs, SMTP URLs. Values resolve at boot; Flows read them as `Redacted` through
`fx.vault.get`.

For developers who must never leak credentials into logs or HTTP bodies — declare the name,
resolve it, reveal only at the provider edge.

<Callout title="The one rule">
  Touch secrets only through `fx.vault.get(contract)`. That records the `secret` effect and returns
  `Redacted` — `.reveal()` belongs at the SDK / HMAC boundary, not in return values.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare the contract

```typescript title="src/core/vault.ts"
import { vault } from "okengine";
import { z } from "zod";

export const stripeKey = vault.secret("STRIPE_KEY", {
  description: "Stripe secret API key",
  schema: z.string().startsWith("sk_"),
  rotate: "90d",
  dev: "sk_test_local",
});
```

Import this module before `oke()` so auto-registry can adopt the contract (or pass it in
`oke({ secrets: […] })`).

</Step>

<Step>
### Read inside `do`

```typescript title="src/flows/payments/charge.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { stripeKey } from "@/core/vault";

export const charge = on(
  http
    .post({
      in: z.object({ amount: z.number().int().positive() }),
    })
    .gate(member),
  flow({
    do: async ({ amount }, fx) => {
      const key = await fx.vault.get(stripeKey);
      const stripe = new Stripe(key.reveal());
      const intent = await stripe.paymentIntents.create({ amount, currency: "usd" });
      return { id: intent.id };
    },
  }),
);
```

</Step>

<Step>
### Confirm the effect

Manifest / Console list `secrets: ["STRIPE_KEY"]` on `payments.charge`. Logs that stringify
`key` show `[redacted]`.

</Step>

</Steps>

## Progressive Patterns

From a bare name to schema, cadence, tenancy, and sensitivity overrides:

<Tabs items={["Minimal", "Schema", "Per-tenant", "Shared"]}>

<Tab value="Minimal">

Name only — description helps boot gaps and Console:

```typescript
export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
  description: "HMAC secret for outbound webhooks",
});
```

</Tab>

<Tab value="Schema">

Attach a schema for tooling and human contracts (Zod / Standard Schema). Prefer validating at the
provider edge when the remote value must match a prefix:

```typescript
export const resendApiKey = vault.secret("RESEND_API_KEY", {
  description: "Resend email delivery API key",
  schema: z.string().startsWith("re_"),
});
```

</Tab>

<Tab value="Per-tenant">

When `gate.auth.tenant` is on, contracts default to request-time paths
`{tenantId}/{name}` — they are **not** boot-gap fatal (value appears per tenant):

```typescript
export const tenantStripe = vault.secret("STRIPE_KEY", {
  description: "Per-workspace Stripe key",
  perTenant: true, // explicit; also the default when tenancy is on
});
```

`fx.vault.get(tenantStripe)` resolves under the active `fx.tenant.id`. Missing tenant throws
`TENANT_REQUIRED`. Opt out of isolation with `perTenant: false`.

**Consequence:** seed or `set` the tenant path (`acme/STRIPE_KEY`), not only the bare contract
name.

</Tab>

<Tab value="Shared">

A secret shared across all tenants while tenancy is enabled:

```typescript
export const platformWebhook = vault.secret("PLATFORM_WEBHOOK", {
  description: "Platform-wide webhook secret",
  perTenant: false,
});
```

Boot still requires a value for shared contracts.

</Tab>

</Tabs>

## Options Reference

Third argument shape is `VaultSecretOptions` on `vault.secret(name, options)`:

| Option        | Type      | Default                | Meaning                                                              |
| ------------- | --------- | ---------------------- | -------------------------------------------------------------------- |
| `description` | `string`  | —                      | Boot-gap and Console label                                           |
| `rotate`      | `string`  | omit ≈ `"never"`       | Cadence hint (`"90d"`) for Console posture — not automatic rotation  |
| `schema`      | Schema    | —                      | Declared validator for docs / tooling                                |
| `dev`         | `string`  | —                      | Dev-only fallback (`vault.fromDocker(role)` allowed)                 |
| `sensitive`   | `boolean` | `true`                 | Fingerprinted; Console never shows cleartext                         |
| `perTenant`   | `boolean` | `true` when tenancy on | Storage path `{tenantId}/{name}`; skipped in boot-gap scan when true |

Empty name throws `TypeError: vault.secret: name is required`.

## Reading secrets


> Vault Redacted physics: fx.vault.get returns a Redacted wrapper so fx.log, String, and JSON show [redacted]; only .reveal() yields cleartext at the provider boundary.


| Call                         | Returns            | Notes                                  |
| ---------------------------- | ------------------ | -------------------------------------- |
| `await fx.vault.get(handle)` | `Redacted<string>` | Preferred — capability from the handle |
| `await fx.vault.get("NAME")` | `Redacted<string>` | Same when the name is declared         |
| `key.reveal()`               | `string`           | One explicit cleartext escape          |
| `key.map(fn)`                | `Redacted<U>`      | Transform without exposing to callers  |
| `String(key)` / `toJSON`     | `"[redacted]"`     | Safe for accidental serialization      |

```typescript
const key = await fx.vault.get(stripeKey);

fx.log.info(`using ${key}`); // message contains [redacted]
// JSON.stringify({ key }) → { "key": "[redacted]" }

const client = new Stripe(key.reveal());
```

Secret access is **never journaled** — durable replay re-reads the live value so a rotated
credential is not resurrected from the journal.

## Effects & Manifest

Calling `fx.vault.get` records `effects.secrets` with the contract name. Declare the same list
explicitly when you want Manifest truth without inference:

```typescript
flow("payments.charge", {
  effects: { secrets: ["STRIPE_KEY"] },
  do: async (_, fx) => {
    await fx.vault.get(stripeKey);
  },
});
```

Capability enforcement: reading a name not allowed by the Flow’s effects fails the `secret`
capability check.

## Boot gaps

Missing non-tenant contracts fail boot with every hole listed once:

```text
vault boot failed — 2 missing secret(s):
  - STRIPE_KEY: Stripe secret API key
  - APP_WEBHOOK_SECRET: HMAC secret for outbound webhooks
```

Fill gaps with:

| Layer        | How                                                       |
| ------------ | --------------------------------------------------------- |
| Driver       | `oke vault set NAME`, managed provider write, memory seed |
| process.env  | Export `NAME=…` in the host / CI                          |
| `.env.local` | Local override file (gitignored)                          |
| `dev:`       | Contract fallback — only when dev fallbacks are allowed   |

On a TTY, `oke dev` prompts for each gap before the app starts and writes values
into `.env.local` (same store as `oke vault set`). Non-interactive runs still
fail with `VaultBootError` listing every hole.

## Troubleshooting

<Accordions>

<Accordion title="VaultBootError lists this secret">
  No resolution layer supplied a value. On a TTY, `oke dev` prompts into `.env.local`; otherwise use
  `oke vault set` / env / managed, or a `dev:` fallback. Per-tenant contracts are skipped at boot —
  seed the tenant path.
</Accordion>

<Accordion title='vault: secret "…" is not loaded'>
  Boot succeeded but the name is absent from the merged bag (or you never declared it). Import the
  declaring module; confirm auto-registry or `oke({ secrets: [handle] })`.
</Accordion>

<Accordion title="TENANT_REQUIRED on fx.vault.get">
  Tenancy is on, the contract is per-tenant, and `fx.tenant.id` is null. Resolve a tenant on the
  request, or set `perTenant: false` for a platform-wide secret.
</Accordion>

<Accordion title="Capability / secret effect denied">
  The Flow’s `effects.secrets` (inferred or declared) must include the name you `get`. Touch the
  handle inside `do`, or list the name explicitly.
</Accordion>

<Accordion title="Redacted still leaked through a third-party logger">
  Vault scrubs `fx.log` and known substrings. Direct `console.log(key.reveal())` or foreign sinks
  are outside the redactor — keep cleartext off those paths.
</Accordion>

</Accordions>

## Learn more

- [Vault overview](/docs/elements/vault) — resolution chain and drivers
- [Config](/docs/elements/vault/config) — cleartext contracts vs secrets
- [Key Rotation](/docs/elements/vault/rotation) — version and master-key rotate
- [Tenancy](/docs/elements/gate/tenancy) — `fx.tenant.id` for per-tenant paths
- [fx](/docs/reference/fx) — full `fx.vault` table

## Next

<Cards>
  <Card
    title="Config"
    description="Non-sensitive vault.config and vault.env helpers."
    href="/docs/elements/vault/config"
  />
  <Card
    title="Key Rotation"
    description="Rotate secret versions and the master key."
    href="/docs/elements/vault/rotation"
  />
  <Card
    title="Vault Overview"
    description="Resolution chain, Redacted, and drivers."
    href="/docs/elements/vault"
  />
</Cards>
