# Exposure budget

The [exposure matrix](/security#exposure-matrix) says who may do what in your API right now. The exposure budget writes it down: `calm audit --accept` saves the matrix to `security.snapshot.json`, you commit it, and from then on any access wider than the file is an error, in CI and in your editor's terminal, until a person accepts it again and says why.

```bash
npx calm audit --accept                                   # the first snapshot
npx calm audit --accept --reason "Products are public for the storefront"
npx calm audit --ci                                       # fails when access is wider than the snapshot
npx calm doctor --ci                                      # so does doctor
```

## Accepting

```bash
npx calm audit --accept
```

```text
✔ security.snapshot.json written: 2 resources, 4 personas, accepted by Ada Lovelace. Commit it.
```

Accepting boots the app the way `calm routes` does, without a database, reads the exposure matrix and writes it to `security.snapshot.json` at the project root. Commit the file.

When access has widened since the snapshot, accepting needs a reason, which is recorded next to your name:

```text
$ npx calm audit --accept
✖ Access widens in 2 places: say why with --reason "..." (it is recorded next to your name)

$ npx calm audit --accept --reason "Visitors can browse posts before they sign up"
! Accepted 2 wider cells:
    post list anonymous: no -> yes
    post.title read anonymous: no -> yes
✔ security.snapshot.json written: 2 resources, 4 personas, accepted by Ada Lovelace. Commit it.
```

Accepting again when nothing changed writes nothing and adds nothing to the log, so the log stays a record of real decisions:

```text
✔ security.snapshot.json already matches who may do what: nothing to accept.
```

Access that only got narrower is accepted without a reason.

| Option | Meaning |
|---|---|
| `--accept` | Write the current matrix to `security.snapshot.json` |
| `--reason <why>` | Why access changed. Required when it widened since the last snapshot. |
| `--as "Name <email>"` | Accept as this person instead of your git user |
| `--json` | `{ file, unchanged, wider, accepted }` |

The person is your git user: `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL`, else `git config user.name` and `user.email`, unless you pass `--as`. Like [signing a review](/review#signing-is-for-a-person), accepting refuses to run in an AI agent's shell:

```text
✖ Accepting wider access is for a person, and this shell belongs to an AI agent (claude-code). Run it in your own terminal.
```

## security.snapshot.json

The file is the plain-text answer to "who can see what", for you, a reviewer or an auditor:

```json
{
  "$comment": "Who may do what in this API. Generated by calm audit --accept; CI fails when access widens beyond it. Commit it.",
  "version": 1,
  "updatedAt": "2026-10-13T09:40:02.118Z",
  "matrix": {
    "personas": ["anonymous", "user", "owner", "admin"],
    "resources": [
      {
        "name": "post",
        "path": "/posts",
        "actions": {
          "list": { "anonymous": "allow", "user": "allow", "owner": "allow", "admin": "allow" },
          "read": { "anonymous": "allow", "user": "allow", "owner": "allow", "admin": "allow" },
          "create": { "anonymous": "deny", "user": "allow", "owner": "allow", "admin": "allow" },
          "update": { "anonymous": "deny", "user": "deny", "owner": "allow", "admin": "allow" },
          "delete": { "anonymous": "deny", "user": "deny", "owner": "allow", "admin": "allow" }
        },
        "fields": {
          "title": {
            "read": { "anonymous": "yes", "user": "yes", "owner": "yes", "admin": "yes" },
            "write": { "anonymous": "yes", "user": "yes", "owner": "yes", "admin": "yes" }
          },
          "slug": {
            "read": { "anonymous": "yes", "user": "yes", "owner": "yes", "admin": "yes" },
            "write": { "anonymous": "never", "user": "never", "owner": "never", "admin": "never" }
          }
        }
      }
    ]
  },
  "accepted": [
    { "by": { "name": "Ada Lovelace", "email": "ada@example.com" }, "at": "2026-10-12T16:02:44.310Z", "reason": "first snapshot", "widened": [] },
    {
      "by": { "name": "Ada Lovelace", "email": "ada@example.com" },
      "at": "2026-10-13T09:40:02.118Z",
      "reason": "Visitors can browse posts before they sign up",
      "widened": ["post list anonymous: no -> yes", "post.title read anonymous: no -> yes"]
    }
  ]
}
```

(Shortened: a real file has every resource and every field.)

- `matrix.personas`: anonymous, a signed-in user, the owner, each custom role, admin.
- `actions`: per action and persona, `allow`, `own` (only their own records), `deny`, `custom` (a policy function) or `off` (the action is turned off).
- `fields`: per field, who may `read` and `write` it (`yes`, `no`, `never`...), and `pii: true` for personal data.
- `accepted`: who accepted, when, why, and which cells it widened. The last 50 entries are kept. The first snapshot gets the reason `first snapshot` when you give none.

## What counts as wider

Each cell (`post list anonymous`, `user.email read user`) is compared with the snapshot. A cell is wider when it allows more than before: `deny` or `no` to `own`, `own` to `allow` or `yes`, a cell that did not exist (a new resource, field or role) that allows anything, and a cell that becomes `custom` (a policy function, whose effect cannot be read from the file). Each one is reported as a line like `post list anonymous: no -> yes` or `invoice.total read user: new -> yes`.

Narrower cells are counted but never fail anything.

## Where it is checked

As soon as `security.snapshot.json` exists:

- **`calm doctor`** reports wider access as an error, so `calm doctor --ci` fails:

  ```text
    ✖ error  Access widened beyond security.snapshot.json in 2 places  security.snapshot.json
             post list anonymous: no -> yes
             post.title read anonymous: no -> yes
             If it is intended: npx calm audit --accept --reason "why". Otherwise fix the policy or field rule.
  ```

- **`calm audit`** ends with the comparison, and `calm audit --ci` fails on wider access as well as on failed checks. Without a snapshot it says `No exposure budget yet. Write one with calm audit --accept, commit security.snapshot.json, and CI will fail when access widens.` Within it: `✔ Within the exposure budget (security.snapshot.json)`. `calm audit --json` has the result under `budget`.
- The devtools **Doctor** panel shows the same error while you work.

A file that is not valid JSON is an error too.

## In CI

```bash
npx calm doctor --ci
MONGODB_TEST_URI=mongodb://127.0.0.1:27017 npx calm audit --ci
```

`calm doctor --ci` needs no database. When a pull request widens access on purpose, a person runs `calm audit --accept --reason "..."` and commits `security.snapshot.json` with the change; the reason and their name stay in the file's history.

The exposure budget, [`calm review --ci`](/review#in-ci) and [`calm diff --ci`](/behavior-diff) catch wider access from three sides: a committed list of who may do what, the last signed review, and a git ref.
