## Diagnostic toolbox

When a decision surprises you, reach for these tools in order:

1. **`engine.explain()`** - returns the full evaluation trace: which
   policy matched, which rule, which condition passed or failed, and
   why. Dev mode only. The single most useful tool for debugging.
2. **`validateRoles()` + `validatePolicy()`** - emit typed `IamValidate.IIssue`
   objects with closed-set codes. Run at boot.
3. **`engine.stats.get()`** - counters for cache hits, evaluations,
   errors, denies.
4. **`engine.healthCheck()`** - pings the adapter, reports staleness.
5. **`engine.preload()`** - eagerly resolve roles and policies (catches
   adapter errors before the first user request).

Always start with `explain()`. If a check returns the wrong decision,
nine times out of ten the explain trace shows exactly which condition
fired.

## Common errors

### "My role's permissions don't apply"

**Symptoms**: `engine.can({ subject: { roles: ['editor'] }, action: 'update', resource: 'post' })` returns `false`, but `editor` should be allowed.

**Causes & fixes**:

* The role isn't loaded. Check `await engine.preload()` runs before
  the call and the adapter actually returns the role.
* The role name has a typo. `defineRole('editor')` then assign
  `'edtior'` - the type check would normally catch this; if you're
  using `as` casts, you've defeated the safety net.
* Inheritance is broken. `editor.inherits('viewer')` requires `viewer`
  to exist in the adapter. Missing inheritance silently no-ops.
* The action / resource isn't declared in `defineIam`. The
  config is the closed set; arbitrary strings won't trip rules.
* A `deny` policy is shadowing the allow. Run `explain()` and look at
  the cross-policy combination decision.

### "Policy conditions never match"

**Symptoms**: A policy with `condition: when().equals('subject.id', '$resource.attributes.ownerId')` always returns deny.

**Causes & fixes**:

* The `$resource` path is wrong. Run `explain()` and check the
  `conditionContext` snapshot - it shows the actual values that were
  compared.
* The resource was passed as a string, not an object. `engine.can({ resource: 'post' })`
  binds `resource.type = 'post'` and `resource.attributes = {}`.
  Conditions reading `$resource.attributes.ownerId` then see `undefined`.
  Pass `{ type: 'post', attributes: post }` instead.
* The condition compares the wrong type. `when().equals('subject.id', '$resource.attributes.ownerId')`
  with `subject.id: 'user-1'` and `resource.attributes.ownerId: 1` (a
  number) returns false even though they look the same. iam compares
  values with strict equality, no coercion.

### "Type error: Argument of type 'X' is not assignable"

**Symptoms**: `engine.can({ action: 'delete', resource: 'post' })` flags
a TS error.

**Causes & fixes**:

* The `action` or `resource` isn't in your `defineIam`. Add
  it to the const-asserted array.
* You're using `engine.can` without a typed engine. Construct via
  `defineIam(...).createEngine({ ... })` to propagate types.
* You're passing dynamic values that lose their literal type. Mark
  them `as const` or assert against your config's union types.

### "POLICY\_LIMIT\_EXCEEDED"

**Symptoms**: `validatePolicy()` returns `{ code: 'POLICY_LIMIT_EXCEEDED' }`.

**Cause**: A single policy exceeds `IAM_POLICY_LIMITS` (number of rules,
actions per rule, resources per rule). The defaults are generous (50
rules, 100 actions, 100 resources). If you've hit them, the policy is
likely a "god policy" that mixes too many domains - split it.

### "MAX\_INHERITANCE\_DEPTH\_EXCEEDED"

**Symptoms**: `validateRoles()` returns this code.

**Cause**: A role's `inherits` chain is longer than `MAX_INHERITANCE_DEPTH`
(default 16). Often a sign of accidental cyclic inheritance - `admin`
-> `editor` -> `admin` - that gets unrolled past the limit.

**Fix**: Run `validateRoles()` and look at the `cycle` field; remove
the offending edge.

## Performance debugging

### "Checks are slow"

`engine.stats.get()` shows you cache hits vs misses. If the
miss rate is high, raise the LRU sizes:

```typescript
createEngine({
  cache: {
    policy: { max: 1000 },     // default 100
    role: { max: 1000 },       // default 100
    subject: { max: 10_000 },  // default 1000
    ttlMs: 60_000,             // default 30s
  },
})
```

If `engine.stats.get().errors` is non-zero, look at the adapter logs -
an intermittent network error can fall through to fail-open by
default; opt in to `failOpen: false` for stricter behaviour.

### "Cross-instance cache drift"

If two replicas disagree on a permission check because one's cache is
stale, wire an invalidator:

```typescript
import { IamRedisInvalidator } from '@gentleduck/iam/integrations/invalidators/redis'

createEngine({
  invalidator: new RedisInvalidator(redis, { channel: 'iam:invalidate' }),
})
```

Every mutation publishes an invalidation event; every replica clears
the affected cache entries.

See [Redis invalidator ->](/duck-iam/integrations/invalidators/redis).

## Production gotchas

* **Don't forget `await engine.preload()`** at boot, or your first
  user request pays the full adapter cold-start.
* **Use production mode for high-throughput**. Dev mode returns rich
  decision objects (alloc-heavy); production mode returns plain
  booleans. See [Development vs production mode](/duck-iam/advanced/engine/modes)
  and [Benchmarks](/duck-iam/benchmarks).
* **Wire `onError` and `onPolicyError`** so silent adapter failures
  don't lock everyone out without a trace.
* **Test your policies**. The engine ships an `engine.test()` helper
  (dev only) that asserts decisions against expected outcomes - pair
  with vitest for regression tests.

## Getting help

* Check [`engine.explain()`](/duck-iam/advanced/explain) first.
* File issues at
  [github.com/gentleeduck/gentleduck/issues](https://github.com/gentleeduck/gentleduck/issues).
* For pre-1.0 questions, the [course chapters](/duck-iam/course/chapter-1)
  cover most onboarding scenarios.