## Two modes

The `mode` config option chooses the evaluation path:

```typescript
const engine = new IamEngine({
  adapter,
  mode: 'production', // or 'development' (default)
})
```

| Mode | Path | Output | Use for |
| --- | --- | --- | --- |
| `'development'` (default) | `evaluate()` | Full `AccessControl.IDecision` with timing, rule/policy refs, reason | Local dev, `explain()`, debugging |
| `'production'` | `evaluateFast()` | Plain `boolean` - no allocations | Deployed services |

***

## What changes

### development mode

* `engine.authorize()` returns `AccessControl.IDecision` (object with `allowed`, `effect`, `rule`, `policy`, `reason`, `duration`, `timestamp`)
* `engine.check()` returns `AccessControl.IDecision`
* `engine.explain()` works (full evaluation trace available)
* `afterEvaluate` and `onDeny` hooks receive the `IDecision` object
* Timing is recorded - `duration` is the actual evaluation milliseconds
* All allocations happen - matching rule arrays, intermediate effect tags, etc.

### production mode

* `engine.authorize()` returns plain `boolean`
* `engine.check()` returns plain `boolean` (same as `can()`)
* `engine.explain()` **throws** (`Error: explain() is not available in production mode`)
* `afterEvaluate` and `onDeny` hooks never fire (the engine takes the fast path that skips `IDecision` construction)
* `onMetrics`, `onError`, and `onPolicyError` still fire - see [hooks](/duck-iam/advanced/engine/hooks)
* No `duration` timing recorded on the path itself; use `onMetrics` for telemetry
* Zero-allocation hot path - combined action+resource index, pre-computed unconditional rules

`engine.can()` always returns boolean regardless of mode - it's the simple-API method that works the same way in both modes.

The `IamEngine` constructor refuses two combinations when `mode: 'production'` is set:

* `policyCombine: 'first-applicable'` - the production fast path returns a plain boolean and cannot distinguish "rule fired" from "default applied", so first-applicable is rejected outright.
* `defaultEffect: 'allow'` without `allowFailOpen: true` - production never falls back to allow-by-default unless you opt in explicitly.

Both cases throw at construction time so misconfigurations fail loudly before serving traffic.

***

## Performance difference

Benchmark numbers from `bun run benchmark`:

| Operation | development | production | Ratio |
| --- | --- | --- | --- |
| `engine.can()` simple RBAC | ~5 us | ~2 us | 2.5x faster |
| `engine.authorize()` full | ~6 us | ~1.5 us | 4x faster |
| `engine.permissions()` 20 checks | ~21 us | ~12 us | 1.7x faster |
| `evaluatePolicyFast()` (one policy) | ~1 us | ~0.5 us | 2x faster |

Production mode is ~2x faster than development on simple checks. The gap widens for batch operations because the per-check `IDecision` allocation cost compounds.

For raw single-policy lookups, duck-iam in production mode is ~2x slower than CASL (8.2M ops/sec vs CASL's 16.8M). CASL pre-compiles rules at build time; duck-iam supports runtime-updatable policies, which costs an extra Map lookup per check.

***

## When to switch modes

Default to development mode in:

* Local dev
* Test suites (you want `explain()` for failing tests)
* Staging environments
* CI

Switch to production mode in:

* Production deployments serving live traffic
* High-throughput batch jobs
* Edge runtimes where every microsecond matters

You can mix - different engines for different routes if you really care:

```typescript
const debugEngine = new IamEngine({ adapter, mode: 'development' })
const prodEngine = new IamEngine({ adapter, mode: 'production' })

app.use('/api/debug', accessMiddleware(debugEngine))
app.use('/api', accessMiddleware(prodEngine))
```

But this is rare. Default to one mode per process.

***

## Detecting mode at runtime

The engine exposes its mode via the type system:

```typescript
const engine = new IamEngine<MyAction, MyResource, MyRole, MyScope, 'production'>({
  adapter,
  mode: 'production',
})

// `engine.authorize()` returns boolean (typed)
// `engine.explain()` is unavailable at the type level (and throws at runtime)
```

The mode is a **type parameter** of `IamEngine` - calling `explain()` on a `'production'`-mode engine is a compile-time error if you've declared the mode in the generic. At runtime, it throws if called regardless of the static type.

***

## Caveat: development hooks vs production fast path

`afterEvaluate` and `onDeny` receive the `IDecision` object - only constructed in development mode. In production mode these two hooks **don't fire** because the fast path skips `IDecision` construction entirely.

`onMetrics` is the production-safe telemetry hook: it gets primitive-only events (`subjectId`, `action`, `resource`, `allowed`, `durationMs`, `mode`) on every terminal path in both modes. Wire it for latency, hit-rate, and outcome metrics. `onError` and `onPolicyError` also fire in both modes.

If you need full-decision auditing in production:

* Either run development mode (and pay the ~2x cost)
* Or wire `onMetrics` for the boolean outcome plus timing and emit your own audit record from there

For batched audit logging, build it into your route handlers or middleware around `engine.can()` instead of relying on engine hooks.

***

## Recommended setup

For most apps, development mode is the right default. The 2x performance gap doesn't matter unless you're doing 100k+ checks per second.

```typescript
// Most apps:
const engine = new IamEngine({ adapter })

// Only if you're chasing microseconds:
const engine = new IamEngine({ adapter, mode: 'production' })
```

When in doubt, benchmark your actual workload. Adapter latency (DB round-trips, Redis network hops) usually dominates engine evaluation time - and that doesn't change between modes.