The behavior diff
calm diff compares two versions of your app by what they do, not by the lines that changed: routes added or removed, policies loosened or tightened, fields that became visible to more people, environment variables the code now reads, hosts it can reach, events it emits, blocks and invariants. It is the unit a person can review in a minute, whatever an agent changed underneath.
npx calm diff # the working tree against the last commit
npx calm diff main # against the main branch
npx calm diff --ci # exit 1 when access got wider (for pipelines) Behavior changes since main (5)
+ POST /api/refunds policy: admin app/resources/refund/refund.routes.js
~ GET /api/orders/:id policy: owner | admin -> authenticated looser app/resources/order/order.resource.js
~ user.phone visible to: the owner and admins -> everyone looser, personal data
+ env STRIPE_SECRET_KEY required in production, secret app/blocks/stripe-billing/index.js
+ calls api.stripe.com reachable from the code app/blocks/stripe-billing/index.js
2 of 5 widen access. `calm diff --json` for CI, `calm diff --ci` to fail the build on wider access.How it works#
The diff is built from two behavior snapshots. A snapshot is plain data computed from the same definitions the app serves: every route with the access each persona has (anonymous, signed-in user, owner, each custom role, admin), every resource field with who may read and write it (the field rule combined with the resource's policies, so it says what the API actually returns), the environment variables blocks declare, the installed blocks and the invariants. On top of that the CLI scans your app/ and calm/ source for URL literals (hosts the code can reach), emit('...') calls (events) and environment variables read by app/ code.
To compare with a git ref, calm diff checks the ref out into a temporary worktree, loads that version of the project the way calm routes does (a child process, no database connection, no server) and removes the worktree again. Nothing is sent anywhere.
You can also read a snapshot at runtime: await app.snapshot() in code, GET /_calm/api/snapshot in devtools.
What counts as wider access#
| Change | Reported as |
|---|---|
A route's policy lets a persona do more (owner to authenticated, admin to public) | looser |
| A new route that anyone but admins can call | looser |
A field readable or writable by more personas, or no longer .encrypted() | looser (plus personal data when the field is marked .pii() or looks personal) |
| A route lost its rate limit | looser |
An invariant removed from calm.config.js | looser |
| A route or resource removed, a policy tightened, an invariant added | tighter |
| A new environment variable, host, event or block | listed, no direction |
A change that is looser for one persona and tighter for another is reported as looser for some. Custom policy functions cannot be read from a label, so a route whose policy becomes or stops being a function counts as wider too.
--ci fails the build when at least one change widens access. A widening that is intended still has to pass: run the diff locally, read it, and commit; the next CI run compares against the new base.
Snapshot files#
Without git, or to keep a record, write the snapshot to a file and compare against it later:
npx calm diff --save behavior.json # write the current snapshot
npx calm diff behavior.json # compare the working tree with itThe file is JSON you can read and commit. Its shape is stable within a major version (version: 1).
Options#
| Option | Meaning |
|---|---|
[ref] | A git ref (branch, tag, commit) or a snapshot file. Default HEAD |
--ci | Exit with code 1 when a change widens access |
--json | Machine readable: { since, changes, summary: { total, widen, tighten } } |
--save <file> | Write the current snapshot to a file |
Each change in the JSON has kind (route, field, resource, env, host, event, block, invariant), change (added, removed, changed), key, detail, direction (looser, tighter, mixed or null), file, personal, and the before and after entries.
For agents#
The MCP tool calm_diff runs the same comparison (ref optional) and returns the JSON, so an agent can show you what it changed in behavior terms before it finishes. AGENTS.md asks agents to run it.
Limits#
- Access is judged from policies and field rules. A service
scope()or custom code can hide records beyond what the policy says; the diff does not see that,calm auditdoes. - Hosts and events come from string literals in your source. A URL built at runtime is not listed.
- The ref must contain a CalmAPI 2 project (a
calm.lock). Projects whosecalm/predatescalm diffare loaded with less detail: route access comes from policy labels and field visibility from the exposure matrix.