# diff

Source: https://permdock.com/docs/cli/diff

Compare two policies or catalogs, list the permissions, roles, scopes and grants that changed, run fixtures through both with --impact, and exit 1 on a breaking change.

`permdock diff a b` reads two versions of a policy and reports what changed between them: permissions, scopes, roles, and every grant. It classifies each change as breaking or not and exits `1` when any is breaking, so a CI step can hold a pull request that takes access away without anyone reading the TypeScript diff. With `--impact` it also runs the project's fixtures through both policies and prints who loses or gains what.

## Usage [#usage]

```bash
permdock diff permissions.catalog.json src/policy.ts          # committed catalog vs the working tree
permdock diff src/policy.ts src/policy.next.ts --json           # two modules, machine-readable
permdock diff src/policy.ts src/policy.next.ts --impact         # plus the fixtures' outcomes
permdock diff a.catalog.json b.catalog.json                     # two catalogs from two commits
```

Each input is either a `permissions.catalog.json` (any file ending in `.json`, validated with `parseCatalog`) or a module that exports `policy`, which the command turns into a catalog in memory with `buildCatalog`. A catalog written by `permdock collect` with `policy` configured carries a `grants` section ([catalog](/docs/cli/catalog#catalog-json-shape)); one written without the policy does not, and `diff` then compares permissions, scopes and roles only and says so.

| Flag | Meaning |
| --- | --- |
| `--impact` | Load the fixtures and evaluate each one against both policies; both inputs must be modules. A fixture that was `granted` and no longer is counts as breaking (`access-lost`) |
| `--fixtures <file>` | The fixture file for `--impact`; defaults to `rls.fixtures` in the config, then `rls.fixtures.json`. The format is the one [`rls verify`](/docs/cli/rls#verify) reads: `{ subject: { id, roles?, tenant?, memberships? }, row, newRow?, action }` |
| `--json` | Print the report as JSON instead of text |

## What is breaking [#what-is-breaking]

A change is breaking when it can take a decision from `granted` to something else for some subject. The classification reads the grants' structure and does not evaluate rows, so it is conservative: a condition that changed is reported as narrowing even when it widened, because without data the command cannot tell. `--impact` is the precise answer for the subjects and rows the fixtures name.

| Kind | Trigger |
| --- | --- |
| `permission-removed` | A permission key in `a` is absent from `b` |
| `alias-removed` | A key `a` lists in `renamedFrom` is neither a key nor a `renamedFrom` entry in `b`: stored custom roles, scopes and SQL that still name it now deny |
| `level-removed` | A level `a` lists on a permission is gone from that permission in `b`: stored custom roles that pick it now deny the permission ([levels](/docs/concepts/custom-roles#levels)) |
| `scope-removed` | A declared scope in `a` is absent from `b` |
| `role-removed` | A role in `a` is absent from `b`; its grants are not listed again |
| `allow-removed` | An allow with no counterpart in `b` (same permission, role, grantee and scope), on a permission and role that still exist |
| `allow-narrowed` | An allow whose counterpart gained or changed a `where` or `check`, gained or changed an `approval`, lost fields, started later or ends earlier (`validFrom` / `validUntil`), gained or changed a `limit`, changed `purpose`, or became non-portable |
| `deny-added` | A deny in `b` with no counterpart in `a` |
| `deny-changed` | A deny whose body changed in any way, since a looser deny condition denies more rows |
| `delegation-removed` | A policy delegation in `a` (same `from` and `to`) is absent from `b`; the agents it covered lose every delegated permission |
| `delegation-narrowed` | A delegation whose counterpart lost permission keys or whose validity starts later or ends earlier |
| `access-lost` | With `--impact`: a fixture that was `granted` under `a` and is `denied` or `approval-required` under `b` |

Not breaking: a renamed permission, whose old key `b` lists in `renamedFrom` (`a`'s grants are compared under the new key, and the text form prints `old → new (renamed)`); an added permission, level, scope, role, allow or delegation; a removed deny; an approval or condition removed from an allow; a wider field list or validity window; more permission keys on a delegation; a role declaration change (`assignable`, `assigns`, `min`, `max` and so on), which is listed under `roles` as `~ name: declaration changed` but is not an access change on its own.

## Output [#output]

The text form lists each section with `+` for added, `-` for removed and `~` for changed entries, then `impact` when requested and `breaking (n)` with one line per breaking change:

```text
roles
  - auditor
  ~ admin: declaration changed
grants
  + allow post.archive (role member)
  + deny post.read (role member)
  - allow post.publish (role member)
  ~ allow post.delete (role member): approval added
impact (2 fixture(s) change outcome)
  u1 post.publish: granted → denied
  u1 post.delete: granted → approval-required
breaking (5)
  role-removed: role auditor no longer exists
  allow-removed: allow post.publish (role member) removed
  deny-added: deny post.read (role member) added
  allow-narrowed: allow post.delete (role member): approval added
  access-lost: u1 loses post.publish: granted → denied
```

`--json` prints `{ a, b, permissions, levels, scopes, roles, grants?, delegations?, impact?, breaking }`: `permissions` has `added`, `removed` and `renamed` (`{ from, to }`); `levels` has `added` and `removed` as `{ permission, level }` for permissions on both sides, printed as `key@level`; `a` and `b` carry each input's path and catalog `fingerprint`; `grants.added` and `grants.removed` are catalog grant entries, `grants.changed` pairs `before` and `after` with the list of `changes`; `delegations` has the same three lists over the catalog's `delegations` section and is present whenever `grants` is; `impact` rows are `{ action, subject, tenant?, before, after }`; `breaking` entries are `{ kind, permission?, role?, detail }`.

## Exit codes [#exit-codes]

| Code | Meaning |
| --- | --- |
| `0` | No breaking change (there may be non-breaking ones) |
| `1` | At least one breaking change, including `access-lost` from `--impact` |
| `2` | Usage error: fewer or more than two inputs, a missing file, an invalid catalog, `--impact` over a catalog file, or unreadable fixtures |

## Why [#why]

Every other command reads one policy; a review needs two. The catalog already is the policy's JSON twin that CI commits, and reviewers were reading its diff by hand, so the command compares catalogs rather than inventing a second format, and the catalog gained `grants` and `delegations` sections so that the diff could see conditions, approvals, validity and standing delegations instead of only keys and role names. Breaking is defined structurally rather than by evaluation because a diff must run with no subjects, rows or stores on a build machine; the conservative rule ("a changed condition may have narrowed") errs toward holding the pull request, and `--impact` with the project's own fixtures gives the exact answer when the structure alone is ambiguous. The fixture format is the one `rls verify` already uses so a team writes its subjects and rows once. The command never evaluates a real subject, like the rest of the CLI ([CLI](/docs/cli)).

## Related [#related]

* [catalog](/docs/cli/catalog)
* [collect](/docs/cli/collect)
* [rls verify](/docs/cli/rls#verify)
* [Policies](/docs/concepts/policies)
