Troubleshooting
Common errors, what causes them, and how to fix them. Plus the right knobs for diagnosing your own conditions.
Diagnostic toolbox
When a decision surprises you, reach for these tools in order:
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.validateRoles()+validatePolicy()- emit typedIamValidate.IIssueobjects with closed-set codes. Run at boot.engine.stats.get()- counters for cache hits, evaluations, errors, denies.engine.healthCheck()- pings the adapter, reports staleness.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 usingascasts, you've defeated the safety net. - Inheritance is broken.
editor.inherits('viewer')requiresviewerto 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
denypolicy is shadowing the allow. Runexplain()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
$resourcepath is wrong. Runexplain()and check theconditionContextsnapshot - it shows the actual values that were compared. - The resource was passed as a string, not an object.
engine.can({ resource: 'post' })bindsresource.type = 'post'andresource.attributes = {}. Conditions reading$resource.attributes.ownerIdthen seeundefined. Pass{ type: 'post', attributes: post }instead. - The condition compares the wrong type.
when().equals('subject.id', '$resource.attributes.ownerId')withsubject.id: 'user-1'andresource.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
actionorresourceisn't in yourdefineIam. Add it to the const-asserted array. - You're using
engine.canwithout a typed engine. Construct viadefineIam(...).createEngine({ ... })to propagate types. - You're passing dynamic values that lose their literal type. Mark
them
as constor 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:
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:
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 ->.
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 and Benchmarks.
- Wire
onErrorandonPolicyErrorso 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()first. - File issues at github.com/gentleeduck/gentleduck/issues.
- For pre-1.0 questions, the course chapters cover most onboarding scenarios.