Development vs production mode
What the mode option actually changes - two different evaluators, two return shapes, two sets of live hooks, and the options each mode refuses
mode does not pick the evaluator. Both modes get their verdict from the compiled table. What mode picks is how much provenance comes back with that verdict, and what it costs to produce it. 'production' (the default) returns a bare boolean. 'development' returns an AccessControl.IDecision and runs the interpreter a second time to fill it in.
One verdict, two amounts of provenance
Production used to evaluate through the table and development through the interpreter, so any disagreement between the two was invisible until it reached production — and it reached production as an allow against a dev run that denied. The table is now authoritative in both modes. Development additionally runs the interpreter, because the table cannot explain itself: a CONST_ALLOW / CONST_DENY cell is a single kind byte and allow is a raw bitmask, so policy identity is erased at compile time. That erasure is the optimisation.
Three details of the development run are easy to get wrong. It is passed onPolicyError: undefined, because the authoritative run already reported — otherwise a handler wired to an alerting pipeline pages twice for one bad policy, in development only. It gets its own signals bag, so a failOpen seen only by the explanatory run cannot rewrite what the authoritative path reported; once the verdicts agree the signals take the union. And a disagreement throws: it means duck-iam has a bug, authorize() catches it and answers a generic fail-closed 'Evaluation error' deny, and the real message also goes to console.error because that deny is the right verdict and a useless diagnostic.
Production does not run the interpreter and therefore cannot detect a disagreement. That asymmetry is deliberate: the second evaluator is the cost the fast path exists to avoid, and development is where the divergence is meant to be caught.
What differs
'development' | 'production' (default) | |
|---|---|---|
| Verdict from | compiled table | compiled table |
| Interpreter also runs | yes, for provenance | no |
authorize() returns | AccessControl.IDecision | boolean |
check() returns | AccessControl.IDecision | boolean |
can() returns | boolean | boolean |
permissions() returns | typed IamClient.PermissionMap | Record<string, boolean> |
decision.reason | the interpreter's real reason | 'Allowed/Denied (production mode; compiled table does not retain policy identity)' |
decision.policy / decision.rule | present | undefined |
decision.failure | 'input' / 'resolution' / 'evaluation' | no object to carry it — use onError |
explain() | works | throws explain() is not available in production mode |
beforeEvaluate | fires | fires |
afterEvaluate / onDeny | fire, with the full decision | fire, with a synthesised verdict-only decision |
onError / onPolicyError / onMetrics / onMutation | fire | fire |
policyCombine: 'first-applicable' | allowed, but costs the table | refused at construction |
| Table/interpreter cross-check | on | off |
explain chunk in the bundle | loaded on first call | never loaded |
can() is the constant in both columns: it unwraps whatever the mode produced and always answers boolean, which makes it the right choice in shared code that must compile against either mode.
The verdict-only decision is built by the engine only when afterEvaluate or onDeny is wired, so a production install that leaves them unset pays no allocation. Those two used to be development-only, which put a denial log — a production concern if there is one — out of reach exactly where operators need it.
defaultEffect: 'allow' throws at construction unless allowFailOpen: true is
also set - in development mode too. The constructor applies the guard
unconditionally, and even with the opt-in it logs a console.warn naming the
engine as fail-open, so an operator grepping startup logs always finds it.Options production refuses
policyCombine: 'first-applicable' throws at construction when combined with mode: 'production':
[@gentleduck/iam:engine] policyCombine 'first-applicable' requires mode 'development';
the production fast path cannot represent it correctly.
lookup() folds its votes with some for 'allow-overrides' and every for everything else, so first-applicable would arrive there as plain 'and'. It never learns which policy voted first, or whether a vote was a real decision rather than a default.
The constructor refuses the combination outright in production. In development it is legal, and the engine handles it by giving up the compiled table entirely: _getCompiledTable() returns null for policyCombine: 'first-applicable' and the whole combine is handed to the interpreter, which implements real XACML first-applicable. Without that, the one mode where the config is legal would be the one where its semantics is not implemented, and every request the two paths disagreed on would come back 'Evaluation error'.
The 32-role cap
The table's RBAC grant mask is a Uint32Array, one bit per role. Past 32 roles the bit index would wrap - 1 << 32 is 1 << 0 in JavaScript - and role 33 would silently inherit role 0's grants. compileTable throws IamRoleLimitExceededError instead.
Because both modes evaluate through the table, the cap applies to both. It is not an error and not a deny: the engine catches that error specifically, warns once, and drops to the interpreter, which answers every question correctly and more slowly. healthCheck() then reports it on compiledTable with ok still true, since what was lost is throughput. Full rationale on the compiled table page.
Types follow the mode
mode is also the fifth type parameter of IamEngine, so the return shapes are statically correct when you declare it:
const prod = new IamEngine<Action, Resource, Role, Scope, 'production'>({
adapter,
mode: 'production',
})
const allowed = await prod.authorize(request) // boolean
await prod.explain('user-1', 'read', post) // compile error
The conditional types are AccessControl.ModeResult<M> (boolean for 'production', AccessControl.IDecision otherwise) and AccessControl.ModePermissionMap<M, ...> (Record<string, boolean> versus the typed IamClient.PermissionMap). explain() declares a this parameter pinned to a development-mode engine, which is how calling it on a production engine fails to compile. It also throws at runtime, so a value typed loosely as IamEngine still fails safely.
The mismatch runs the other way too, and it is the dangerous direction: TMode defaults to 'production', and IConfig.mode is optional, so naming 'development' in the type arguments does not set it. new IamEngine<A, R, Ro, S, 'development'>({ adapter }) runs in production, returns a boolean, and types it as IDecision — reading .allowed off the boolean yields undefined, which is falsy, so the mismatch shows up as assertions that quietly pass rather than as a crash. Four E2E suites in this repo were written that way. Always pass mode explicitly, and declare the type parameter to match.
Choosing a mode
The role catalog is not on this tree. Over 32 roles drops both modes to the interpreter, so it changes throughput, not the mode you should pick.
Development in local development, test suites (where explain() turns a failing assertion into a readable trace), CI and staging — it is also the only place a table/interpreter divergence can be caught, which is the reason to keep it on in CI. Production everywhere else, and in edge runtimes where bundle size matters, since production never loads the explain chunk.
Different engines per route is possible but rarely worth it:
const debugEngine = new IamEngine({ adapter, mode: 'development' })
const prodEngine = new IamEngine<Action, Resource, Role, Scope, 'production'>({ adapter, mode: 'production' })
Two engines means two independent cache sets and two compiled tables, and an admin write through one does not invalidate the other unless they share an invalidator. Prefer one mode per process.
Performance, honestly
Measured 2026-08-29 through the full stack - subject resolution, hooks, scope enrichment, caches and evaluation - with engine.can() on the same machine, two runs for stability:
| Request shape | 'production' | 'development' | Ratio |
|---|---|---|---|
| RBAC grant covered by the role mask | 2.62-2.92M ops/s | 0.89-0.91M ops/s | ~2.9-3.2x |
| Condition-gated ABAC cell | 1.83-1.93M ops/s | 0.90-0.93M ops/s | ~2.0-2.1x |
Development's number is the table lookup plus the interpreter run, since both execute on every development check. That is where the gap comes from — it is the price of provenance and of the cross-check, not two different qualities of evaluator.
The more useful framing: a fully warm production check costs roughly 950 nanoseconds, and the evaluator is about 7% of that. Half the time goes to the subject cache's LRU bookkeeping and roughly a third to the promise chain of four nested async functions. Switching modes optimises the 7%. If authorization is genuinely your bottleneck, the subject cache and the number of adapter round trips are where the time is.
Throughput does not degrade with catalog size in either mode's hot path - the compiled lookup is array indexing regardless of how many roles and policies exist. What constrains production is catalog shape: the 32-role cap, very wide action-by-resource grids, and policies that cannot compile because they use wildcards. See benchmarks for methodology and cross-library comparisons.
Auditing in production mode
afterEvaluate and onDeny fire in production, so a denial log keeps working when mode flips. What it loses is provenance: decision.policy and decision.rule are undefined and reason is a fixed string, so a log built on those fields starts recording nothing without failing. onMutation is unaffected — it is the write-side audit seam and reports the same events in both modes.
If the deciding rule genuinely has to be in the record:
- Stay in development mode and pay for the second evaluator. For most services it is invisible next to database latency.
- Audit at the call site, in your middleware or route handler, around
engine.can(). That is also where you have the request context the engine never sees.
Chart failOpen either way. It is the only signal that distinguishes "allowed because a rule said so" from "allowed because nothing had anything to say and defaultEffect is 'allow'".
Gotchas
- Naming
TModedoes not setmode. The type argument and the config field are independent; pass both. - The compiled table is built lazily. The first
authorize()orpermissions()after boot, or after any policy/role invalidation, pays one table build — in both modes.preload()warms it. - A 33rd role is not an error. It is a silent-until-you-look throughput cliff: one
console.warnat the first trip, thenhealthCheck().compiledTablefor as long as the condition holds. explain()throws even when the static type allowed the call. The runtime check does not trust the generic.- Switching modes does not change verdicts. The same table answers both. Development additionally cross-checks it against the interpreter and throws on a disagreement, which is a bug in duck-iam, not a mode trade-off.