> For the complete documentation index, see [llms.txt](https://docs.labs.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.labs.ai/security-and-compliance/secrets-vault.md).

# Secrets Vault

EDDI includes a built-in secrets vault for managing sensitive values like API keys, tokens, and passwords. Secrets are encrypted at rest, referenced via URI syntax, and automatically scrubbed from logs and API exports.

## Architecture

```
┌─────────────────┐     ┌──────────────┐     ┌───────────────────┐
│  Configuration   │────>│ SecretResolver│────>│  VaultSecretProv.  │
│  (JSON configs)  │     │  (resolves    │     │  (envelope crypto  │
│  ${vault:..} │     │   at runtime) │     │   + persistence)   │
└─────────────────┘     └──────────────┘     └───────────────────┘
                                                       │
                                              ┌────────▼────────┐
                                              │  EnvelopeCrypto  │
                                              │  (AES-256-GCM +  │
                                              │   PBKDF2 KEK)    │
                                              └─────────────────┘
```

### Core Components

| Component                              | Package            | Purpose                                                                                    |
| -------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------ |
| `SecretReference`                      | `secrets.model`    | Value object: `tenantId/keyName` URI parsing                                               |
| `EnvelopeCrypto`                       | `secrets.crypto`   | AES-256-GCM encryption with envelope key wrapping                                          |
| `ISecretProvider`                      | `secrets`          | SPI for reading/writing encrypted secrets, plus `seal`/`unseal`                            |
| `SealedDataRotationParticipant`        | `secrets`          | SPI joining DEK rotation's sweep — implemented by anything else that stores `seal`-ed data |
| `VaultSecretProvider`                  | `secrets.impl`     | Production implementation with envelope crypto + persistence                               |
| `SecretResolver`                       | `secrets`          | Resolves `${vault:...}` references to plaintext at runtime                                 |
| `IRestSecretStore` / `RestSecretStore` | `secrets.rest`     | JAX-RS endpoints for secret CRUD and key rotation                                          |
| `SecretScrubber`                       | `secrets.sanitize` | Removes plaintext secrets from export payloads (leaves `${vault:...}` references intact)   |
| `SecretRedactionFilter`                | `secrets.sanitize` | Regex-based log redaction for API keys and tokens                                          |
| `ISecretPersistence`                   | `secrets.persist.` | DB abstraction (MongoDB default, PostgreSQL via profile)                                   |

## Secret References

Secrets are referenced in configuration JSON using the vault URI syntax:

**Short form** (uses `default` tenant):

```
${vault:keyName}
```

**Full form** (explicit tenant):

```
${vault:tenantId/keyName}
```

* **tenantId** — tenant namespace (e.g., `default`, `acme-corp`)
* **keyName** — the secret name (e.g., `openai-api-key`)

### Where Vault References Work

| Configuration Type                    | Fields Resolved                        |
| ------------------------------------- | -------------------------------------- |
| **HTTP Calls** (`httpcalls.json`)     | URL, headers, body, query parameters   |
| **LangChain** (`langchain.json`)      | `apiKey` and other model configuration |
| **Property Setter** (`property.json`) | Values with `scope: secret` auto-vault |

In an HTTP call, a vault reference is resolved **only where the configuration wrote it**: in the template of that URL, header, body or query parameter, or as the value of a property the template names that EDDI itself auto-vaulted (`Bearer {properties.apiKey}` holding `${vault:<agentId>.u<userHash>.<nonce>.apiKey}`). The same applies to `${eddivault:…}`, `${connection:…}` and `${caller:…}`. A reference that arrives through conversation data — user input, a model reply, an API response, client context — refuses the call instead of being resolved, with an error naming the field. Grants are checked at deploy time, so without this rule a user could have a template substitute any secret of the tenant.

**Who typed it does not matter.** The rule is about where the reference came from, not who wrote it, so there is no way to make a conversation-supplied reference acceptable: an admin pasting `${vault:openai-prod}` into a chat, a model copying one out of a configuration it read, and a reference in a tool call all arrive as conversation data and are refused the same way. The error says so, and says what to do instead: **send the credential's NAME in a field the endpoint documents for that, or edit with a partial update that does not repeat the credential — never a reference.** For creating an agent over REST that field is [`vaultKeyName`](#reusing-one-key-across-agents): a bare name (`"openai-prod"`) with `apiKey` left empty requires the entry to already exist and points the new agent at it. A bare name contains no `${…}`, so this guard has nothing to refuse, and the [deploy-time grant check](#agent-grants-allowedagents) still applies: a new agent is not yet on a restricted secret's `allowedAgents`, so its first deploy is refused and explained, and an administrator grants it (see [New agents always need adding](#new-agents-always-need-adding)). Both tools the Platform Operator writes agents with, `setupAgent` and `createApiAgent`, carry `vaultKeyName` for exactly this reason. The operator cannot read the vault and does not grant: it asks the admin for the name, reports a `VAULT_GRANT_MISSING` refusal, and points at the Manager's grant dialog. It must not set `grantReferencedSecrets` (the admin opt-in described there) even though that field is part of the setup body schema it is generated from.

For a tool call that needs a human's approval, the same refusal is made **before** the approval is requested: a call certain to be refused is returned to the model with the explanation instead of being put in front of a person (see [Request pinning](/conversations-and-orchestration/hitl.md#request-pinning--approval-binds-to-a-request-not-a-tool-name)).

This holds through `${vars:…}` as well. A global variable may itself hold a vault or connection reference, so a `${vars:…}` the configuration wrote still resolves through to the secret, while one that arrived through conversation data refuses the call — the check runs again after variable expansion, against the configured template expanded the same way.

Because agent-secret grants are checked at **deploy** time, a global variable that resolves to a secret can be used to redirect a deployed agent's credentials past that check by editing the variable after deployment. To close that, **only an `eddi-admin` may store a global variable whose value contains a `${vault:…}`, `${eddivault:…}` or `${connection:…}` reference** — a non-admin editor writing such a value is refused with `403`. Plain-literal variables are unaffected, and callers on a deployment with authentication disabled are out of scope (there is no editor/admin distinction to enforce).

The auto-vaulted-property case rests on **provenance, not on what the value looks like**. A `scope: secret` instruction stores its vault reference as an ordinary conversation property, so the string `${vault:<agentId>.u<userHash>.<nonce>.apiKey}` is one anything that can write a property could produce — a `valueString` of `{memory.current.input}` and a user who types it, a model reply, an API response copied into a property. The property therefore carries an `autoVaulted` marker, written by the auto-vaulting code and by nothing else, and the reference is resolved only when that marker is present. On top of it the reference must still name this agent, this user and the property the template reads, **under this conversation's own tenant**.

Every auto-vaulted write gets **its own slot**, `<agentId>.u<userHash>.<nonce>.<property>` — the user hash is a truncated SHA-256 of the user id (the id itself never enters the vault), the nonce makes each write unique. The slot used to be `<agentId>.<property>`, one per agent, so every user and conversation of the agent shared it and the last writer's secret was what everyone's reference resolved to. Because slots are no longer overwritten they are deleted explicitly: when their conversation is permanently deleted, and on GDPR erasure (which reports `autoVaultedSecretsDeleted`). Overwriting the property keeps the previous slot, because undo restores the previous reference; conversation deletion sweeps every slot the conversation's undo history and redo cache still point to. If the vault cannot delete them, the conversation is **not** deleted (the request fails, the retention sweep retries on its next run), and a GDPR erasure keeps the user's conversations and reports both steps failed — the snapshots are the only record of a custom tenant the slots live in, so a retry needs them. A legacy `<agentId>.<property>` reference in an older conversation is still accepted, and never deleted by this cleanup — it may back other users' conversations.

The slot shape is **reserved**: the GDPR sweep recognises a user's slots by name, so `PUT /secretstore/secrets/{tenant}/{key}` and agent setup's `vaultKeyName` reject a key name in the form `<agentId>.u<16 hex>.<12 hex>.<name>` with `400`.

The same rule covers the builder parameters of an LLM task (`modelName`, `baseUrl`, …) and of its cascade steps and judge model: they are resolved against the vault after templating, so a reference conversation data put into one fails the turn instead of being resolved. A data-supplied `${vars:…}` is refused there too, because the registry expands it later. The prompts (`systemMessage`, `prompt`) are never resolved and may carry reference-shaped text.

A property with no marker is refused, which includes one stored in a conversation that began before this marker existed: an unmarked property and one written from conversation data are the same thing on disk, and accepting the pair would leave the case the marker exists to close open. Re-running the `scope: secret` instruction — having the user supply the secret again, or starting a new conversation — marks it.

A configured reference that **cannot** be resolved — no such secret, the provider failed, or the vault is disabled — also refuses the call, naming the field and the reference. The literal `${vault:name}` is never sent as a credential: it would come back as the API's own "invalid key", with nothing naming the cause.

The plaintext EDDI substitutes — a vault secret, a connection credential, the caller's token — is also removed by value from the response before it reaches conversation memory, template data, the LLM tool result or the log. Error bodies are redacted in full. A success body is data, so it is handled more carefully: a secret of 8 characters or more is removed from its text, a shorter one only where a JSON value is exactly the secret, and a number is never rewritten digit by digit.

The plaintext EDDI substitutes is redacted by value from everything it records about the request: the request record in conversation memory, the HITL approval preview and the request log line. The log line is built from the request's raw components and redacted *before* it is shortened, so a secret carrying a newline, or one longer than the log's body limit, cannot survive the formatting.

### Resolution Behavior

Vault references are resolved **at runtime** when the task executes, never stored as plaintext in conversation memory. The resolution flow:

1. Task reads configuration containing `${vault:...}` reference
2. `SecretResolver.resolveValue()` finds and replaces vault URIs
3. `VaultSecretProvider.resolve()` decrypts and returns the plaintext
4. Plaintext is used for the operation (e.g., HTTP call header)
5. **Plaintext is NOT stored in memory** — only the vault reference persists

**Caching:** Successfully resolved secrets are cached in a Caffeine cache (configurable TTL). Failed resolutions are **never cached**, ensuring newly created secrets resolve immediately without waiting for cache expiry.

### Editing a configuration that holds a reference: PATCH, not echo

A stored configuration carries your credential **references**, not secrets — `GET /llmstore/llms/{id}` returns `"apiKey": "${vault:anthropic-api-key}"`. Editing it by reading the document and sending the whole thing back with `PUT` therefore means *sending a reference back*, and that is the one thing a caller that is not a human at the Manager should not do:

* An agent's API call (the Platform Operator's tools are such calls, generated from EDDI's own OpenAPI spec) resolves `${vault:…}` in its rendered request **just before sending**. A document body that echoes a reference it read would reach the store as the **plaintext secret** — written into a new, exportable configuration version.
* It never gets that far: the reference in the body was not written by the call's own configuration, so the check above refuses the call. That is correct, and it is deliberately **not** relaxed with an "unchanged from the stored document" exemption — the check does not know the target document, and what the model read back is itself conversation data.

The way through is to not send what does not change. `PATCH /{store}/{id}?version=N` with `Content-Type: application/json-patch+json` applies an [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902) JSON Patch to version `N` and stores the result as a new version (returned in `Location`, like `PUT`):

```bash
curl -X PATCH "$EDDI/llmstore/llms/$ID?version=2" \
  -H "Content-Type: application/json-patch+json" \
  -d '[{"op":"replace","path":"/tasks/0/parameters/modelName","value":"claude-sonnet-4-5"}]'
```

The reference already in the document is untouched, and a patch that tried to *introduce* one is refused by the same check as any other call — so a caller restricted this way can edit everything in the document **except** its credentials. `add`, `replace`, `remove` and `test` all work; `test` makes an edit conditional on what it expects to find.

| Outcome                                                                                                                                                            | Status                                                           |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| New version stored                                                                                                                                                 | `200`, `Location` of the new version                             |
| `N` is not the current version                                                                                                                                     | `409`, the current resource URI in the body — nothing is applied |
| Not a patch, an unknown operation, a `test` that does not hold, a path that does not resolve, or a patched document the model rejects (unknown field, wrong shape) | `400` with the reason                                            |

The patched document goes through the same strict parse and the same pre-write checks as a `PUT` body, so nothing a `PUT` would refuse can be stored this way. Available on `llmstore/llms`, `apicallstore/apicalls` and `mcpcallsstore/mcpcalls` — the stores whose documents carry references. A patch is limited to 256 KiB and 200 operations: it is a diff, not a document.

## Secrets that belong to a space

With [workspaces](/security-and-compliance/workspaces.md) enforced, writing to the vault stays with `eddi-admin` — but every personal and team space also has a tenant of its own, which the space's members manage without an administrator (`/spacestore/secrets?space=…`, or the Manager's **Workspaces** page). An agent references such a secret explicitly, as `${vault:<tenant>/<key>}`, with the tenant id every response names. Deploying an agent that references a space's tenant requires the deployer to be a member of that space; the grant check below applies on top. See [Secrets and variables that belong to a space](/security-and-compliance/workspaces.md#secrets-and-variables-that-belong-to-a-space).

## Agent Grants (`allowedAgents`)

Every stored secret carries an `allowedAgents` list — the agent IDs permitted to use it, or `["*"]` for all agents. It is checked **when an agent is deployed**, not when a secret is resolved. What a violation costs is set by [the enforcement mode](#modes) — blocked, logged, or not checked at all.

**Nothing is granted by default.** Under `enforce` (the shipped default) a `PUT /secretstore/secrets/{tenantId}/{keyName}` that *creates* a secret must carry `allowedAgents` — the agent ids, or `["*"]` to grant every agent out loud — and a create without it is a **400** that says so. A secret used to be granted to every agent by a default three layers down when the field was left out; that is the one grant an operator running in enforce mode has said they do not want applied silently. The provider itself refuses a create without a grant in every mode, so no code path reaches the old default: the setup wizard and the conversation auto-vaulting pass their lists explicitly (`["*"]` and the agent's own id respectively). Under `warn` and `off` the REST endpoint still fills in `["*"]` for an omitted list, since those modes have opted out of the control. A **rotation** may omit the list in every mode and keeps the stored grant — the Manager's rotate dialog relies on that, and it can never widen anything. The grant endpoint (`PUT …/grant`) has always required the list.

### Why deploy time, not resolution time

Blocking at resolution would fail in the middle of a live conversation, after the agent is already serving users, and the operator would learn about the misconfiguration from a broken turn. The deploy-time check runs once, before any user is affected: in `enforce` mode a misconfigured agent simply does not come up — and the API says why (see [When a deploy is refused](#when-a-deploy-is-refused)), not only the server log.

The gate lives in `AgentFactory.deployAgent` — the one boundary every deployment path funnels through (REST administration, conversation-triggered deployment, the scheduled deployment poller). Placing it there rather than at each caller means it cannot be bypassed by reaching deployment through a different entry point.

### Modes

Configured with `eddi.vault.grant-enforcement`:

| Mode      | Behavior                                                           |
| --------- | ------------------------------------------------------------------ |
| `off`     | No check at all — the checker is never consulted                   |
| `warn`    | Violations logged at WARN, deployment proceeds                     |
| `enforce` | **Default.** Violations logged at ERROR, deployment is **blocked** |

Two parsing rules, both deliberate:

* **An unrecognized value fails startup** rather than falling back to a default. `grant-enforcement=enforced` silently behaving as `warn` would turn one typo into a security control that is off while appearing on.
* **Absent or blank resolves to `enforce`**, the shipped default — never to something weaker. Turning enforcement down is always explicit.

### What counts as granted

The check answers "is this provably ungranted?", and anything short of proof is treated as granted. An agent is allowed when its `allowedAgents` list:

* contains the agent's ID, or
* contains the `*` wildcard, or
* is `null` or empty — an unset list means unrestricted, not "deny all"

Uncertainty likewise never becomes a violation: unreadable metadata or a disabled vault resolve to *allowed*. A check that cannot run must not be able to take agents down.

A secret that **does not exist yet** is allowed at deployment too: it has no grant to check, and deploying first is a supported flow — [agent sync](/conversations-and-orchestration/agent-sync-guide.md) promotes a workflow to an instance that does not hold the source's secrets, then deploys it. Its grant is checked when the secret is **created** instead, because the resolver does not check grants: a secret created later and restricted to another agent would otherwise resolve for every agent already deployed with a reference to it. Under `enforce`, `PUT /secretstore/secrets/{tenantId}/{keyName}` and the space-secret endpoint answer **409** to a create whose `allowedAgents` leaves out a deployed agent that already references the secret, and name the agents: add them to the list, or undeploy them first. `warn` logs the same message and creates the secret; `off` does not look. The scan covers every deployment recorded in the shared database — so, on a cluster, the agents deployed on the other nodes too — plus the runtime registry of the node handling the request, which alone knows a deployment still coming up or one made with `autoDeploy=false`. Under `enforce` a scan that could not look everywhere refuses as well, with its own **409**: an agent whose configuration could not be read (it is named), or a listing that failed. That is the opposite of the deploy gate, which lets an unreadable configuration through, because the costs are reversed — a refused deployment is an outage, a refused creation is a retry. Naming the agent in `allowedAgents`, or granting the wildcard, needs no scan. What no scan can see is a deployment racing the creation, recorded on another node a moment after the records were read. Updating a secret that already exists is not checked this way — narrowing an existing grant is covered by the grant endpoint's `agentsLosingAccess` warning.

### What is scanned

The agent document itself plus each workflow's LLM, HTTP-call, MCP-call, and RAG configurations are serialized and scanned for `${vault:...}` references. Each reference found is resolved to its secret metadata and tested against the deploying agent's ID.

### Upgrading to enforcement

On most deployments this control is inert, for two reasons worth confirming rather than assuming:

1. **No master key, no check.** With `eddi.vault.master-key` unset the vault is disabled and the checker reports no violations without looking at anything.
2. **Auto-vaulted keys are unrestricted by agent.** Every key the setup wizard vaults is stored with `allowedAgents = ["*"]` — written out by the wizard, which says why in its source: the agent being set up has no id yet. Their *destination* is restricted instead — see [Where a wizard key may be sent](#where-a-wizard-key-may-be-sent).

The deployments that *are* affected have **both** a master key and a grant an operator has deliberately narrowed. There, enforcement is a behavior change with no warning phase — the first symptom is an agent refusing to deploy. Before enabling it, run once with `warn` and confirm the log is free of:

```
references vault secret(s) it is not granted
```

Then set `enforce`. To widen a grant instead, see below — or remove the reference from the agent's configuration.

### New agents always need adding

A grant lists agent **ids**, and an agent's id exists only once the agent has been created. So every **new** agent that uses a restricted secret — whether it comes from the Manager's wizard, the setup API, MCP `setup_agent`, a ZIP import, an agent sync, or the Platform Operator's activation — is not on the grant when it is first deployed, and in `enforce` mode that first deploy is refused. This is the check working as intended; the fix is to add the new agent to the grant before (or after) its first deploy. There are three ways, and every one of them is an explicit administrator action — nothing grants silently, and no MCP tool can change a grant:

1. **The Manager.** Deploying an agent — from the editor, its card or detail page, the creation wizard, or the import dialog — first runs the [preflight](#preflight-before-deploying). If a key the agent uses is restricted, a dialog names the key and who it is currently allowed for, and an administrator can **Add and deploy** in one step (a dry run first, then the append, then the deploy). Non-administrators see the same explanation and can copy the exact request for an administrator. The secret picker in each editor marks a restricted key with a lock badge, and says up front when the agent being edited is not on the grant yet.
2. **The setup API**, with `"grantReferencedSecrets": true` on `POST /administration/agents/setup` or `/setup-api`. After creating the agent and before deploying it, the new agent is added to the grant of every restricted secret it references; each change is written to the [audit ledger](/security-and-compliance/audit-ledger.md). The flag is for `eddi-admin` only (anyone else gets **403**) and it never touches a secret that is open to every agent. The response lists the secrets it extended under `grantedSecrets`.
3. **The append endpoint**, `POST /secretstore/secrets/{tenantId}/{keyName}/grant/agents/{agentId}` — see [below](#adding-one-agent-to-a-grant).

Without any of these, the setup API still creates the agent and answers with its id **and** the structured failure, so the caller can grant and redeploy *that* agent — running the setup again would only create a second agent that is refused the same way.

### When a deploy is refused

A deploy refused by the grant check used to answer only `{"status":"ERROR"}`. The reason now travels with it, as a `failure` object, in two places:

* `POST /administration/{env}/deploy/{agentId}?version=N&waitForCompletion=true` — still **200** with `status: "ERROR"`, as every failed waited deploy has always answered, plus `error` (the message) and `failure`.
* `GET /administration/{env}/deploymentstatus/{agentId}?version=N&format=detailed` — `failure` next to `status`, for a caller who may edit the agent.

```json
{
  "status": "ERROR",
  "failure": {
    "code": "VAULT_GRANT_MISSING",
    "message": "Agent '0123456789abcdef01234567' v1 uses vault secret(s) it is not granted: default/gemini-api-key. A secret's grant (allowedAgents) lists agent ids, so an agent created after the grant was written is never on it and has to be added. Fix (eddi-admin): POST /secretstore/secrets/default/gemini-api-key/grant/agents/0123456789abcdef01234567 (add ?dryRun=true to preview), or add the agent to the key's grant on the Manager's Secrets page, then deploy again.",
    "secrets": [{ "tenantId": "default", "keyName": "gemini-api-key", "reference": "${vault:gemini-api-key}" }],
    "fix": {
      "addAgentId": "0123456789abcdef01234567",
      "endpoints": ["POST /secretstore/secrets/default/gemini-api-key/grant/agents/0123456789abcdef01234567"],
      "dryRunFirst": true
    }
  }
}
```

Only names and references are returned, never a value. Any other cause of an ERROR is reported as `code: "DEPLOYMENT_FAILED"` with a fixed message that points to the server log. The cause itself goes only to the log, because an exception message can carry a credential, a connection string or an internal path. The failure is kept in memory with the deployment status, so it does not survive a restart — a redeploy recomputes it — and the next successful deploy clears it. MCP `deploy_agent` returns the same code, secret names and fix, with a note that an administrator has to apply it. For a grant refusal, the server log line says the same thing as `message`.

### Preflight before deploying

`GET /administration/{env}/deploy/{agentId}/preflight?version=N` runs the deploy's grant check without deploying. It needs the same EDIT access as the deploy and never returns a value:

```json
{
  "agentId": "0123456789abcdef01234567",
  "version": 1,
  "enforcement": "ENFORCE",
  "checked": true,
  "ready": false,
  "grantIssues": [
    { "tenantId": "default", "keyName": "gemini-api-key", "reference": "${vault:gemini-api-key}",
      "grantsAllAgents": false, "allowedAgentCount": 2, "allowedAgents": ["89abcdef0123456789abcdef", "fedcba9876543210fedcba98"] }
  ]
}
```

* `ready` is `false` only when the deploy would be refused: `enforce` mode with at least one issue. In `warn` mode the issues are still listed and `ready` is `true`.
* `checked` is `false` when the check did not run — mode `off`, or it could not read what it needed. The deploy then goes ahead too, so the preflight reports `ready: true` rather than predict a refusal the deploy would not make.
* `allowedAgents` — the ids on the grant — is returned to administrators only; an editor gets `allowedAgentCount`. The ids are not secret, but they map which other agents hold a credential, and only an administrator can change a grant anyway.

### Adding one agent to a grant

`POST /secretstore/secrets/{tenantId}/{keyName}/grant/agents/{agentId}` (`eddi-admin`) adds one agent and keeps everything already on the grant:

```bash
# Preview, then apply
curl -X POST "$EDDI/secretstore/secrets/default/gemini-api-key/grant/agents/0123456789abcdef01234567?dryRun=true"
curl -X POST "$EDDI/secretstore/secrets/default/gemini-api-key/grant/agents/0123456789abcdef01234567"
```

The answer has the same shape as the replacing `PUT …/grant` below, plus `changed`. It is:

* **idempotent** — an agent already on the grant answers `changed: false` and writes nothing;
* **never a narrowing** — a secret open to every agent (`["*"]`) is left exactly as it is (`changed: false`); appending an id there would turn "every agent" into "these agents";
* **safe under concurrency** — the write is a compare-and-set on the stored grant, retried on a conflict, so two administrators granting two new agents at the same moment both land. With the replacing `PUT` that is a read-modify-write in the client, and one of the two grants would be lost;
* **audited** — each change writes an audit-ledger entry (`taskType: vault`, `taskId: vault.grant.append`) with the actor, the secret, the agent, the previous and the new grant, and where it came from (`rest` or `setup`).

### Changing a grant without the secret's value

Use **`PUT /secretstore/secrets/{tenantId}/{keyName}/grant`**. It replaces `allowedAgents` (and optionally the description) and **never touches the encrypted value** — no plaintext is accepted, and none is required:

```bash
curl -X PUT "$EDDI/secretstore/secrets/default/llm-api-key/grant" \
  -H 'Content-Type: application/json' \
  -d '{"allowedAgents": ["0123456789abcdef01234567", "89abcdef0123456789abcdef"]}'
```

This exists because `PUT /{tenantId}/{keyName}` — the other way to write `allowedAgents` — requires the plaintext value, and **by the time you need to widen a grant the plaintext is gone**, which is the point of having vaulted it. Before this endpoint, adding one agent to a list meant recovering the value from a backup or rotating the key, and the path of least resistance was to grant `["*"]` to everything — throwing away the only control that limits a secret's blast radius. The endpoint is on the same `eddi-admin` role as every other vault endpoint.

Three properties are worth knowing:

* **`allowedAgents` is required and must not be empty.** Unlike the store endpoint, an omitted list is a `400` rather than a silent default to `["*"]`, and so is `[]` — which every other layer reads as "unrestricted": on an edit, a field missing from a JSON body, or a list filtered down to nothing, must not be able to open a narrowed secret to every agent. Send `["*"]` to mean "all agents". A list that mixes the wildcard with agent IDs, such as `["*", "someAgent"]`, is stored as plain `["*"]`, because that is what it already means to the deploy-time check — so a grant never *reads* narrower than it behaves.
* **Concurrent edits can be refused instead of lost.** Every edit replaces the whole list, so two operators editing from what they each loaded would otherwise overwrite each other without either finding out — the later write quietly reinstating an agent the earlier one removed. Send the list you loaded as `expectedAllowedAgents` and the write is applied only while the grant is still that list (compared as a set; every spelling of the wildcard is equal). Otherwise the answer is **409** with the grant as it now stands, and nothing is written. A dry run checks the precondition too. Omitting the field keeps the old unconditional behaviour.
* **Absent means null.** Like every EDDI response, fields whose value is null are omitted — a secret that has never been rotated has no `lastRotatedAt` in the response, rather than `"lastRotatedAt": null`.
* **It is not a rotation.** `createdAt` and `lastRotatedAt` keep their values and the checksum is unchanged; all three are echoed back so you can see that for yourself. The `SecretResolver` cache is deliberately *not* invalidated — the plaintext cannot have changed, and the grant check reads metadata from the store on every call, so the new grant is in force for the very next deployment either way.
* **Narrowing a grant is reported, not silently applied.** The response lists every *deployed* agent that references the secret and is no longer granted it:

```json
{
  "reference": "${vault:llm-api-key}",
  "tenantId": "default",
  "keyName": "llm-api-key",
  "dryRun": false,
  "allowedAgents": ["0123456789abcdef01234567"],
  "previousAllowedAgents": ["0123456789abcdef01234567", "89abcdef0123456789abcdef"],
  "grantsAllAgents": false,
  "description": "LLM provider key",
  "createdAt": "2026-03-15T10:30:00Z",
  "agentsLosingAccessScope": "cluster",
  "agentsLosingAccess": [
    { "agentId": "89abcdef0123456789abcdef", "agentVersion": 3, "environment": "production" }
  ],
  "warning": "1 deployed agent(s) reference this secret and are not on the new grant list. They keep running — the grant is checked when an agent is deployed, not when a secret is resolved — but under eddi.vault.grant-enforcement=enforce their next deployment will be REFUSED. …"
}
```

Those agents are **not** broken now: the check is a deploy-time gate, so they keep resolving the secret until they are redeployed. What breaks is their *next* deployment, possibly weeks later on a restart nobody connects to the grant edit — which is exactly why it is reported at the moment of the change. Add `?dryRun=true` to get this answer **without writing anything**; that is how the Manager shows the warning before you commit rather than after. Every deployed *version* is considered, not only the latest, since an older version can be the one serving. The list covers every deployment recorded in the shared database plus the runtime registry of the node you are talking to — `agentsLosingAccessScope: "cluster"` says so. A deployment made with `autoDeploy=false` on another node is not recorded, and appears once that node's announcement has deployed it on this one too.

**In the Manager UI:** *Secrets* → the **Allowed Agents** column, or the **Access** action on a row. Agents are picked from a searchable list of the ones that exist (a raw ID can still be typed, for an agent not created yet), and a change that would strip access from a deployed agent shows the warning above and has to be acknowledged before it can be saved.

## Destination allow-list (`allowedHosts`)

The agent grant says *which agent* may use a key. It says nothing about *where* the key goes: an editor who may change an agent's LLM configuration can point `baseUrl` at a host of their own, keep `apiKey: ${vault:openai-key}`, and the deploy-time check passes because the agent *is* granted the key. `${connection:…}` closes this with its `baseUrlAllowlist`; `${vault:…}` had nothing.

A secret carries an **`allowedHosts`** list: the exact destinations it may be sent to. Under `enforce` (the shipped default) a create must say what it is — see [Required on a create](#required-on-a-create) below.

```bash
# At creation, with the value ...
curl -X PUT "$EDDI/secretstore/secrets/default/openai-key" \
  -H 'Content-Type: application/json' \
  -d '{"value": "sk-...", "allowedAgents": ["*"], "allowedHosts": ["api.openai.com"]}'

# ... or later, without it (the grant endpoint; eddi-admin, no plaintext needed)
curl -X PUT "$EDDI/secretstore/secrets/default/openai-key/grant" \
  -H 'Content-Type: application/json' \
  -d '{"allowedAgents": ["*"], "allowedHosts": ["api.openai.com", "eu.api.openai.com:443"]}'
```

* **Entries are exact `host` or `host:port`** — no scheme, no path, no wildcard, compared case-insensitively. `host` alone admits that host on any port; `host:port` admits only that port, where the destination's port is the one in its URL or the scheme default (443 for `https`, 80 for `http`). There is no suffix matching: `example.com` admits neither `api.example.com` nor `example.com.attacker.net`. At most 100 entries.
* **Enforced where the reference is resolved for an outbound request**, because that is the only moment the destination is known: an HTTP call (`httpcalls.json` — URL, headers, body and query parameters are all collected and checked against the fully resolved target URL before anything is sent), an LLM provider's configured endpoint (`baseUrl`, `endpoint`, `url`, `serverUrl` or `host` in `langchain.json`), an embedding model's `baseUrl` or `endpoint` in a RAG configuration, every host a vector store connects to (pgvector and Qdrant `host:port`, the Elasticsearch `serverUrl`, the Chroma `baseUrl`, each host of a MongoDB Atlas connection string — checked after the string is decrypted, since it is often the secret itself, and before anything connects; for `mongodb+srv://` that is the SRV name, which is the entry to list: the driver resolves it to replica-set hosts only within that name's parent domain, so the list trusts whoever controls that DNS zone), an MCP server's `url` for a static `apiKey`, and an A2A peer's URL — for a task call, the endpoint the peer's agent card announced, which need not be the configured host. A refusal names the reference, the destination and the list — none of them a secret — and the `PUT …/grant` call that fixes it; the call, the build or the discovery fails (an MCP server is reported as `INVALID_CONFIGURATION`), the key is not sent. A list that cannot be read is treated the same way: a key whose destinations are unknown is not a key that may go anywhere.
* **Absent or empty means any destination.** That is what every secret stored before the field existed has, so adding the field changes nothing for those until an operator sets it. `[]` or `["*"]` on the grant endpoint removes a restriction; an omitted field on a rotation or a grant edit keeps the stored list.
* **Changing the list evicts what was built with the old one.** Models, embedding models and stores and MCP clients are checked when they are built and then cached, so a grant edit that sets `allowedHosts` also invalidates the secret's cache entry, and every cache listening for that rebuilds — re-checking — on next use.
* **What it does not cover.** An LLM or embedding configuration with *no* endpoint parameter uses the provider's own default endpoint, which an editor cannot choose, so it is not checked — the list exists to stop an editor-chosen destination, not a vendor's.
* **Returned in the metadata** (`GET …/{tenantId}/{keyName}`, the list endpoint, the grant response, and a space's secret list) as `allowedHosts`. The Manager's Secrets page shows a restricted secret's hosts under its agent grant and edits them in the **Access** dialog, which sends the list only when it changed (removing every host sends `[]`).
* **Secrets that are still open are named at startup.** Every secret vaulted before the setup wizard started setting `allowedHosts` — and any an admin stored without it — may still be sent anywhere. Nothing narrows them automatically (only you know which host a key belongs to, and a wrong guess breaks a working agent). Instead, when the vault is enabled, `UnrestrictedSecretsCheck` scans the current version of every LLM, MCP-calls, RAG, httpcalls and channel configuration for `${vault:…}` references after boot and logs **one** `[SECURITY]` WARN listing each referenced secret without `allowedHosts` as `tenant/key`, the configurations that use it, and the `PUT …/grant` call above. The list endpoint (`GET /secretstore/secrets/{tenantId}`) carries the same information per entry: `unrestrictedDestination: true` when `allowedHosts` is empty (live), and `referencedBy`, the configurations the last complete scan found (absent until one has finished — also after a scan that could not read every configuration, which says so in a second `[SECURITY]` WARN rather than report a shorter list as complete). The scan does not follow `${vars:…}` or `${connection:…}` indirection and reads current configuration versions only.

### Required on a create

`allowedAgents` limits which agents may reference a secret, but any editor can write a configuration that sends a referenced secret to a URL of their choosing, so a secret created without `allowedHosts` went wherever the next configuration edit pointed it (security review 2026-10-17, H1). Under `eddi.vault.grant-enforcement=enforce` a **create** — through `PUT /secretstore/secrets/{tenantId}/{keyName}` or a space's `PUT /spacestore/secrets/{key}` — therefore has to say where the secret may go, the same way it already has to say which agents may use it:

| `allowedHosts` on a create | Result under `enforce`                                                                                                     |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| omitted                    | **400**, naming the field and the `["*"]` opt-out                                                                          |
| `[]`                       | **400** — every layer reads an empty list as "any host", so a list filtered down to nothing must not create an open secret |
| `["*"]`                    | stored with no destination restriction, said out loud. `"*"` is only accepted alone; it is never a pattern                 |
| `["api.openai.com", …]`    | stored as given, validated as above                                                                                        |

Under `warn` and `off` an omitted list still creates an unrestricted secret. A **rotation** may omit the list in every mode and keeps the stored one. The setup wizard derives the list itself ([Where a wizard key may be sent](#where-a-wizard-key-may-be-sent)), and the Manager's **Add Secret** dialog, its inline create in the secret picker and the Workspaces page ask for it, offering *Any host* as the explicit choice.

## Encryption

### Envelope Encryption

EDDI uses **envelope encryption** — each tenant gets its own random Data Encryption Key (DEK), which is itself encrypted by a Key Encryption Key (KEK) derived from the master password.

```
Master Password → PBKDF2 (600,000 iterations) → KEK
                                                  │
Secret → tenant DEK → AES-256-GCM encrypt → ciphertext
                │
                └→ KEK wraps DEK → encrypted DEK
                        │
                        └→ stored: { encryptedDek, iv, ciphertext, dekId }
```

`dekId` is `<tenantId>#g<generation>` — readable on purpose, so `default#g3` in a database row tells an operator exactly what it means. A tenant holds one DEK row per generation because [rotation adds one rather than replacing the key](#dek-rotation-is-additive--it-adds-a-generation), and ciphertext therefore has to say which key sealed it. A value written before generations existed carries no generation and reads as generation 1.

**Ciphertexts are bound to their rows.** A secret is sealed with AES-GCM associated data `tenantId|keyName|dekId`, so a ciphertext copied into another row by someone with write access to the database fails authentication instead of decrypting as that row's value. Rows written before this keep decrypting through a no-AAD fallback, so nothing has to be migrated, and the fallback retires itself: the first time such a row is resolved it is re-sealed in the bound form in place (same value, same DEK generation, not counted as a rotation), guarded on the IV it was read with so a concurrent store or DEK rotation always wins, and logged once per key. A DEK rotation still re-seals every remaining row, including those never read. System values (such as the pinned audit key) are bound to their name and have no unbound form. Wrapped DEKs are not bound.

**Sealed runtime data is bound too.** OAuth connection grants are sealed through the generic `seal()` API, which used to bind nothing: one user's refresh token copied into another user's grant row — or an access token copied into the refresh-token field — opened there. The API now takes a *context* naming the row and the field (`ISecretProvider.seal(tenantId, plaintext, context)`), and the AAD is `eddi-sealed-data|v1|tenantId|dekId|<UTF-8 byte length>:context`, a prefix distinct from a secret's and a system value's so no sealed value of one kind passes as another. A grant's context is its connection name, its principal and `access`/`refresh`.

*Migration — nothing to run.* Grants sealed before this keep opening: the bound form is tried first and the unbound one only if it fails, exactly as for secrets. Every grant **written** from this release on (a new link, every token refresh) is bound, and the next `POST /{tenantId}/rotate-dek` re-seals every remaining one in the bound form. Until a grant has been rewritten or swept, it can still be opened unbound — run a DEK rotation if you want that window closed now.

**One generation per row.** A grant stores both tokens under a single `dekId`. They are now sealed in one call (`sealAll`), against one active-DEK lookup. Sealed one at a time, a rotation committing between the two calls could leave the refresh token under a generation the row did not name — and that grant could never refresh again.

**The per-deployment salt is created once, by whichever replica gets there first.** It is written with an insert-if-absent and every other replica adopts the winner, so two replicas booting against an empty database cannot each derive their KEK from a different salt. A salt that cannot be read fails the start: falling back to the legacy salt on a deployment that has a random one derives the wrong KEK.

**An upgrade that stays on the legacy salt is told so, louder each time.** A deployment that already holds DEKs but no per-deployment salt — one upgraded from before 6.0.2 — boots on the legacy fixed salt so its DEKs stay readable, and logs a **WARN** naming `POST /secretstore/secrets/admin/rotate-kek` as the way out. That salt is shared by every such deployment, so the master key is then the only secret protecting the DEKs. The first such boot is recorded (`vault-kek-legacy-salt-seen` in the vault metadata); every later boot still on the legacy salt logs an **ERROR** instead, with the date the legacy salt was first adopted and the same command. Rotating the KEK — with the current master key as both old and new key is enough — generates the per-deployment salt and clears the record.

### Configuration

The vault requires a master key (KEK) to encrypt/decrypt secrets. If not set, the vault is **disabled** — all `${vault:...}` references pass through unresolved and a prominent warning is logged at startup.

#### Installer (Recommended)

The `install.sh` / `install.ps1` installer automatically generates a unique, cryptographically random vault master key during setup and stores it in `~/.eddi/.env`. No manual configuration is needed — the vault is **secure by default** for all installer-based deployments.

The installer offers two options during the "Security" wizard step:

1. **Auto-generate** (recommended) — creates a strong 32-character base64 key via `openssl rand`
2. **Custom passphrase** — enter your own passphrase (minimum 16 characters)

You can also provide a key non-interactively:

```bash
# Bash
bash install.sh --vault-key=your-strong-passphrase-here

# PowerShell
.\install.ps1 -VaultKey "your-strong-passphrase-here"
```

Re-running the installer preserves your existing key — it reads from `~/.eddi/.env` and never overwrites it.

#### Manual Configuration

For manual Docker Compose deployments or local development, set the master key using **one** of these methods (in priority order):

```bash
# 1. System property (highest priority)
./mvnw compile quarkus:dev -Deddi.vault.master-key=your-strong-passphrase

# 2. Environment variable (recommended for production)
export EDDI_VAULT_MASTER_KEY=your-strong-passphrase

# 3. .env file in project root (recommended for local dev — add to .gitignore!)
echo "EDDI_VAULT_MASTER_KEY=your-strong-passphrase" > .env

# 4. application.properties (dev profile only — safe to commit)
%dev.eddi.vault.master-key=dev-passphrase
```

Additional vault settings in `application.properties`:

```properties
# Cache for resolved secrets (avoids repeated decryption)
eddi.vault.cache-ttl-minutes=5
eddi.vault.cache-max-size=1000

# Whether an agent referencing a secret it is not granted may deploy:
# off | warn | enforce (default). See "Agent Grants" above.
eddi.vault.grant-enforcement=enforce

# Whether agent setup reuses a vault entry that already holds the same
# plaintext key: checksum (default) | never.
# See "Reusing one key across agents" below.
eddi.setup.vault-key-reuse=checksum
```

> **⚠️ Important:** The vault master key encrypts all stored API keys. If the master key is lost, all encrypted secrets become **permanently unrecoverable**. Back up your `~/.eddi/.env` file. If the key has already changed and the old one is unavailable, follow [Lost master key](#lost-master-key): adopt the new key with `POST /secretstore/secrets/admin/adopt-master-key?confirm=true`, then `POST /secretstore/secrets/{tenantId}/reset?confirm=true` clears each tenant's unrecoverable entries so it can start fresh.

## Secret Input (Agent Conversations)

Agents can request secret input from users (e.g., API keys during setup). The flow works end-to-end across backend, chat UI, and Manager.

### Backend: PropertySetterTask + Conversation Scrubbing

When a property has `scope: secret`:

1. **`SecretPropertyVault`** (used by `PropertySetterTask` and by the httpcall / MCP / LLM property instructions) detects `scope == secret`
2. The raw value is immediately stored in the vault via `ISecretProvider.store()`, in a slot of its own (`<agentId>.u<userHash>.<nonce>.<name>`)
3. A vault reference (`${vault:...}`) replaces the plaintext in memory
4. The raw `input:initial` entry, and any other copy in the conversation step, is scrubbed

When the **client flags input as secret** (via the `secretInput` context key):

1. `Conversation.isSecretInputFlagged()` checks for `{"secretInput": {"type": "string", "value": "true"}}` in the context map
2. `storeUserInputInMemory()` replaces the display value with `<secret input>` in conversation output
3. The actual plaintext still flows through lifecycle data for the whole turn, so tasks — the parser, `PropertySetterTask` — can use or vault it
4. When the turn ends (completed, stopped, paused or failed), `Conversation.scrubSecretClientInput()`:

   * replaces `input:initial` and `input:normalized` with `<secret input>`;
   * clears the parsed expressions and intents derived from the input;
   * re-asserts `<secret input>` as the displayed `input` (the parser overwrites it with the normalized text mid-turn);
   * removes the raw and normalized text from every other datum and output of the step, and from the pending tool-call batch of a tool-call pause — the transcript the model saw, the gated call's arguments and the redacted arguments an approver is shown. A form of 4+ characters is removed wherever it occurs; a shorter one (a 3-digit PIN) only where it stands as a **whole token** — no letter or digit directly before or after it — so "PIN 739 saved" loses the PIN while "order 17391" keeps its digits. The whole-token pass changes values only, **never map keys**: a short secret is often also a field name (`id`, `to`), and renaming it would corrupt the turn's stored API responses and output items.

   This runs in the turn's `finally`, before the audit flush. The audit ledger records the placeholder as the user input and redacts both forms (4+ characters) from every entry of the turn.
5. So, for turns run on this version, the stored step, the pending approval, API responses, the streamed `done` frame and the audit ledger show `<secret input>`. Clients can rely on the turn output's `input` being the **masked display copy**.
6. **Turns stored before this version** still hold the raw `input:initial` and the parser's normalized copy in the database. They are masked **on read**: every secret turn carries `context.secretInput == "true"`, and conversation reads (REST, MCP, the `done` frame) replace `input`, `input:initial`, `input:normalized` and `expressions:parsed` for such a turn, clear its other parser results, and remove the raw and normalized text (4+ characters) from every other step result and output value they return for it — an output item that echoed the input included. A `returnDetailed` read additionally drops internal keys (`audit:*`, `*:trace:*`, `*Error`), runs every value through the secret redaction filter and masks everything under a credential-named key (`apiKey`, `token`, `secret`, `password`, `authorization`), for every turn, secret or not. Conversation properties are not masked as part of a secret turn (see **Not scrubbed** below); in a `returnDetailed` read only that credential-name masking applies to them. The stored document itself is not rewritten, and the audit entries of those old turns are unchanged.

**Why four characters.** Where a form *is* the input (`input:initial`, `input:normalized`, the displayed `input`) it is replaced whatever its length. Elsewhere it has to be found by searching. From four characters every occurrence is replaced — inside longer words too, and in map keys as well as values of the turn. A 4-digit PIN or a short password is what a password field carries, so the substring search starts there; below that, replacing every "ok" or "7" inside other words and numbers would shred the turn's reply and rename the fields of its stored API responses and output items. A shorter form is therefore searched for only as a whole token and only in values, which removes a PIN echoed into the reply without touching "17391" or a field named `id`. The audit ledger and the read-time masking of older turns use the 4-character substring search only. Secret *context* values keep their own 8-character floor ([Secret Context Values](/agent-configuration/passing-context-information.md#secret-context-values)).

**Not scrubbed:** a conversation property the agent designer captured the input into (`{memory.current.input}`, the wizard pattern), and its `properties:*` step mirror — neither at turn end nor on read. Keeping it is the designer's explicit choice; give the property the `secret` scope to have it vaulted instead.

**HITL resume sees the placeholder.** Everything after a pause of a secret turn — a RULE pause (`PAUSE_CONVERSATION`) or a tool-call pause — runs after the scrub. A tool approved on resume executes with `<secret input>` where the secret was, and a property setter that runs after the resume reads `<secret input>` from `{memory.current.input}`. `PropertySetterTask` does not store or vault that placeholder: it logs a warning and leaves the property unset. Capture a secret input **before** any rule that pauses the turn.

**A resume cannot search for the secret.** The resume is a later request, and the plaintext is not kept past the pause — not in the conversation document, not in the resume bookmark, not as a hash (a hash of a PIN or short password stored beside the conversation is reversed offline in seconds, and a hash cannot be searched for inside a longer text anyway). The resumed turn's audit entries record the placeholder as the input, because the step was scrubbed at the pause. What a resumed task *reintroduces* is not redacted — from the ledger or from the stored step: a value copied out of a conversation property that captured the input (the designer's choice above), or a tool or API result that happens to contain it. Give a property that must never reappear the `secret` scope.

When the **client sends a credential as context** — for example the caller's token for a downstream API — it marks that context entry `"secret": true`. The value works for that one turn and is replaced by `<secret context>` in everything that outlives it. See [Passing Context Information → Secret Context Values](/agent-configuration/passing-context-information.md#secret-context-values).

### Output InputField Directive

To signal the chat UI to show a password field, use the `inputField` output type in your output configuration:

```json
{
  "type": "inputField",
  "subType": "password",
  "label": "API Key",
  "placeholder": "Paste your API key here"
}
```

The explanatory sentence ("Please enter your API key:") belongs in a sibling `text` output item in the same action's output set — the `inputField` item only controls how the input is rendered.

### Chat UI: Password Fields + Secret Mode

Both the **Chat UI** (`ui/chat`) and the **Manager chat panel** (`ui/manager`) support secret input:

**Backend-driven password fields:**

* When the backend response contains an `inputField` output item with `subType: "password"`, the chat UI replaces the normal text input with a masked `<input type="password">` field
* An **eye toggle** button allows the user to reveal/hide the value
* After submission, the input reverts to the normal text field

**Proactive secret mode (client-initiated):**

* A 🔒/🔓 toggle button on the chat input lets users mark any input as secret
* When toggled ON, the input becomes a password field with eye toggle
* The `secretInput` context flag is sent to the backend, triggering output scrubbing in `Conversation.java`

**Security measures:**

* Chat UI state for secret values is **ephemeral** — cleared on submit or dialog close
* No secret values are stored in browser `localStorage` or `sessionStorage`
* `autoComplete="new-password"` prevents browser caching

### Example: collecting a key in a conversation

An agent that asks the user for an API key mid-conversation wires it up like this:

```json
// Output configuration — prompts with a password field
// (pair it with a sibling `text` item that explains what to enter)
{
  "type": "inputField",
  "subType": "password",
  "label": "API Key",
  "placeholder": "Paste your API key here"
}

// Property setter — auto-vaults the input
{
  "name": "apiKey",
  "valueString": "{memory.current.input}",
  "scope": "secret"
}
```

The `scope: secret` instruction causes `PropertySetterTask` to store the API key in the vault and replace the memory value with a `${vault:...}` reference.

## Auto-Vaulting (Agent Setup)

When creating agents through the Manager's agent wizard, the Platform Operator, or the Setup API directly (`POST /administration/agents/setup` and `/setup-api`), API keys are **automatically stored in the vault**. You don't need to manually create vault entries.

### How It Works

1. User provides an API key during agent setup
2. `AgentSetupService.vaultApiKey()` resolves it against the vault (see *Reusing one key across agents* below)
3. A vault reference (`${vault:setup.<agent-name>.<timestamp>-<random>.apiKey}`, or the name you chose) is written to the LLM configuration
4. When the vault is enabled, the plaintext key is never persisted in MongoDB — only the vault reference is stored
5. The entry is stored with an [`allowedHosts`](#destination-allow-list-allowedhosts) list — see *Where a wizard key may be sent* below

### Where a wizard key may be sent

Every editor can read the LLM configuration that holds the reference, so a key stored with no destination list could be copied into a configuration pointed at an editor's own host and received there on the first turn (security review 2026-10-17, H1). Every secret the setup wizard **creates** — the LLM `apiKey`, and on `/setup-api` the `apiAuth` it vaults — is therefore stored with the destinations it was meant for:

| Request                                                          | `allowedHosts` stored                                                                                                                                                                                                                                                |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| a base URL (`baseUrl` on `/setup`, `llmBaseUrl` on `/setup-api`) | that URL's host, with its port when the URL spells one                                                                                                                                                                                                               |
| no base URL, a provider with a known endpoint                    | the provider's host: `api.openai.com`, `api.anthropic.com`, `generativelanguage.googleapis.com`, `api.mistral.ai`, `router.huggingface.co`, `huggingface.co` (jlama's model-download token), or the default and regional hosts of a named OpenAI-compatible provider |
| `apiAuth` on `/setup-api`                                        | the host of each generated httpcall's `targetServerUrl` (from `apiBaseUrl` or the spec's `servers`)                                                                                                                                                                  |
| `"allowedHosts": ["api.example.com", "proxy.example:8443"]`      | exactly that list, for every secret the request vaults — validated as on the secrets API                                                                                                                                                                             |
| `"allowedHosts": ["*"]`                                          | no list: the secret may go anywhere                                                                                                                                                                                                                                  |

Under `eddi.vault.grant-enforcement=enforce` (the default) a secret is never stored open by accident: when the request names no endpoint and the provider has none setup knows (`azure-openai` without an endpoint, for one), the setup is refused with a **400** before anything is written, and the message says to pass the endpoint, a host list, or `["*"]`. Under `warn` and `off` the secret is stored open and a `[SECURITY]` warning is logged. A configuration that names no endpoint still uses the provider's own and is not checked, so the default list is what an editor may legitimately *spell out*, not something the agent needs in order to work.

Plaintext reuse (below) follows the same rule: an existing entry holding the same key is reused only when it is restricted to the same hosts — an `["*"]` request reuses only an open entry — so a key vaulted open by an earlier release is not handed to new agents; a restricted copy is stored instead. An entry named in `vaultKeyName` that already exists, or a `${vault:…}` reference passed as `apiKey`, is used as it is: that entry is the administrator's own, restricted however they left it. Narrow an older open entry with `PUT /secretstore/secrets/{tenant}/{key}/grant {"allowedAgents": ["*"], "allowedHosts": [...]}`.

`allowedHosts` is an administrator's field like `grantReferencedSecrets`, and is not exposed on the MCP `setup_agent` / `create_api_agent` tools; those always derive the list.

### Reusing one key across agents

One provider key usually serves many agents, so setup avoids storing it many times. Three ways to say "use this key", in the order setup considers them:

| What you pass                                            | What setup does                                                                                                                                                                                                                                                                                               |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vaultKeyName: "openai-prod"` (with or without `apiKey`) | Uses that entry. With `apiKey` it **creates** the entry under exactly that name; without one, the entry must already exist. Never overwrites an entry holding a different value — the request is rejected instead, because other agents may already point at it. Accepts the `${vault:openai-prod}` form too. |
| `apiKey: "${vault:openai-prod}"`                         | Used as-is, never re-vaulted. Surrounding whitespace is trimmed first, so a pasted reference still counts as one. If the key does not exist the setup still succeeds (you may vault it afterwards) but a warning is logged — the agent cannot resolve its credential until it does.                           |
| `apiKey: "sk-…"` (plaintext)                             | Reused if the vault already holds that exact value, otherwise stored under a generated name.                                                                                                                                                                                                                  |

Plaintext reuse is matched on the keyed checksum the vault stores per entry — nothing is decrypted to make the decision, and the match is performed by the vault provider (which holds the checksum key) rather than by recomputing a digest in the setup code — and only entries with `allowedAgents` unset or `["*"]` are candidates, since referencing a narrowed grant from a new agent produces a config that [grant enforcement](#agent-grants-allowedagents) rejects at deploy time. When several entries match, the oldest wins, so repeated setups converge on one entry rather than depending on listing order.

Set `eddi.setup.vault-key-reuse=never` to switch plaintext reuse off and give every agent its own entry again — appropriate when two agents hold the same-valued key today but must be able to rotate independently. Neither setting affects the first two rows above: those are explicit caller decisions. Any other value fails startup, as `eddi.vault.grant-enforcement` does — a typo must not silently switch de-duplication off.

Every setup response carries **`apiKeyVaultReference`** — the reference the created agent's LLM config actually points at, whether this call vaulted the key or reused an entry that already held it. Pass it straight back as `vaultKeyName` (or as `apiKey`) on the next setup to put another agent on the same credential.

It is `null` in two cases: the provider needs no key at all (`ollama`, `jlama`, `bedrock`, `oracle-genai`, …), and the vault is disabled so the key was stored in plaintext — a plaintext key is a secret and is never echoed back in a response body.

A setup can also return **`resources.vaultWarning`**: the chosen key does not exist, or it is granted only to other agents. Neither fails the setup — see *Reusing one key across agents* above — but both end in an agent that was created and cannot use its credential, so both are reported rather than only logged.

`vaultKeyName` accepts three shapes:

| Shape                       | Parsed as                                       |
| --------------------------- | ----------------------------------------------- |
| `openai-prod`               | key `openai-prod` in the `default` tenant       |
| `${vault:openai-prod}`      | the same — the wrapper is unwrapped, not stored |
| `${vault:acme/openai-prod}` | key `openai-prod` in tenant `acme`              |

The **tenant and key components** must each match `[a-zA-Z0-9._-]{1,128}` — the same charset the secrets REST API enforces on create. (The `${vault:…}` wrapper itself is of course not expected to match; it is stripped first.) The constraint is not cosmetic: the components are re-embedded into `${vault:<tenant>/<key>}`, where a `/` inside a *bare* name would re-parse as a tenant separator and a `}` would truncate the reference, leaving the agent pointing at a different secret or none.

Naming one key in `vaultKeyName` and a *different* one in `apiKey` is rejected rather than silently resolved in favour of either.

`vaultKeyName` is a REST-only field (`eddi-admin`). The MCP `setup_agent` / `create_api_agent` tools do not carry it — not to prevent reuse, which their `apiKey` already supports as a `${vault:...}` reference, but because the two things it *adds* (choosing the name of a newly created entry, and a value-must-match check on an existing one) are not needed to provision an agent and do not belong on a tool surface a model drives: name-squatting an entry an operator intends to create, and a per-request "does key X hold value V" oracle.

Naming, or pasting a reference to, a secret whose `allowedAgents` is narrowed is accepted — the legitimate flow is setup without deploy, widen the grant to the new agent's ID, deploy — but a brand-new agent cannot be on any existing grant list, so under `enforce` a `deploy: true` setup will end as `deployed: false`. The response says so up front in `resources.vaultWarning`. (Plaintext reuse simply skips such entries.)

### What rollback does with the vault

A setup that fails part-way rolls back the documents it created. It also removes the secret it vaulted — but only when nothing else can be referencing it: under `never` (unique per-agent names) it does; under `checksum` a freshly stored entry is a shared resource the moment a second setup with the same key runs, so it is left in place — a retry finds it by value and reuses it, and at worst it is one orphan. A caller-named entry is never rolled back for the same reason.

### Collision Prevention

A generated vault key is named `setup.<agent>.<timestamp>-<random>.apiKey`. The timestamp alone was not sufficient: two setups for agents with the same name landing in the same millisecond produced the same name, and `store` is an upsert, so one silently overwrote the other's credential. The random suffix is what makes the name unique; the timestamp is kept because it tells you when the entry was made.

A **caller-chosen** `vaultKeyName` has no such suffix, by design — that is the point of naming it. Creating one uses `ISecretProvider.storeIfAbsent`, an atomic create-if-absent (`INSERT … ON CONFLICT DO NOTHING` on PostgreSQL, an `insertOne` under the unique `(tenantId, keyName)` index on MongoDB), so two setups naming the same new key cannot overwrite each other: exactly one creates it. The other reuses the entry when it holds the same value, and fails — before anything is created — when it holds a different one. `store` stays the explicit upsert for rotation and the secrets REST API.

### Graceful Degradation

When the vault is disabled (no `EDDI_VAULT_MASTER_KEY`), the setup service logs a warning and falls back to plaintext storage. This ensures agent setup works in local development without requiring vault configuration.

A request carrying `vaultKeyName` is the exception: it fails with a clear error instead. Naming a vault entry is a request for one specific shared secret, and quietly writing the key in plaintext is not a smaller version of that.

> **Production recommendation:** Always set `EDDI_VAULT_MASTER_KEY` in production. The installer does this automatically.

***

## REST API

### Endpoints

All endpoints are under the base path `/secretstore/secrets`. All endpoints require the `eddi-admin` role.

| Method   | Path                                           | Description                                                                                                                                                                                                                                                                                    |
| -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUT`    | `/{tenantId}/{keyName}`                        | Store a secret (JSON body: `{"value": …, "description": …, "allowedAgents": …, "allowedHosts": …}`). A create under `enforce` must carry `allowedAgents` and `allowedHosts`                                                                                                                    |
| `PUT`    | `/{tenantId}/{keyName}/grant`                  | Replace `allowedAgents` (and optionally the description and `allowedHosts`) **without** the value — see [Changing a grant](#changing-a-grant-without-the-secrets-value) and [Destination allow-list](#destination-allow-list-allowedhosts). `?dryRun=true` previews the impact without writing |
| `POST`   | `/{tenantId}/{keyName}/grant/agents/{agentId}` | Add one agent to `allowedAgents`, atomically and idempotently; never changes a `*` grant — see [Adding one agent](#adding-one-agent-to-a-grant). `?dryRun=true` previews                                                                                                                       |
| `DELETE` | `/{tenantId}/{keyName}`                        | Delete a secret                                                                                                                                                                                                                                                                                |
| `GET`    | `/{tenantId}/{keyName}`                        | Get secret **metadata only** (never returns plaintext)                                                                                                                                                                                                                                         |
| `GET`    | `/{tenantId}`                                  | List all secrets for a tenant (metadata only)                                                                                                                                                                                                                                                  |
| `GET`    | `/health`                                      | Vault health check (provider status)                                                                                                                                                                                                                                                           |
| `POST`   | `/{tenantId}/rotate-dek`                       | Install the tenant's next DEK generation and sweep rows onto it                                                                                                                                                                                                                                |
| `POST`   | `/admin/rotate-kek`                            | Rotate the Master Key (KEK) — **TLS required**                                                                                                                                                                                                                                                 |
| `POST`   | `/{tenantId}/reset?confirm=true`               | Delete **ALL** secrets and the DEK for a tenant — destructive; use when the master key changed and the old key is unavailable. Without `confirm=true` it is a `400` and nothing is deleted                                                                                                     |
| `POST`   | `/admin/adopt-master-key?confirm=true`         | Make the configured master key the vault's after the previous one was **lost** — never during an unfinished rotation. Lists the tenants that still need a reset                                                                                                                                |

> **⚠️ Important:** The `GET` endpoints return **metadata only** (`keyName`, `createdAt`, `lastAccessedAt`). Secret values are **write-only** — they can be stored and used by the engine but never retrieved via API. The integrity **checksum is not returned over REST**: it is a value keyed to the plaintext, and exposing it would give an offline attacker a target to test guesses against. It is kept internally only for de-duplication and value-match.

### Response Examples

**`PUT /{tenantId}/{keyName}`** — the request body carries the plaintext value, an optional description, the agent grant list and the [destination allow-list](#destination-allow-list-allowedhosts). On a **create**, `allowedAgents` and `allowedHosts` are required under `eddi.vault.grant-enforcement=enforce` (the default) — send `["*"]` to grant every agent, or to allow any host, explicitly; under `warn` and `off` an omitted `allowedAgents` still defaults to `["*"]` and an omitted `allowedHosts` leaves the secret unrestricted ([Required on a create](#required-on-a-create)). On an **update** — rotating the value — an omitted `allowedAgents`, `allowedHosts` or `description` keeps what is stored: a rotation used to reset a narrowed grant to `["*"]` and wipe the description whenever the client did not restate them.

```json
{
  "value": "sk-...",
  "description": "OpenAI production key",
  "allowedAgents": ["*"],
  "allowedHosts": ["api.openai.com"]
}
```

It returns the vault reference:

```json
{
  "reference": "${vault:apiKey}",
  "tenantId": "default",
  "keyName": "apiKey"
}
```

**`GET /{tenantId}`** — returns metadata list:

```json
[
  {
    "tenantId": "default",
    "keyName": "apiKey",
    "createdAt": "2026-03-15T10:30:00Z",
    "lastAccessedAt": "2026-03-16T14:00:00Z"
  }
]
```

> The stored integrity checksum is a **keyed** HMAC of the plaintext (not a plain SHA-256), so it cannot be brute-forced offline by anyone with database access and does not reveal equal values across rows or tenants. It is used only internally for de-duplication and value-match and is **never** included in an API response. A row written before the keyed form still carries a bare SHA-256; the next `POST /{tenantId}/rotate-dek` replaces it with the keyed form as it re-seals the row.

**`GET /health`** — returns vault provider status:

```json
{
  "status": "UP",
  "provider": "VaultSecretProvider",
  "available": true
}
```

**`POST /{tenantId}/reset?confirm=true`** — deletes every secret and the DEK for the tenant (without `confirm=true`: `400`, `{"error": "confirm=true is required", …}`):

```json
{
  "tenantId": "default",
  "secretsDeleted": 5,
  "message": "Vault reset for tenant 'default'. 5 secret(s) deleted, DEK removed. The next secret store operation will generate a fresh DEK with the current master key."
}
```

**`POST /{tenantId}/rotate-dek`** — installs the tenant's next DEK generation and sweeps existing rows onto it:

```json
{
  "tenantId": "default",
  "secretsReEncrypted": 5,
  "message": "DEK rotated successfully. 5 secrets re-encrypted."
}
```

If the sweep does not finish, the call answers **500** and says so — the new generation is still active, a row left behind by a concurrent write still opens and a re-run moves it, and a row that could not be opened at all needs an operator:

```json
{
  "error": "DEK rotation failed: DEK rotation for tenant 'default': generation 3 is now the active key and every new value is sealed with it, but at least 2 sealed row(s) still name an older generation. A row left behind by a concurrent write still decrypts with the generation it names, which has not been deleted; the operation is safe to re-run, and a re-run moves it. A row that could not be opened at all (logged at ERROR for a secret, at WARN with its connection name for an OAuth grant, and counted in eddi_vault_reseal_failures_total) was already unreadable before this rotation and stays outstanding on every re-run: store such a secret again with its value, and have the user of such a grant reconnect the account (DELETE /connections/{name}/grant, then link it again)."
}
```

**`POST /admin/rotate-kek`** — rotates the master key:

Request body:

```json
{
  "oldMasterKey": "current-master-key",
  "newMasterKey": "new-master-key-at-least-8-chars"
}
```

Response:

```json
{
  "deksReEncrypted": 3,
  "message": "KEK rotated successfully. 3 DEKs re-encrypted. IMPORTANT: Update the EDDI_VAULT_MASTER_KEY environment variable to the new key and restart."
}
```

> **⚠️ Warning:** The `rotate-kek` endpoint transmits master keys in the request body. Ensure TLS is enabled. After rotation, update `EDDI_VAULT_MASTER_KEY` and restart.

### Key Rotation

EDDI supports two levels of key rotation.

#### DEK rotation is additive — it adds a generation

`POST /{tenantId}/rotate-dek` does **not** replace the tenant's key. A tenant holds one DEK row per **generation**, and every ciphertext — a stored secret, and any other sealed value written through a `SealedDataRotationParticipant`, such as an [OAuth connection grant](/protocols-and-integration/connections.md#rotating-the-key-that-holds-them) — records the generation that sealed it. Reading opens a value with the generation it names, not with whichever one is newest.

Rotation runs three phases, and only the middle one is irreversible:

1. **Verify** — every existing generation is opened with the current KEK, so a wrong `EDDI_VAULT_MASTER_KEY` is discovered before anything is written. Every generation, not just the newest: the sweep below has to open the older ones, and finding that out mid-sweep is a discovery that belongs before the commit point.
2. **Commit** — the next generation is *inserted*. One statement, guarded by a unique key on `(tenant, generation)`, so two racing rotations produce one winner and one clean refusal. From here new values seal with the new key while every existing row still names a generation that exists and decrypts.
3. **Sweep** — rows move onto the new generation one at a time, each write guarded on the state the row was read in, so a `store` that landed in between is never overwritten with a re-seal of the value it replaced.

**Old generations are never deleted.** That is precisely what makes a half-finished sweep harmless: a row the sweep did not reach still names a key that opens it. Deleting the generation a row still names is the one action that would make a partly swept tenant unreadable, so nothing here does it — and a resolve that finds a named generation missing says so by name rather than failing as a decryption error one layer down.

**A partial sweep is reported, and re-running finishes it.** The endpoint answers **500** with a message stating that the new generation is active and every new value seals with it, that at least *N* sealed rows still name an older generation, and that the operation is safe to re-run. Re-running picks up exactly the rows the previous run left **that can be opened**: a row the sweep merely did not win still decrypts with the generation it names. A row that cannot be opened at all — a secret whose ciphertext is corrupt, a grant sealed under a generation that no longer exists — was already lost before the rotation, and stays in the *N* on every re-run until an operator deals with it: store the secret again with its value, or have the grant's user reconnect the account (`DELETE /connections/{name}/grant`, then link it again). Rows are swept individually so that one secret nobody can open does not strand the rest of the tenant on an older generation for every future rotation as well. The same holds for OAuth grants: a grant whose tokens cannot be opened (corrupt, or sealed under a generation that no longer exists) used to end the grant sweep, leaving every grant after it on the old generation; it is now skipped, logged as a `WARN` naming the connection, counted in `eddi_vault_reseal_failures_total{participant="connection-grants"}` and included in the "at least *N* rows" of the 500. Every other grant still moves. A grant that keeps failing there cannot be opened at all — its user has to reconnect the account.

* Does NOT require a restart
* Recommended: rotate periodically or after personnel changes

#### KEK rotation

`POST /admin/rotate-kek` re-encrypts all tenant DEKs with a new master key:

* **Every generation of every tenant is re-wrapped**, not just the newest. A tenant part-way through a DEK sweep still has rows depending on an older generation, and leaving one behind on the old KEK is exactly the orphaned-key failure generations exist to prevent.
* Secret ciphertexts are NOT modified — only DEK wrappers change
* Requires restarting **every** replica with the new `EDDI_VAULT_MASTER_KEY` after rotation. Until then, the other replicas cannot open the re-wrapped DEKs and refuse to create new ones (see below) rather than wrap them under the retired key.

A new master key the production startup gate would reject is refused before anything is written (`eddi.vault.allow-weak-master-key` lets a deployment *boot* on a weak key to rotate off it, never rotate onto one). The rotation cannot be made atomic, so it rolls back what it can and is ordered so that whatever it cannot roll back, a **re-run with the same two keys** completes:

1. **New salt first.** A deployment still on the legacy salt migrates to a random one during the rotation, and that salt is persisted as *pending* before anything is wrapped under it. It used to exist only in memory until the end, so a failure half-way left DEKs wrapped under a KEK nobody could derive again.
2. **Verify** — every DEK, and the keyed-checksum key, must open with the old KEK *or the new one*; the second is what an interrupted run leaves behind. Anything that opens with neither stops the rotation before anything is written.
3. **Announce** — the vault's KEK check value is switched to the new KEK *before* any DEK is re-wrapped. A replica still on the old master key checks it before wrapping a new DEK and refuses, so no new tenant's key is wrapped under a KEK that is on its way out.
4. **Re-wrap** each DEK, guarded on the wrapping it was read with, then sweep again for a DEK another replica created in the meantime, then re-wrap the checksum key (so every stored keyed checksum keeps verifying).
5. **Promote** the pending salt.

A failure in step 3 or 4 rolls every DEK this run re-wrapped back to the old KEK and restores the check value, so the vault is still readable under the master key it is configured with, and the answer says the rotation can simply be retried. Only if that rollback cannot finish either (the store is down) does the announcement stay: every DEK then opens with one of the two keys, and the answer says re-running completes the rotation. A failure in step 5 leaves every DEK on the new key and the pending salt persisted; a re-run only promotes it. A replica restarted part-way through with the new master key opens DEKs under either KEK and wraps new ones under the new one. A DEK it cannot open — one the rotation did not reach, still under the previous key — fails with a message saying to re-run the rotation, and does **not** offer a tenant reset while a salt migration is pending: one re-run recovers it.

If a legacy-salt migration was interrupted after step 3 and the nodes were restarted with the new key, everything keeps working and the unfinished rotation is easy to forget. A later rotation from that key to another still succeeds when every DEK was reached (DEKs under the pending salt are recognised). If some DEK was not reached, it is still under the key before that, and the rotation refuses with a message saying to finish the earlier rotation first — from the previous key to the current one.

A node that is about to wrap a new DEK checks the KEK check value both before and after inserting it. A node that stalled across a rotation's announcement takes the DEK it just inserted back out, before anything is sealed with it, and fails the request. Otherwise a stale replica could leave a DEK — and, during a DEK rotation, every secret swept onto it — under a KEK nobody runs any more.

#### Lost master key

The KEK check value records which KEK the vault uses, and every node refuses to wrap a new DEK under any other. That is what protects a rotation from replicas still on the retired key. From a node's point of view, though, a replica that simply has not been restarted with a rotated key looks exactly like an operator who lost the old key and configured a new one. After a lost key, every new secret for every tenant is refused until the operator decides which case applies. That decision cannot be made automatically.

When the previous master key is **gone for good**:

1. Start EDDI with the new `EDDI_VAULT_MASTER_KEY`.
2. `POST /secretstore/secrets/admin/adopt-master-key?confirm=true`. This does the following:
   * re-announces the check value with the configured key;
   * if the reserved `__eddi-system` tenant's DEKs no longer open, discards them and every sealed system value (the audit ledger pins a new key — see [audit-ledger.md](/security-and-compliance/audit-ledger.md#signing-keys-and-rotation)), and likewise the keyed-checksum key if it no longer unwraps (`checksumKeyReset`; a new one is created on the next store);
   * answers with `tenantsNeedingReset`, the tenants whose DEKs the key cannot open.
3. `POST /secretstore/secrets/{tenantId}/reset?confirm=true` for each of them, then store the secrets again.

Never call it while a KEK rotation is merely unfinished, or because one replica was not restarted: in both cases the previous key still exists and `rotate-kek` recovers everything. Adopting the wrong key makes every other replica refuse new DEKs instead.

One case needs no call. A vault that holds no DEKs at all — started once with one key, restarted with another before anything was stored — adopts the configured key at startup, because nothing can be stranded.

#### Resetting a tenant

`POST /{tenantId}/reset?confirm=true` deletes every secret and every DEK generation of the tenant, and — before the DEKs go — everything else sealed with them, such as the tenant's [OAuth connection grants](/protocols-and-integration/connections.md); their users reconnect. Left in place those values were not merely unreadable: the tenant's next DEK is generation 1 again, with the same `dekId`, so every later read opened them with the wrong key and failed authentication on every request. If discarding them fails, the reset stops with the DEKs still in place and can be re-run. The reserved tenant `__eddi-system`, which seals EDDI's own system values (the audit ledger's pinned key), cannot be reset.

#### Schema

Generations need one column and one index, and both are created on boot. Nothing to run by hand on either backend:

| Backend    | On boot                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PostgreSQL | Adds `generation INTEGER NOT NULL DEFAULT 1` to `secret_vault_deks`, drops the column-level `UNIQUE (tenant_id)` constraint, and creates a unique index on `(tenant_id, generation)`. The old constraint is the blocker: while it stands a tenant cannot hold a second generation, so rotation has nowhere to write. It is an index rather than a constraint because only an index can be declared `IF NOT EXISTS`, and Postgres accepts one as an `ON CONFLICT` target just the same. |
| MongoDB    | Backfills `generation` on existing DEK documents, *then* drops the legacy unique-on-`tenantId` index and creates a unique compound index on `(tenantId, generation)`. That order matters — the backfill runs first so every pre-generation document has a generation to be indexed on.                                                                                                                                                                                                 |

A row written before generations existed reads as generation 1, which is what lets every already-stored ciphertext keep working with no migration of the ciphertext itself. During a rolling upgrade the two spellings coexist: a document with no `generation` field and a document holding `generation: 1` both match generation 1.

### Input Validation

All path parameters (`tenantId`, `keyName`) are validated against `[a-zA-Z0-9._-]{1,128}` to prevent path traversal attacks.

## Observability

### Micrometer Metrics

The vault emits metrics under the `eddi.vault.*` namespace for Grafana/Prometheus monitoring:

#### SecretResolver Metrics

| Metric                      | Type    | Description                             |
| --------------------------- | ------- | --------------------------------------- |
| `eddi.vault.cache.hits`     | Counter | Number of cache hits                    |
| `eddi.vault.cache.misses`   | Counter | Number of cache misses                  |
| `eddi.vault.resolve.errors` | Counter | Resolution failures (not-found, errors) |
| `eddi.vault.resolve.time`   | Timer   | Duration of provider resolution calls   |

#### VaultSecretProvider Metrics

| Metric                          | Type    | Description                                                                                          |
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `eddi.vault.resolve.count`      | Counter | Total resolve operations                                                                             |
| `eddi.vault.store.count`        | Counter | Total store operations                                                                               |
| `eddi.vault.delete.count`       | Counter | Total delete operations                                                                              |
| `eddi.vault.rotate.count`       | Counter | Total rotation operations (DEK + KEK)                                                                |
| `eddi.vault.grant.update.count` | Counter | Successful grant edits (`PUT …/grant`) — counted apart from stores so a spike in widening is visible |
| `eddi.vault.errors.count`       | Counter | Total error count (persistence + crypto)                                                             |
| `eddi.vault.resolve.duration`   | Timer   | Duration of resolve operations                                                                       |
| `eddi.vault.store.duration`     | Timer   | Duration of store operations                                                                         |

## Manager — Secrets Admin Page

The EDDI Manager includes a dedicated **Secrets Admin** page at `/manage/secrets` for managing vault entries through the UI.

### Features

* **Namespace filtering** — select tenant ID to scope the view
* **Secrets table** — displays `keyName`, `createdAt`, and `lastAccessedAt` (the checksum is internal and not returned over the API)
* **Add Secret** — dialog with masked password input (eye toggle, `autoComplete="new-password"`)
* **Delete Secret** — confirmation dialog before permanent deletion
* **Vault Health** — live status badge showing vault online/offline state

### Security

* `autoComplete="off"` on key name input prevents browser caching
* `autoComplete="new-password"` on value input prevents browser caching
* React state is cleared immediately on dialog close or submission
* Secret values are **never displayed** — the API only returns metadata

## Security Measures

### Log Redaction

`SecretRedactionFilter` applies pre-compiled regex patterns to all log messages:

| Pattern                                                                                                                                                                           | Replacement                             | Example                                                                                                                                                                                                          |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI keys (`sk-...`)                                                                                                                                                            | `sk-<REDACTED>`                         | `sk-abc123...` → `sk-<REDACTED>`                                                                                                                                                                                 |
| Anthropic keys (`sk-ant-...`)                                                                                                                                                     | `sk-ant-<REDACTED>`                     | `sk-ant-api03-...` → `sk-ant-<REDACTED>`                                                                                                                                                                         |
| Bearer tokens                                                                                                                                                                     | `Bearer <REDACTED>`                     | `Bearer eyJhb...` → `Bearer <REDACTED>`                                                                                                                                                                          |
| API key params                                                                                                                                                                    | `apikey=<REDACTED>`                     | `apikey=secret123` → `apikey=<REDACTED>`                                                                                                                                                                         |
| Google (`AIza…`), xAI (`xai-…`), Groq (`gsk_…`), GitHub (`ghp_…`, `github_pat_…`), Slack (`xoxb-…` and siblings), AWS access key ids (`AKIA…`/`ASIA…`), bare JWTs (`eyJ….eyJ….…`) | `<REDACTED>`                            | `xoxb-1234-…` → `<REDACTED>`                                                                                                                                                                                     |
| `Authorization: Basic\|Token\|Digest …`                                                                                                                                           | `Authorization: Basic <REDACTED>`       | the credential after the scheme word, which the name rule (stopping at the space) used to leave. A `Digest` header loses its whole directive list (`username`, `nonce`, `response`, …), not only the first token |
| `Basic <base64>` with no header name in front                                                                                                                                     | `Basic <REDACTED>`                      | only when the token decodes to `user:password` (printable text around a colon), so `Basic configuration` stays prose                                                                                             |
| `Ocp-Apim-Subscription-Key: …` (`api-key:` / `x-api-key:` are matched by the API key rule)                                                                                        | `Ocp-Apim-Subscription-Key: <REDACTED>` |                                                                                                                                                                                                                  |
| Database connection-string password (`mongodb(+srv)://`, `postgres(ql)://`, `mysql://`, `redis(s)://`, `amqp(s)://`)                                                              | `mongodb://user:<REDACTED>@host`        | the host stays legible                                                                                                                                                                                           |

> A bare `${vault:...}` reference is deliberately **not** redacted — it is a pointer, not a secret, and hiding it removes exactly the information an approver needs to judge a request. A reference followed by extra value material (`${vault:k}SECRET-TAIL`) **is** redacted as a whole.

### Export Sanitization

`SecretScrubber` removes plaintext secrets from agent export (backup) payloads — detected by field name, by credentials embedded in a URL, and by Shannon entropy — replacing each with `${vault:REDACTED}`. This prevents secrets from leaking when agents are shared or exported. Existing `${vault:...}` / `${eddivault:...}` references are deliberately left intact: a reference is a pointer to a secret, not the secret itself.

### Configuration Read Redaction

`ConfigSecretRedactor` is applied by the LLM, MCP-calls, httpcalls, RAG and channel stores whenever a caller who is not `eddi-admin` reads a document (including `?version=` reads and MCP `read_resource`). A plaintext credential — by field name anywhere in the document, by header name in a header map, as a userinfo password or credential-named query value in a URL, or by shape in an httpcall body — is replaced; a `${vault:…}`, `${vars:…}`, `${connection:…}` or `${caller:…}` reference is returned as it is. A write that carries the marker back is refused with a `400` naming the fields. Details in [Security → Editors never read a plaintext credential back](/security-and-compliance/security.md#editors-never-read-a-plaintext-credential-back).

### Memory Protection

* **HTTP headers**: Sensitive headers (`Authorization`, `X-Api-Key`, etc.) are scrubbed before storing HTTP request details in conversation memory
* **Property values**: Secret-scoped properties store only vault references, never plaintext
* **User input**: When `scope == secret`, the raw `input:initial` is removed from the conversation step

### Persistence Error Handling

Both MongoDB and PostgreSQL persistence implementations wrap all database exceptions in `PersistenceException` (unchecked). This ensures:

* Consistent error handling across database backends
* No silent failures — all persistence errors surface to the caller
* Clear error messages with context (tenant ID, key name, operation)

## Testing

\~100 tests across backend and frontend:

### Backend (\~80 tests)

| Test Class                    | Tests | Coverage                                                                                           |
| ----------------------------- | ----- | -------------------------------------------------------------------------------------------------- |
| `SecretVaultIntegrationTest`  | 22    | Full round-trip, negative caching, DEK/KEK rotation, metrics, exceptions                           |
| `VaultSecretProviderTest`     | 11    | Store, resolve, delete, metadata, list, unavailable states                                         |
| `SecretResolverTest`          | 10    | Single/multiple/nested resolution, caching, passthrough, auto-vault keys                           |
| `RestSecretStoreTest`         | 22    | All endpoints, validation, error codes, vault unavailable, rotation                                |
| `EnvelopeCryptoTest`          | 9     | Encrypt/decrypt, key rotation, wrong key, tampering, large payloads                                |
| `SecretRedactionFilterTest`   | 6     | All 5 regex patterns, null/empty, safe messages                                                    |
| `SecretScrubberTest`          | 4     | Nested object scrubbing, preservation of non-secret fields                                         |
| `SecretReferenceTest`         | 6+    | Parsing, equality, hash, invalid references                                                        |
| `ConversationSecretInputTest` | 5     | Secret context scrubbing, normal passthrough, false flag, empty context, output vs. lifecycle data |

### Frontend (17 tests)

| Test File                       | Tests | Coverage                                                                                                          |
| ------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- |
| `secrets.test.tsx` (Manager)    | 12    | Page render, tenant inputs, vault health, create dialog (password, autocomplete, eye toggle), delete confirmation |
| `chat-store.test.tsx` (Chat UI) | 5     | `SET_INPUT_FIELD`, `CLEAR_INPUT_FIELD`, `TOGGLE_SECRET_MODE`, `CLEAR_MESSAGES` reset, initial defaults            |
