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

# Vault

> Optional per-organization secret store. GraphQL writes metadata only. Decrypt runs on the consumer against a job the webapp enqueued.

Vault is opt-in. Omit `tools.vault` and the process boots without loading the AWS SDK. Development omits vault unless `VAULT_PROVIDER=local`. Once that is set, `VAULT_KMS_CAPABILITY` and `VAULT_LOCAL_MASTER_KEY` are required, with no fallback. The same capability is required in every environment where vault is enabled, including test.

## Authorization

| Action | Who | Gate |
| - | - | - |
| `vaultPut`, `vaultReplace`, `vaultDelete`, `vaultSecrets` | GraphQL | `vault:write`. Denial is HTTP 403 before the resolver. No vault audit. |
| `use()` | Consumer process | `VAULT_KMS_CAPABILITY=decrypt`, then a `vault_jobs` row the webapp inserted in the enqueue transaction. The consumer never inserts that row. |
| Migration | Operational script | Encrypt capability. Seals with GenerateDataKey. Does not call `use()` or KMS Decrypt. |

There is no `vault:use` scope and no GraphQL or HTTP decrypt path. A harness "Test key" action, when built, calls `use()` on the consumer.

`recordVaultJob` is the only writer of `vault_jobs`. A fabricated job id fails `job_not_found`. Another org's job fails `job_org_mismatch`. Neither calls Decrypt.

## Envelope

One data key per secret. The provider only wraps and unwraps that key. Shared code builds one AES-256-GCM envelope (`v`, `alg`, `wrappedDataKey`, `iv`, `authTag`, `ciphertext`). Additional authenticated data is the canonical encryption context `{org_id, name, secret_id}`. AWS KMS receives the same context. A blob moved to another org or row fails closed with `decrypt_failed`.

`local` is allowed only when `NODE_ENV` is `development` or `test`. Production and staging refuse to boot if the provider is `local`. Local wrap still honors the process capability gate.

## Event matrix

| Case | Database | Audit | KMS | Error |
| - | - | - | - | - |
| Put new name | One row | `put` success, process `webapp` | One GenerateDataKey | none |
| Name already exists | Unchanged | none | none | `name_taken` |
| Unique race loser | Loser blob discarded | none for the loser | GenerateDataKey already issued | `name_taken` |
| Replace | Same id, new envelope, previous bytes gone | `replace` success | One GenerateDataKey | none |
| Delete | Hard delete | `delete` success | none | none |
| List | Unchanged | none | none | metadata only |
| `vault:write` denied | Unchanged | none | none | 403, resolver not entered |
| Vault omitted | Unchanged | none | SDK not loaded | `vault_disabled` |
| Encrypt process calls `use()` | Unchanged | `use` failure `capability_mismatch`, process `webapp` | Decrypt not called | `capability_mismatch` |
| Fabricated job | No new row | `use` failure `job_not_found` | none | `job_not_found` |
| Other org's job | Job row unchanged | `use` failure `job_org_mismatch` | none | `job_org_mismatch` |
| Secret in another org | Unchanged | `use` failure `secret_org_mismatch` | none | `secret_org_mismatch` |
| Tampered blob | Unchanged | `use` failure `decrypt_failed` | Decrypt attempted | `decrypt_failed` |
| KMS AccessDenied on decrypt | Unchanged | `use` failure `access_denied` | Decrypt called | `access_denied` |
| KMS AccessDenied on put/replace | No new envelope stored | one failure | GenerateDataKey denied | `access_denied` |
| Same job id twice | Job row stays | two `use` success | two Decrypts | none |
| Replace then delete | No row, name free | replace success, delete success | one GenerateDataKey | none |
| Delete then replace | No insert | delete success, replace `not_found` | none on the replace | `not_found` |
| Delete during in-flight `use()` | Row gone after delete | `use` success, delete success | one Decrypt | later `use` is `not_found` |
| Replace during `use()` | Same id, new envelope | `use` success, replace success | one Decrypt of the old value, one GenerateDataKey | later `use` opens the new envelope |
| Put after delete, same name | New id | delete success, put success | one GenerateDataKey | not `name_taken` |
| Second migration of a sealed name | No second row | no second migrate audit | no second GenerateDataKey | skip |
| Revert after finalize | Vault rows stay | none | none | `journal_finalized` |

`use()` passes a `Buffer` to the callback and zeroes it in `finally`. That wipe is best effort once the caller copies the bytes into a string. The vault module does not coerce the full secret to a string. `last4` is the last four bytes only. Values shorter than 8 bytes are `value_too_short`.

## Key policy

IAM is documented here and is not provisioned by this repo. Use one CMK with automatic rotation on. Require encryption context `org_id`. Split the principals:

* Webapp: `kms:GenerateDataKey` only.
* Consumer: `kms:Decrypt` only.

```json theme={null}
{
  "Effect": "Allow",
  "Action": "kms:Decrypt",
  "Resource": "arn:aws:kms:REGION:ACCOUNT:key/KEY_ID",
  "Condition": { "StringLike": { "kms:EncryptionContext:org_id": "*" } }
}
```

Give the webapp the same condition on `kms:GenerateDataKey` and do not grant it `kms:Decrypt`.

## Alerts and incidents

* Metric `vault.operation` with tags `action` and `outcome` only. Alert on an unusual volume of `action=use`.
* CloudTrail alarm on `Decrypt` for the key ARN: a spike, and `AccessDenied`.
* Logs run through vault redaction (`value`, `plaintext`, `secret`, `ciphertext`, `wrappedDataKey`, `Plaintext`, `CiphertextBlob`).

Incident: disable the IAM access keys, confirm CloudTrail, rotate the CMK, then re-wrap data keys. Switching provider later only re-wraps the data key. The legacy migration script is the only path that decrypts old AES plaintext.

## Legacy keys

After a verified migration, run `migrateOrgSecretsToVault` with `--finalize`. Finalize purges legacy ciphertext from the journal. Then delete `ORG_SECRETS_ENCRYPTION_KEY` and `ORG_SECRETS_ENCRYPTION_KEY_RING` from Railway. Revert restores the legacy row only before finalize. After finalize, `--revert` exits `journal_finalized`.

Refract has no `organization_secrets` table. The script logs a skip and exits 0.


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