> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent vault

# Agent vault

**Status: the store and operator surface (step 1, #470) and the use-sites —
`fill_secret` in the browser, `workspace_exec secrets:` in the sandbox, the
prompt guidance (step 3) — are built; drop links (step 2) are in #472.** This document is the design for letting
an agent hold secrets a human gave it — a Sentry token, a website login, an API key for a
service Mesh has no connector for — and *use* them without reading them, in the
browser and in the sandbox. It also specifies the one-time drop link through
which a human hands a secret to an agent without the value ever touching Slack.

## Two kinds of secret, two firewalls

Mesh already holds secrets, and none of them are the agent's to read:

| Store | What it holds | Who decrypts it | Does the model ever see it |
| - | - | - | - |
| `secrets` table (`internal/secrets` crypto, `0002`/`0005`) | Connector credentials, model keys, MCP logins, browser-provider keys, the agent's GitHub PAT | The connector, the MCP transport, the web adapter, the workspace authorizer | Never. The GitHub PAT reaches the sandbox shell, which is as close as any of them get. |
| **Agent vault** (this document, new table) | Secrets a human gave the agent for the agent's own work | Only the three vault use-sites below, on the agent's explicit request | Names and descriptions, yes. Values, only through `vault_reveal`, if that tool is enabled at all. |

The first store is infrastructure: it exists so the agent *does not* need the
secret. The agent asks for a Linear issue and the MCP transport adds the bearer
token; the agent never learns it, and the token is useless to an injected prompt.
That property is the whole reason connectors exist, and the vault must not erode
it. Two consequences:

* **The vault is a separate table with a separate code path, not a new `kind`
  in `secrets`.** `agent.Loader.Load` decrypts every `owner_type='agent'` row of
  `secrets` into `Agent.Secrets` at boot (`internal/agent/loader.go:360`); a vault
  entry stored there would sit decrypted in process memory beside the Slack
  token. The `secrets` table also carries triggers (`0074`, `0077`) that bump the
  workspace credential revision on `github_pat` writes, and a hard-delete query
  with no tombstone. None of that is what the vault wants. `internal/vault`
  imports `internal/secrets` for `Encrypt`/`Decrypt`/`Fingerprint` — the crypto
  and the master key are shared, the rows are not.
* **No vault tool can reach `secrets`, and no infrastructure path can reach the
  vault.** `effects.Runtime.Secrets` (the `SecretResolver` that hands the GitHub
  PAT to the workspace authorizer) does not learn about vault entries; the vault
  tools do not receive a `SecretResolver`. A test in `internal/vault` asserts
  that its sqlc query file references no table but `agent_vault_entries` and
  `agent_vault_drops`, so the firewall is a build-time fact rather than a
  convention.

Why share the master key at all, then? Because `MESH_AGENT_MASTER_KEY` is the
one credential an operator holds personally, and `docs/secrets-and-recovery.md`
is the runbook for it. A second key is a second runbook, a second thing to lose,
and a second thing a backup is useless without. The blast radius the user wants
separated is *who can read the plaintext at runtime*, and that is a code-path
property, not a key property. `rotate-key` and `verify-secrets` are extended to
cover `agent_vault_entries` in the same transaction and the same report, so the
existing drill stays one drill.

The vault does not use the master key *directly*, though. It derives its own
key with HKDF-SHA256 (`info = "mesh/vault/v1"`) and seals with that. Same
master key, same runbook, same rotation — `rotate-key` re-derives from the next
key — but a vault ciphertext handed to the `secrets` decrypt path, or the
reverse, fails authentication instead of opening. The firewall is then also a
fact about the bytes, not only about which function was called.

### Two things the existing scheme does that the vault must not copy

The existing `secrets` crypto is sound: AES-256-GCM from the standard library,
a fresh 96-bit random nonce per seal, the key normalized to 32 bytes, key
fingerprints that name a key without revealing it, the key itself nowhere near
the database. Nothing in it needs a third party, and nothing in it should.
Two details are fine for API tokens and wrong for the passwords a vault holds:

* **The hint is a hash of the plaintext.** `upsertEncryptedSecret` stores
  `RedactedHint(plaintext)` — the first 6 bytes of an unsalted SHA-256 of the
  secret — in `redacted_hint`, beside the ciphertext, and uses it as the GCM
  AAD. For a 40-character API token that is harmless. For a human password it
  is a 48-bit offline oracle that needs no master key: an attacker with a dump
  tests guesses at SHA-256 speed, and two agents holding the same password have
  the same hint. The vault's AAD is the row's identity, `vault:<agent_id>:<name>`,
  which is what AAD is for (a ciphertext moved to another row fails to open) and
  reveals nothing about the value. The vault has no hint column.
* **A short master key is hashed once, unsalted.** `normalizeKey` takes 32 bytes
  as the key and SHA-256s anything else, with no stretching, and the key's
  fingerprint sits in `secret_keys` as a check oracle. A master key generated as
  the runbook says — 32 random bytes — is unaffected. A memorable passphrase
  would be brute-forceable from a dump. That is a property of the master key,
  not of the vault, and the fix (an entropy floor at boot, or HKDF with a stored
  salt) is a small separate change noted in [What is not built](#what-is-not-built).

## Use by reference, not by value

The default assumption — "the agent asks for a secret and gets the value" — is
the wrong primitive, for a reason specific to Mesh: **everything that enters the
model context is persisted in plaintext.** `model_calls.request_body` holds the
exact bytes sent to OpenRouter (`0080`), prompt snapshots hold the system prompt,
`tool_observations` hold tool results, and memory extraction reads the
transcript. A revealed value is therefore written to Postgres several times, sent
to a third-party model provider, and eligible to be remembered. Slack is the
exposure the drop link avoids; the ledger is a larger one.

So the vault is designed so that the common cases never reveal a value. The
agent names the secret and Mesh applies it at the boundary where it is needed:

| Use-site | How the agent invokes it | Where the plaintext goes | What the model sees |
| - | - | - | - |
| **Browser** | `browser_act` action `fill_secret` with `{ref, secret: "<name>"}` | Decrypted in the browser runtime, inserted into the focused element over CDP (`Input.insertText`), discarded | The post-action page observation. Password fields render nothing; a username field shows what was typed, as it would to a human looking at the screen. |
| **Sandbox** | `workspace_exec` gains `secrets: ["<name>", …]` | Delivered into `$HOME/.mesh/grants.env` as `MESH_SECRET_<NAME>` (name uppercased; 0600, same mechanism as `GH_TOKEN`) for that command, redacted from stdout/stderr/file reads like the PAT | Command output, with exact plaintext replaced by `[REDACTED]`. A shell that holds a value can transform it; this is the same non-barrier the PAT accepts, stated the same way. |
| **Model context** | *(not built)* | — | Nothing. See [Reveal](#reveal). |

The browser case is the one that motivates the feature and it is already half
built. `browser_act` **refuses password fields today** (`internal/webbrowser/page.go:48`,
`if(e.type==='password')return {error:'password'}`), with a tool description
that says an operator can import authenticated state instead. `fill_secret` is
the action that hole was left for: it lifts the password-field refusal *only*
for a value Mesh types itself. Once the agent has logged in, the existing
agent-owned profile (`browser:profile` in `secrets`, Playwright storage-state,
restored over CDP on every new session — `internal/webbrowser/profile.go:79`)
keeps the cookies, so the next run does not need the password again. Neither
Cloudflare Browser Run nor Browserbase offers credential injection; they offer
cookie persistence, which Mesh already drives. Server-side fill is the correct
layer and the vendor-neutral one.

Second factors are a bounded follow-on: an entry flagged `totp` holds a seed,
and `fill_secret` on it inserts the current code. Not in the first delivery.

## The data

```sql theme={null}
CREATE TABLE agent_vault_entries (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agent_id        uuid NOT NULL REFERENCES runtime_agents(id) ON DELETE CASCADE,
  name            text NOT NULL,            -- [A-Za-z][A-Za-z0-9_]{0,63}; env-safe
  description     text NOT NULL DEFAULT '', -- shown to the model and the operator
  ciphertext      bytea NOT NULL,
  nonce           bytea NOT NULL,
  key_version     text  NOT NULL,           -- secrets.Fingerprint of the MASTER key
  -- no hint column: the GCM AAD is 'vault:<agent_id>:<name>', reconstructed from the row
  source          text  NOT NULL CHECK (source IN ('drop','operator')),
  created_by_user_id uuid REFERENCES users(id),   -- operator path only
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  last_used_at    timestamptz,
  UNIQUE (agent_id, name)
);

CREATE TABLE agent_vault_drops (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash      bytea NOT NULL UNIQUE,    -- sha256 of a 32-byte url token
  agent_id        uuid NOT NULL REFERENCES runtime_agents(id) ON DELETE CASCADE,
  name            text NOT NULL,
  description     text NOT NULL DEFAULT '',
  conversation_id uuid NOT NULL REFERENCES conversations(id),
  message_event_id uuid NOT NULL REFERENCES message_events(id), -- the agent's request
  requested_by_actor_id uuid REFERENCES actors(id),             -- whoever the agent was answering
  lineage_id      uuid NOT NULL,            -- shared by a drop and every renewal of it
  renewals        int  NOT NULL DEFAULT 0,  -- how many times this lineage has been renewed
  created_at      timestamptz NOT NULL DEFAULT now(),
  expires_at      timestamptz NOT NULL,     -- submit window: created_at + 15 minutes
  renewable_until timestamptz NOT NULL,     -- created_at + 72 hours
  consumed_at     timestamptz,
  consumed_from   inet,
  superseded_at   timestamptz               -- set on the old drop when a renewal is issued
);
```

One row per secret, not one JSON blob per agent. The blob is not simpler: each
value has to be sealed separately anyway (`secrets.Encrypt` is one plaintext,
one nonce, one AAD), a blob needs read-modify-write under a lock for every
write, and it cannot carry per-entry provenance, `last_used_at`, or a
per-entry delete. The "data bag" the agent perceives is the set of its rows.

`name` is constrained to an environment-variable-safe identifier because it
*becomes* one in the sandbox. Writes replace in place (`ON CONFLICT (agent_id,
name)`); the vault keeps no value history, for the same reason `secrets` keeps
none — a history of secrets is a larger secret.

## The drop link

The agent cannot receive a secret through Slack: the message persists in Slack's
own store, in the conversation graph, and in the prompt. So the agent asks for
it out of band:

1. The agent calls `vault_request {name, description}`. Mesh creates an
   `agent_vault_drops` row anchored to the current conversation and the agent's
   outgoing message, and returns `PUBLIC_URL/drop/<token>` and the expiry. The
   agent includes the link in its reply: *"I'll need a Sentry auth token for
   this. Drop it here (link expires in 15 minutes): …"*
2. A human opens the link. `GET /api/vault-drops/{token}` returns the agent's
   display name, the secret's name and description, who asked for it, and when
   the link expires — enough for the human to know what they are handing over
   and to whom. It does **not** consume the token: Slack's link unfurler and
   every corporate link scanner issue a GET, and a link that dies on preview is
   a link that never works.
3. The human submits. `POST /api/vault-drops/{token}` with `{value}` runs, in
   one transaction: `UPDATE agent_vault_drops SET consumed_at = now() WHERE
   token_hash = $1 AND consumed_at IS NULL AND expires_at > now()` (of two
   racing submits exactly one gets the row), then the encrypted upsert into
   `agent_vault_entries` with `source='drop'`. The page confirms and shows
   nothing it was given.
4. Mesh wakes the agent: `CreateRun` + `AddRunTrigger` anchored to the agent's
   request message, with a `prepareVaultDrop` runtime hook that puts *"the
   secret `<name>` you requested is now in your vault"* into the turn — the
   same pattern schedules and commitments use (`internal/schedule/scheduler.go:205`,
   `internal/app/runtime.go:384`). The agent's next reply lands in the
   originating thread and says, in the thread, that it received `<name>`. That
   sentence is load-bearing; see below.

### Two clocks: fifteen minutes to submit, three days to come back

A link that works for fifteen minutes is the right exposure for a bearer token
in a channel and the wrong experience for a human. People get pulled into a
meeting, go home, come back Monday. So a drop has two clocks:

* **`expires_at`**, fifteen minutes. Until then the link accepts a value.
* **`renewable_until`**, seventy-two hours. Between the two, opening the link
  shows *"this link has expired"* and a single button: **Send me a new link.**
  Pressing it calls `POST /api/vault-drops/{token}/renew`, which in one
  transaction marks the old drop `superseded_at`, creates a new drop in the same
  lineage with fresh clocks, and wakes the agent the same way a submission does
  — with a notice that says *"the drop link for `<name>` expired and the person
  asked for a new one; here it is: …"*. The agent posts the new link in the
  original thread, through the original connector. The human's next message
  in Slack or Telegram is the new link, exactly where the old one was.
* After `renewable_until`, and for any superseded or consumed token, the link
  is a 404 indistinguishable from a token that never existed.

Why renewal is safe to leave unauthenticated: the worst a stranger holding an
expired link can do is make the agent post a *new* link into the same thread
the old one was already in, to the same people who could already see it. The
stranger does not receive the new link. What they could do is make the agent
post it repeatedly, so renewal is bounded: only the latest drop in a lineage
can renew, a lineage renews at most five times, and renewals share the public
routes' throttle. Those bounds are the whole defense and they are enough.

### Not for crawlers

The dashboard has never had a `robots.txt`; nothing in it is linkable from
the open web, so nothing has asked. The drop link is the first Mesh URL that
will be pasted into chat tools that unfurl, forward, and index. Two additions,
both install-wide because nothing Mesh serves should be indexed:
`GET /robots.txt` answering `User-agent: *` / `Disallow: /`, and
`X-Robots-Tag: noindex, nofollow` added to `securityHeaders`
(`internal/app/app.go:1170`). Neither is a security control. They are
politeness toward crawlers that honor them, and the token's real protection is
that it is single-use, short-lived, and hashed at rest.

The implementation is the invite flow (`internal/admin/operators.go:447-514`,
`handleGetInvite`/`handleAcceptInvite`) with a different payload: 32 bytes from
`crypto/rand`, base64url; only the SHA-256 stored; unknown, expired, and consumed
all answer the same 404; `Cache-Control: no-store`; Origin must match host on the
POST; the shared per-source throttle (`internal/admin/throttle.go`) with an empty
account key; `{token}` already scrubbed from the activity log (`audit.go:149`).
The SPA path `/drop/{token}` joins `spaPaths` (`internal/app/app.go:1182`) and the
React route sits beside `/invite/:token` inside `AuthShell`. The existing CSP,
`Referrer-Policy: no-referrer`, and `frame-ancestors 'none'` apply unchanged.

### What the link does and does not protect against

The link is a bearer credential posted into a channel. It is unauthenticated on
purpose — requiring a Mesh login would exclude exactly the colleague the agent is
helping — so be precise about the threat:

* **Interception** (someone else reads the thread and opens the link first).
  Mitigated by the fifteen-minute submit window and single use. The legitimate
  human then sees a 404 and knows something is wrong, and the agent's in-thread
  confirmation names the moment it happened.
* **Poisoning** (someone submits a token for *their* account, so the agent acts
  in a place the attacker controls or leaks the task's data there). This is the
  real risk, and it is why the agent's confirmation is posted in the thread: a
  secret arriving that the requester did not send is visible to the requester.
  `consumed_from` is recorded for the audit trail. A deployment that wants
  stronger assurance can require that drops be opened by a signed-in operator;
  that is a flag, not a redesign, and is not in the first delivery.
* **Exfiltration of the value** through the drop itself. The value goes from the
  human's browser to Mesh over TLS and into ciphertext. It is never echoed,
  never logged (the admin middleware logs the path with the token scrubbed and
  never the body), never in a URL.
* **Replay against the model.** The drop is not a Slack message, so the value is
  not in the conversation graph or the prompt. The prompt sees only the
  wake-up notice naming the entry.

`tools.md` says a human handoff must not become a private modal — `awaiting_input`
is an attributed thread message anyone can answer. The drop link keeps that
property where it matters: the *request* and the *receipt* are both thread
messages anyone in the thread can see. Only the value takes the side channel,
because the value is the one thing that must not be a thread message.

## The tools

The vault verbs reach the turn's registry through the dynamic provider set
(`tool.Provider`, the door remote MCP and hosted web tools already use):
`vault.Service` implements `Definitions(ctx, Scope)` and is appended to the
engine's `DynamicTools` beside them, scoped to the agent the trigger was
authenticated for. It is a provider rather than a new field on `turn.Engine`
because `Engine` is copied by value into every builder method, and growing it
is a cost paid forty times a turn. The consequence is that the verbs are part
of the progressive-activation index for agents with many integrations — they
carry a `vault` package hint so `tool_search` finds them — rather than being
unconditionally in every prompt. They are `Internal: true` — they write only
storage Mesh owns — except `vault_request`, which is an effect because it sends
a link into the world and must cross the commit boundary once.

| Tool | Arguments | Returns | Notes |
| - | - | - | - |
| `vault_list` | — | `[{name, description, source, created_at, last_used_at}]` | Never values, never hints. This is the "do I already have it?" check the agent runs before asking. |
| `vault_request` | `name, description` | `{url, expires_at}` | Creates a drop. Refuses a name that already exists unless `replace: true`, so the agent does not ask for what it has. |
| `vault_forget` | `name` | — | Hard delete. |

Plus the two use-site extensions that are not tools of their own:

* `browser_act` action `fill_secret`: `{ref, secret}`. Validation mirrors `type`
  (input or textarea, writable, attached, unobscured) *without* the
  password-field refusal, which stays in force for every other action. The
  value is opened in the browser runtime (`webbrowser.Service.WithVault`, wired
  by `webcap/catalog`), typed over CDP, and discarded; a `text` or `key` beside
  `secret` is refused at validation so the typed path cannot carry a value.
  Stamps `last_used_at`.
* `workspace_exec` parameter `secrets: [name, …]` (at most 16): each named
  entry is opened through `effects.Runtime.Vault` for the run's agent and added
  to the command's environment as `MESH_SECRET_<NAME>` through the same grants
  file the run's credentials use (`workspace.Handle.WithEnv`, lifted onto
  `effects.EnvGranter` by the app's workspace adapter). The grants file is
  rewritten when its digest changes, so a later `workspace_exec` naming
  different secrets re-delivers without a new sandbox. The entries are
  **withdrawn from the grant file when the command returns**, so a later
  `workspace_read_file` of `.mesh/grants.env` sees the run's grants and not a
  secret an earlier command asked for; a withdrawal that fails is reported on
  the call. Redaction covers the run's credential grants by key and every
  per-command value at any length — a one-character vault value makes
  redaction noisy, never leaky. Vault entries do **not** go
  through `workspace_credential_issuances` — that table's kinds are a CHECK of
  `github_pat`/`gitlab_pat` and its revision machinery exists to invalidate a
  sandbox when the *operator* changes a grant; a vault entry is the agent's own
  and is delivered on the agent's own request. Rotating or forgetting an entry
  does not tear down a running sandbox; the next `workspace_exec` naming it gets
  the new value or an error.

The prompt names the tools in the existing `Tools:` line, and a vault section
(`prompt.vaultSection`, selected by `Context.Vault` the way the coding section
is selected by `CodeWork`: offered now or activatable this turn) tells the agent
what the vault is for: check `vault_list` first; use a secret through
`fill_secret` or `secrets:`, never by asking a human to paste it into the
thread; never write a value into a reply, a file the user will read, a commit,
or a log; when a value is needed that the vault lacks, say so in the thread and
ask for it the way the offered tools allow — which is `vault_request` once drop
links are offered.

### Reveal

There is deliberately no `vault_reveal`. Every case that has been named — log in
to a website, call an API from the sandbox — is served by a use-site that keeps
the value out of model context, and a tool that returns the value would put it
in `model_calls.request_body`, in prompt snapshots, in tool observations, in
memory extraction's view of the transcript, and on the wire to the model
provider. If a real case arrives that none of the use-sites can serve — an MCP
tool argument is the likely shape — the design is: the run records the values
it revealed, the ledger replaces exact occurrences with `[REDACTED:vault:<name>]`
before persisting, the tool description says plainly that the value still
reaches the model provider, and the tool is a per-agent opt-out. That is a
bounded addition when it is needed, and it is not needed now.

## Operator surface

The operator can see what an agent holds, remove it, and add to it — the
authenticated path to the same rows — but can never read a value back.

| Method | Route | Authority | Body / response |
| - | - | - | - |
| GET | `/api/agents/{slug}/vault` | agent access | `{entries: [{name, description, source, created_at, updated_at, last_used_at}]}` |
| PUT | `/api/agents/{slug}/vault/{name}` | agent access | `{value, description}` → `200` with the entry's metadata (one list element, never the value). Upsert, `source='operator'`. |
| DELETE | `/api/agents/{slug}/vault/{name}` | agent access | `204` |
| GET | `/api/agents/{slug}/vault/drops` | agent access | Open drops: `{name, description, created_at, expires_at}` — never the token. |
| DELETE | `/api/agents/{slug}/vault/drops/{id}` | agent access | Revoke an open drop. |
| GET | `/api/vault-drops/{token}` | **public** | `{agent_name, name, description, requested_by, expires_at, state: "open" \| "expired"}` — `expired` means renewable; anything else is a 404 |
| POST | `/api/vault-drops/{token}` | **public** | `{value}` → `204` |
| POST | `/api/vault-drops/{token}/renew` | **public** | → `204`; the new link arrives in the thread, never in this response |

All responses `Cache-Control: no-store`. The dashboard gets a Secrets
`SectionCard` on the agent page linking to `/agents/:slug/vault`, a page
built from `Card`/`Label`/`Input`/`Button`/`Dialog` that lists entries with
their provenance and last use, an "Add secret" dialog, per-row delete, and the
open-drops list. The public drop page is `web/src/pages/Drop.tsx` beside
`Invite.tsx`: fetch metadata, show agent and secret name, one password-type
input, submit, confirm. Both routes are added to `docs/operator-api.md` in the
feature section, the index, and the public-routes paragraph, and to the
throttled-routes list.

## Delivery

Three pull requests, each useful alone:

1. **Store and operator surface.** Migration, `internal/vault` (HKDF subkey,
   seal, open, list, upsert, delete, the no-other-tables test),
   `rekey`/`verify-secrets` coverage with an integration test on a scratch
   database, the agent routes, the Secrets page, `vault_list`/`vault_forget`,
   `robots.txt` and `X-Robots-Tag`. After this an operator can hand an agent a
   secret and the agent can see that it has it.
2. **Drop links.** `agent_vault_drops`, `vault_request`, the three public
   routes, `/drop/{token}` with its expired-and-renewable state, the wake-up
   run, `prepareVaultDrop`, docs. After this the agent can ask a human for a
   secret in a thread without the value crossing Slack, and the human can come
   back on Monday.
3. **Use-sites.** `fill_secret` (lifting the password refusal for Mesh-typed
   values), `workspace_exec secrets:`, redaction generalized to the handle's
   env, prompt guidance. Built; see the use-site table above.

Each PR carries its own `docs/` update; this file becomes normative as the
pieces land and the "Status" line at the top changes.

## What is not built

* **`vault_reveal`.** See [Reveal](#reveal). Waits for a case no use-site serves.
* **TOTP entries.** A seed the browser fill turns into a current code.
* **Operator-only drops.** A per-install flag requiring a signed-in operator to
  open a drop link, for deployments that want poisoning ruled out rather than
  made visible.
* **A master-key entropy floor.** `normalizeKey` accepts any string; the runbook
  says 32 random bytes and nothing enforces it. A boot-time refusal of a key
  under, say, 16 bytes of decoded entropy is a small change to `internal/secrets`
  and `rekey.CheckMasterKey`, independent of the vault.
* **Salting the existing hints.** `redacted_hint` for `secrets` rows is an
  unsalted 48-bit hash of the plaintext. Harmless for the API tokens that table
  holds today; worth replacing with a row-identity AAD the next time that
  table's envelope changes, because it becomes a weakness the day a low-entropy
  value is stored there.

## Design guardrails

* do not store vault entries in `secrets` — the loader decrypts that table's
  agent rows at boot, and the two stores must have different readers
* do not give a vault tool a `SecretResolver`, and do not give the effects
  runtime a vault handle — the firewall is who holds which decrypt path
* do not return a vault value from any `/api` route, ever, to anyone
* do not consume a drop on GET — link previews are GETs
* do not let a drop accept a value for more than fifteen minutes, and do not let
  a human hit a dead end for three days — renewal posts a new link into the same
  thread, bounded per lineage
* do not hash a vault plaintext into anything stored — the AAD is the row's
  identity, and there is no hint column
* do not seal vault rows with the raw master key — the HKDF subkey is what makes
  the two stores' ciphertexts mutually unreadable
* do not make reveal the default — the ledger persists model context in
  plaintext, so the use-sites exist to keep values out of it
* do not hard-code the sandbox redaction list — redact every delivered value
* do not route the drop's value through the conversation graph — the wake-up
  notice names the entry, never the value
* do not add a second master key for the vault — one key, one runbook, two
  tables, both rotated in one transaction


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.