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

<VaultRedacted />

| 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>
